zoukankan      html  css  js  c++  java
  • 用C#开发TUXEDO客户端

    0 概述 

        Tuxedo是类似于Message query server的一种东西,它以消息服务器的方式提供一个服务器框架,客户端向服务器发送请求报文,服务器处理之后返回应答报文。当然,服务器有对消息队列的各种管理能力。

        我猜这个东西最初提供给客户的动机是为了保留客户用C开发的业务逻辑,又能方便地把自己的系统改造为面向服务的交易系统, 毕竟客户们的系统已经正常运行多年,该卖给他们一些新东西了。

         因为要与C兼容,还有客户们现存应用的多样性问题,Tuxedo决定在设计上不提供消息语法与语义的支持,只是透明地转发数据块——也就是说,C语言里的char*,struct*, 甚至是void*.

         于是,很多公司当时都上了当,把自己的系统改为了Tuxedo的架构,但是几乎马上的,更好的东西就出来了:jri, web service...新的服务协议都不仅提供通信层的支持,还有语法支持,有些甚至还有语义支持。杯具呀!

        现在,用新技术开发的应用程序很可能会遇上需要与旧的tuxedo服务交互的要求,幸运的是,BEA提供了几种主流语言调用Tuxedo服务的技术,不幸的是,有一些很讨厌的问题存在,主要是C语言的ANSI字符集与现代语言的unicode内码的兼容问题,更别提还有utf-8这种东西……你分得清UTF8和Unicode吗?

        本文说明了以C#调用Tuxedo服务的基本过程,会帮助你完成第一次Tuxedo调用。在后记中,又讨论了字符集可能会引起的问题, 希望能减少你在这条路的摸索时间。     

    1       安装

    1.1     安装版本的选择

    BEA网站可以下载到所有版本的tuxedo服务器与客户端的安装包。我下载了V10.0 for xp版,装在Windows Vista Professional上。

    9.1以后的客户端版本,就开始支持.net的托管代码的访问。在安装完了v10.0之后,察看了一下%TUXDIR\%bin\libscdnet.dll文件,他的版本其实还是9.1,可能在V10.0上这个托管库并没有升级。

    BEA的网站上下载完安装包之后,别忘记了在下载页面里下载许可证文件。

    1.2     安装后的一些调整

    1.2.1 TUXDIR环境变量的修改

    我安装完成之后,客户端程序被安装在了C:\Program Files\BEA Systems\TUXEDO\tuxedo10.0_VS2005路径下,但是TUXDIR变量的设置值不知道什么原因没有指到正确的路径,而是指到了C:\Program Files\BEA Systems\TUXEDO,导致运行程序时出错,经检查C:\Program Files\BEA Systems\TUXEDO下的ULOG日志文件,报告找不到locale文件夹。我试着修改了一下TUXDIR环境变量,但没有作用。

    于是把C:\Program Files\BEA Systems\TUXEDO\tuxedo10.0_VS2005\下的所有文件复制了一份到上一层的C:\Program Files\BEA Systems\TUXEDO,这个问题就解决了。

    1.2.2 PATH环境变量的修改

    PATH里增加对C:\Program Files\BEA Systems\TUXEDO\tuxedo10.0_VS2005\bin目录的指向。

    1.2.3 许可证文件

    把下载到的许可证文件改名为lic.txt,复制到udataobj目录下。由于我们复制了安装目录,就造成了有两个这个文件夹的情况。都复制进去好了。

    2       C#tuxedo客户端程序的基础知识

    2.1     托管类的引用

    %TUXDIR%/bin/libwscdnet.dll.net2.0的托管代码类库,可以通过对这个库的引用来对tuxedo函数进行调用。当建立了一个C#客户端项目后,必须新建一个引用,选择%TUXDIR%/bin/libwscdnet.dll。其命名空间是Bea.Tuxedo;

    2.2     服务器地址等环境变量的设置

    根据网上的说法,有三种方法设置服务器地址:

    一.用环境变量来设置:

    在系统的环境变量中设置WSNADDR=//<ip address>:<port>

    这样做的好处是不必在程序里配置。坏处是只支持一个服务器的连接。

     

    二.tuxreadenv函数

    tuxreadenv函数来从一个配置文件中读取指定的节,作为当前环境变量的设置。

    如:tuxenv.ini.内容格式如下:

    [TUXCOMM]

    TUXDIR=c:\tuxedo

    PATH=%PATH%;c:\tuxedo\bin

    WSADDR=//192.168.0.1:6000

    在程序中使用: tuxreadenv("tuxenv.ini","TUXCOMM");语句来调用。

    C#中,tuxreadenv函数被warpUtils.tuxreadenv()了。

     

    三.tuxputenv函数inline地指定环境变量

    tuxputenv函数可以在程序中直接指定环境变量。如:

    tuxputenv("WSNADDR=//10.1.128.227:9401");

    如果有多个环境变量要设置,可以多次调用这个函数来分别执行设置。

    C#中,tuxputenv函数被warpUtils. tuxputenv ()了。

     

    2.3     高版本客户端调用低于7.1版的服务器的问题

    由于服务器是V6.5,而我装的客户端是10.0,因此存在一个协议兼容性的问题,在运行时报错:protocol error. 经查看ULOG文件,发现在调用7.1以下的服务器时,要设置一个环境变量WSINTOPPRE71的值为“yes”。

    增加对这个环境变量设置的方法,见上节三种方法中的任何一种。

    2.4     调用的一般形式

    客户端调用服务的一般过程为:

    设定环境变量à初始化应用上下文à调用服务à得到结果à关闭应用上下文

    下面是一个最简单的C#客户端:

    //设定环境变量

    Utils.tuxputenv("WSNADDR=//10.1.128.227:9401");

    Utils.tuxputenv("WSINTOPPRE71=yes");

    //初始化应用上下文

    AppContext ac = AppContext.tpinit(null);

    //同步调用服务。

    // 同步调用时,服务器不返回结果或是出错之前,

    // tpcall方法不会返回,程序将等在这里。

    TypedString sndstr = new TypedString(1000);

    sndstr.PutString(0, “hello world!”);

    TypedString rcvstr = new TypedString(1000);

    ac.tpcall("TOUPPER", sndstr, ref rcvstr, 0);

    // 得到结果

    string rcvstr_str = rcvstr.GetString(0, 1000);

    //关闭应用上下文

    ac.tpterm();

    2.5     异步调用服务

    TUXEDO支持异步调用模式。在异步调用方式下,用 tpacall方式调用服务。当异步调用一个服务后,客户端程序不等服务器完成工作就立即继续执行其他工作,只保留一个句柄。等到客户端程序有空的时,再回来用tpgetrply方汉等待已经调过的服务。如下面的程序

    //异步调用服务。得到异步调用描述符 acd.

    AsyncCallDescriptor acd = ac.tpacall("TOUPPER", sndstr, 0);

    // …. 做些其他的事情。

    // 继续刚刚的服务调用,等待结果。这个方法是一个同步函数。

    ac.tpgetrply(ref acd, ref rcvstr, 0);

    string rcvstr_str = rcvstr.GetString();

    2.6     多线程调用

    多线程调用时,需要在应用程序上下文初始化时,加入多线程标志。如下:

    TypedTPINIT tpinfo = new TypedTPINIT();

    tpinfo.flags = TypedTPINIT.TPMULTICONTEXTS;

    AppContext ac = AppContext.tpinit(tpinfo);

    2.7     结构数据传递问题

    Tuxedo windows客户端的原始API是面向C语言的,因此在很多的服务器程序的编写时,会采用struct结构来会传递数据的方案。对于C结构体数据,在tuxedo中对应的消息类型应该是CArray, 在用C#制作客户端时,可以采用TypedCArray这个类型来传递数据,其中,需要特别注意的问题是.netinterop操作时的一些技术细节。

    下面是一个具体的例子:

        [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)]

        public struct MYMSGBODY

        {

            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 0x20)]

            public string usrname; // char usrname[20]

            [MarshalAs(UnmanagedType.U4, SizeConst=4)]

            public uint lLogNo;     // unsigned long int lLogNo;

            [MarshalAs(UnmanagedType.I4)]

            public int iRecNum; //  int iRecNum;

        }

    MSDN上有详细的结构体interop类型对应表可以查阅。这里要解释其中几个重要的地方:

    StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Ansi)

    Pack=4表示以最大4字节边界对齐成员。

    CharSet = CharSet.Ansi表示字串是ansi字串。之后再详述。

    MarshalAs(UnmanagedType.U4, SizeConst=4)
    此属性标记为此string成员是以值传递的,也就是是一个数组,而不是指针。并指定了长度。这个长度是指C串里包括了结尾0的总长度。

    Long类型:这个类型在C.net里的有重要不同,在C中, int的大小根据平台不同有16位长,32位长,(在windows和现代的unix中,一般都是32位), long的长度在windowsunix中一般是32位的,而在.net中,long类型的长度是64位,并且interop很多操作不支持对long型数据的转换,如对结构体取size时,如果有long型字段,就会出现异常,这里要注意。

    内存分配的对齐问题

    这个问题是最难以讲清楚的问题。在C语句的编译器中,都会有一些关于结构体成员如何对齐地址的编译指令或是伪指令,如VC#pragma pack, __declspec( align() )指令等。这些指令指示编译器如何在内存中排布结构体的成员。

    其中,pack=n的意思是:结构体中下一个成员的起始地址,要用 “成员类型的长度和n之中的比较小的那个”来对齐。比如说下面的结构体成员:

    #program pack(8)

    Struct ST_E1{

    char s1[2];   // 0偏移开始, 占到1位置,共2字节

    int i;        // min(sizeof int= 4pack=8)=4, 因此,i的起始地址应该按4对齐

               // 也就是空两个字节,到偏移4处开始,到偏移7,共4字节

    char s2[3]; // min(sizeof char=1, pack=8) = 1, 因此s2的起始地址按1对齐,

              // 也就是从偏移8开始,到10,共3字节。

    Char s3    // 同理,s3占偏移量11,一个字节

    }

    结构体总长度为12个字节长。

    Off

    0

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    V

    S1

    S1

    i

    i

    i

    i

    S2

    S2

    S2

    S3

    如果pack指为1呢?那么分配的方式如下:

    Off

    0

    1

    2

    3

    4

    5

    6

    7

    8

    9

    V

    S1

    S1

    i

    i

    i

    i

    S2

    S2

    S2

    S3

    10个字节长。

    可以看出,如果两边的pack值不一样,那么这个结构体在送到目的地之后就会出现成员偏移乱掉的问题(开始想念web services了吧?但是我们不总是能选择所处的条件的)。因此,一定要检查服务器与客户端的这个编译选项是不是一样的。一般情况下,pack=4是比较常见的情况。

     

    有关更多的内存对齐方式的讨论,可以参见本人另一博客文章及其评论内容。

    http://www.cnblogs.com/haoxiaobo/archive/2005/09/05/230204.html

     

    字符集问题

    字符集是另一个需要注意的兼容性问题。例如上面的成员定义:

     [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 0x20)]

    public string usrname; // char usrname[20]

     

    在大多数面向C语言的API中,基本上是没有charbyte的区别的,但是.net中对于charbyte则有本质的不同,char是指一个与文化相关的符号,而byte指一个8位二进制数的物理存储单位。 一个char需要几个byte来保存,要视字符集编码方式而定,在.netjava里,内部的char都是unicode,一个字符两个字节,而在C中,基本上都是ansi(除了windows nt之后新增的那些 _T类型)。

    由于这个原因,一定要认真考虑服务器系统的字符集编码,否则会导致字符串在interop转换时,产生非常令人生气的结果。

    在结构体的定义时,charset = charset.ansi即通知了interop程序,字符串在向结构体转换时,要用ansi方式进行转换。

     

    更多有关字符集的讨论,请参见本人另一篇博客文章:

    http://xiaobohao.spaces.live.com/blog/cns!D1C72860197EBF38!1250.entry

     

    完整的用结构体内存块数据做消息体,调用tuxedo服务的代码如下:

     

    Bea.Tuxedo.ATMI.Utils.tuxputenv("WSNADDR=//10.1.128.227:9401");

    Bea.Tuxedo.ATMI.Utils.tuxputenv("WSINTOPPRE71=yes");

    AppContext ac = AppContext.tpinit(null);

    RecivedStruct rec; // 这个RecivedSTRUCT即是tuxedo服务所规定的返回消息结构体在C#里的对应定义,请参阅本节之前的说明,对C风格的结构进行C#定义。

    SendStruct app = new SendStruct (); // 这个SENDSTRUCT即是tuxedo服务所规定的调用消息结构体在C#里的对应定义,请参阅本节之前的说明,对C风格的结构进行C#定义。

    app.sOperCode = “…”; // 几个示意成员

    app.sDeptCode = “…”;

    //…

    // 开始把C#结构体的内容复制为byte[]

    // 取得长度

    int iAppLen = Marshal.SizeOf(app); 

    int iRecLen = Marshal.SizeOf(typeof(RecivedStruct));

    TypedCArray tbSend = new TypedCArray(iAppLen);

    TypedBuffer tbRecive = new TypedCArray(iRecLen);

    byte[] arAppData = new byte[iAppLen];

    // 分配一个系统堆内存, 并用于一个指针来指向之

    IntPtr pApp = Marshal.AllocHGlobal(iAppLen);

    IntPtr pRec = Marshal.AllocHGlobal(iRecLen);

    // 将托管结构复制到此地址指向的内存块中。

    Marshal.StructureToPtr(app, pApp, false);

    // 再将此地址块复制到字节数组中。

    Marshal.Copy(pApp, arAppData, 0, iLen);

    //把此字节数组绑定到要发送数据中。

    tbSend.PutBytes(arAppData);

    try

    {

        // 调用服务,返回一个typedbuffer.

        ac.tpcall(sServiceName, tbSend, ref tbRecive, 0);

        // 开始从这个返回的内容里取出数据。

        // 初始化一个与返回值相同大小的数组。

        byte[] arRecived = new byte[tbRecive.Size];

        // 从返回值对象中取出字节数组。

        ((TypedCArray)tbRecive).GetBytes(arRecived, arRecived.Length);

        // 用相反的步子把数据从字节流中复制到C#结构中。

        Marsal.Copy(arRecived, 0, pRec, iRecLen);

        Rec = Marsal.PtrToStructure(pRec, typeof(RecivedStruct));

    }

    catch (TPException tpex)

    {

        Trace.TraceError(tpex.ToString());

    }

    finally

    {

        ac.tpterm();

        Marshal.FreeHGlobal(pApp);

        Marshal.FreeHGlobal(pRec);

    }

    3       常用Tuxedo类与方法:

    3.1     tpchkauth检查是否需要认证和认证的级别

    int tpchkauth();

    在调用tpinit()之前检查是否需要认证和认证的级别。

     

    返回值:

     

    TPNOAUTH:不需要认证;

    TPSYSAUTH:系统认证,需要密码;

    TPAPPAUTH:应用认证,需要密码和特殊应用数据;

    当返回值为TPSYSAUTH和TPAPPAUTH时,我们必须使用tpalloc()分配一个TPINIT结构,在该结构中填入认证数据,然后用该结构作为参数调用tpinit()。

     

    失败原因主要有:

     

    协议错;

    操作系统错;

    tuxedo底层错。

     

     

    3.2     tpinit初始化

    在使用tuxedo其他服务之前,必须调用tpinit加入到应用中。

     

    int tpinit(TPINIT *tpinfo);

    参数说明:

     

    tpinfo:指向TPINIT类型的指针。

     

    TPINIT类型在atmi.h中有定义,如以下几个域:

     

        char usrname [32]; (32 characters significant)

        char cltname [32]; (32 characters significant)

        char passwd [32]; (8 characters significant)

        char grpname [32]; (32 characters significant)

        long flags;

        long datalen;

        long data;

    usrname:用户名或login名;

     

    cltname:应用定义;

     

    passwd:应用密码;

     

    grpname:在事务中使用,必须在配置文件定义的组列表中;

     

    flags:定义请求/通知类型和系统存取方法,其中TPU_SIG、TPU_DIP和TPU_IGN不能同时指定;TPSA_FASTPATH和TPSA_PROTECTED不能同时指定。有如下的值:

     

    TPU_SIG:选择信号通知;

    TPU_DIP:选择dip-in通知;

    TPU_IGN:忽略通知;

    TPSA_FASTPATH:选择fastpath方式系统存取;

    TPSA_PROTECTED:选择protected方式系统存取;

    datalen:应用特殊数据的长度;

     

    data:应用特殊数据;

     

    域flags的值覆盖系统的缺省定义,前提是在配置文件中没有指定NO_OVERRIDE。

     

    如果参数使用(TPINIT*)NULL,则client使用系统缺省的通知设置和系统存取设置,若需要认证,则出错返回TPEPERM。

     

    tpinit()调用失败返回-1,失败原因有:

     

    参数错;

    没有空间在BB;

    没有权限;

    协议错;

    操作系统错;

    tuxedo底层错。

    示例:

     

    TPINIT *tpinfo;

    char password[9];

    /* prompt user for password */

    if ((tpinfo = (TPINIT *)tpalloc(“TPINIT”, NULL,

                        TPINITNEED(0))) == NULL) {

        (void)userlog(“unable to allocate TPINIT buffer”);

        exit(1);

    }

    (void)strcpy(tpinfo->passwd, password);

    (void)strcpy(tpinfo->usrname, “Smith”);

    (void)strcpy(tpinfo->cltname, “Teller”);

    tpinfo->flags = (TPU_DIP|TPSA_PROTECTED);

    if (tpinit(tpinfo) == -1) {

        (void)userlog(“failed to join application”);

        tpfree((char*)tpinfo);

        exit(1);

    }

     

     

    3.3     tperm离开应用

    使用tuxedo服务完毕,调用tpterm()离开应用。

     

    int tpterm();

    函数出错返回-1。

     

    错误原因有:

     

    协议错;

    操作系统错;

    tuxedo底层错。

     

     

    3.4     tpacall发送异步请求

    发送异步请求。

     

    int tpacall(char *service, char *bufptr, long length,

    long flags);

    参数说明:

     

    service:请求的service名(最大15个字符,以null结尾);

     

    bufptr:请求发送的数据;

     

    length:发送数据长度(只有CARRAY类型用,其他设为0);

     

    flags:发送模式,有如下的值:

    TPNOTRAN:该次调用不能在一个事务里;

    TPNOREPLY:不需要回应(reply);

    TPNOBLOCK:非阻塞;

    TPNOTIME:不超时,一直等待;

    TPSIGRSTRT:被信号中断的系统调用重启。

    成功返回一个非负的描述符,该描述符可用于后续的tpgetrply调用,出错返回-1。

     

    错误原因有:

    参数错;

    当前太多的tpacall处理存在,上限是50;

    事务错;

    超时(time-out);

     

     

    3.5     tpgetrply接收异步回应数据

    接收异步回应数据。

     

    int tpgetrply(int *handle, char **bufpp, long *length,

    long flags)

    参数说明:

     

    handle:tpacall返回的描述符;

    bufpp:接收buffer的地址的地址,原buffer会自动调整;

    length:接收的buffer的长度的地址;

    flags:接收选项。有如下值:

    TPNOBLOCK:非阻塞;

    TPNOTIME:不超时,一直等待;

    TPSIGRSTRT:被信号中断的系统调用重启;

    TPGETANY:接收任何回应;

    TPNOCHANGE:要求接收的回应与发送数据相同。

    成功返回0,失败返回-1。

     

    出错原因:

     

    参数错;

    错误的接收buffer类型;

    超时;

    其他错误;

     

     

    3.6     tpcancel取消由tpacall发送的请求的响应

    取消由tpacall发送的请求的响应,在没有事务未完时。不能取消一个已经处理的请求。

     

    int tpcancel(int handle);

    参数说明:

     

    handle:tpacall返回的描述符;

     

    出错返回-1。错误原因有:

     

    错误的描述符;

    当前在事务模式;

    其他错误;

     

     

    3.7     tpcall同步发送请求并接收回应数据

    同步发送请求并接收回应数据。

     

    int tpcall(char *service, char *sbufp, long slength, \

         char **rbufpp, long *rlength, long flags);

    参数说明:

     

    service:请求的service名;

    bufp:发送buffer的地址;

    slength:发送数据长度(只CARRAY使用,其他为0);

    rbufpp:响应buffer的地址的地址,可以与发送buffer为同一块区域;

    rlength:响应buffer的长度的地址(不能为NULL);

    flags:标志。有如下值(含义见tpacall和tpgetrply):

    TPNOTRAN:该次调用不能在一个事务里;

    TPNOREPLY:不需要回应(reply);

    TPNOBLOCK:非阻塞;

    TPNOTIME:不超时,一直等待;

    TPSIGRSTRT:被信号中断的系统调用重启。

    返回-1表示出错,其他返回值都表示成功。

     

    错误原因与tpacall和tpgetrply相同,除了描述符错。

     

     

     

    3.8     tpgprio获得上一次请求或接收的消息的优先级

    获得上一次请求或接收的消息的优先级。

     

    int tpgprio();

    成功返回的范围是1-100,值越大优先级越高。失败返回-1。

     

    使用举例:

     

    struct {

        int hdl; /* handle*/

        int pr; /* priority*/

    } pa[SIZE];

     

    for (i=0; i < requests; i++) {

        /* Determine service and data for request */

        pa [i].hdl = tpacall(Svc, buf, len, flags);

        /* Save priority used to send request */

        pa[i].pr = tpgprio();

    }

    /* Use qsort(3) routine to sort handles in priority order */

    qsort((char*) pa, requests, sizeof(pa[0]), cmpfcn);

    for (i=0; i< requests; i++) {

        tpgetrply(&pa[i].hdl, &rbufp, &rlen, rflags);

    }

     

    3.9     tpsprio设置下一个要发送的消息的优先级

    设置下一个要发送的消息的优先级。

     

    int tpsprio (int prio, long flags);

    参数说明:

     

    prio:要设置的优先级;

    flags:标志。有如下值:

    0:使用相对优先级,值改为(default+prio);

    TPABSOLUTE:绝对优先级,值改为prio;

    优先级的范围是1-100,超过次限制的值被改为相应的最大(小)值。

     

    失败返回-1。错误原因有TPEINVAL、TPEPROTO、TPESYSTEM、和TPEOS。

    -------------------------------------------------------------------


    后记:   再谈字符集问题

    在tuxedo返回消息的格式定义中,如果采用定长字串组合的方式(表笑,我们的业务系统就是这么干的),会造成一些意外的困难。

    如下:如果返回值格式的定义是:

    %4s%2s // 一个4字节的串,一个2字节的串

    有一个返回值是:

    Aaaabb

    C语言中以sscanf()来结析这个串很容易:

    char s1[5],s2[3];

    Sscanf(“AAAABB”, “%4s%2s”, s1,s2)

    但是在C#, java中,由于没有sscanf这个函数,就不得不手工写截字串的函数,这样的数据还可以应付。但继续考虑以下的情况:

     

    char s1[5],s2[3];

    Sscanf(“中国红”, “%4s%2s”, s1,s2)

     

    在有汉字的情况下,使用ANSI字符集的程序在sscanf之后,结果是s1=”中国”, s2=””.

    而在C#等用unicode字符集的程序中,截取4个字符的结果将会出错,因为整个字串才只有3个字符。

    因此,在unicode程序中对这个字串解析,就必须先把字串从unicode串以GB18030编码转换为字节组,然后再对字节流进行截取,截取的结果,又必须转换为unicode以在程序中处理。

      

    ---------------------------------------------

    再后记:这篇文章是从前的文章,原来在xiaobohao.live.spaces.com中,后来微软放弃了live spaces, 只好用MS提供的迁移功能迁到了wordpress, 然后,悲杯了,你懂的。

    好不容易从墙外找回了这篇文章,加了一点点修改,放到博客园里来。 

  • 相关阅读:
    [转]C#、VB.NET使用HttpWebRequest访问https地址(SSL)的实现
    C#设置System.Net.ServicePointManager.DefaultConnectionLimit,突破Http协议的并发连接数限制
    [转]WebBrowser控件禁用超链接转向、脚本错误提示、默认右键菜单和快捷键
    [转]C#打印DataGridView的例子源码
    c# TreeView 父节点选中/不选时子节点都同步选中/不选
    C#中PictureBox异步加载图片
    [转]FusionCharts 3.1 破解版 – 非常好用的Flash图表控件
    配合JavaScript拖动页面中控件
    在ThinkPad T400上安装win2003 所遇问题
    C# 抛弃MoveTo来实现文件重命名
  • 原文地址:https://www.cnblogs.com/haoxiaobo/p/1922661.html
Copyright © 2011-2022 走看看