zidate

2011年10月10日 #

完成端口

最近玩了下完成端口,分享下心得.
第一个是概念问题:下面是从网上转过来的东西
基本概念
   设备---指windows操作系统上允许通信的任何东西,比如文件、目录、串行口、并行口、邮件槽、命名管道、无名管道、套接字、控制台、逻辑磁盘、物理磁盘等。绝大多数与设备打交道的函数都是CreateFile/ReadFile/WriteFile等,所以我们不能看到**File函数就只想到文件设备

   与设备通信有两种方式,同步方式和异步方式:同步方式,当调用ReadFile这类函数时,函数会等待系统执行完所要求的工作,然后才返回;异步方式,ReadFile这类函数会直接返回,系统自己去完成对设备的操作,然后以某种方式通知完成操作。

   重叠I/O----顾名思义,就是当你调用了某个函数(比如ReadFile)就立刻返回接着做自己的其他动作的时候,系统同时也在对I/0设备进行你所请求的操作,在这段时间内你的程序和系统的内部动作是重叠的,因此有更好的性能。所以,重叠I/O是在异步方式下使用I/O设备的。重叠I/O需要使用的一个非常重要的数据结构:OVERLAPPED

2、WINDOWS完成端口的特点
   Win32重叠I/O(Overlapped I/O)机制允许发起一个操作,并在操作完成之后接收信息。对于那种需要很长时间才能完成的操作来说,重叠IO机制尤其有用,因为发起重叠操作的线程在重叠请求发出后就可以自由地做别的事情了。在WinNT和Win2000上,提供的真正可扩展的I/O模型就是使用完成端口(Completion Port)的重叠I/O。完成端口---是一种WINDOWS内核对象完成端口用于异步方式的重叠I/0情况下,当然重叠I/O不一定非得使用完成端口不可,同样设备内核对象、事件对象、告警I/0等也可使用。但是完成端口内部提供了线程池的管理,可以避免反复创建线程的开销,同时可以根据CPU的个数灵活地决定线程个数,而且可以减少线程调度的次数从而提高性能。其实类似于WSAAsyncSelect和select函数的机制更容易兼容Unix,但是难以实现我们想要的“扩展性”。而且windows完成端口机制在操作系统的内部已经作了优化,从而具备了更高的效率。所以,我们选择完成端口开始我们的服务器程序开发。
   1)发起操作不一定完成:系统会在完成的时候通知你,通过用户在完成端口上的等待,处理操作的结果。所以要有检查完成端口和取操作结果的线程。在完成端口上守候的线程系统有优化,除非在执行的线程发生阻塞,不会有新的线程被激活,以此来减少线程切换造成的性能代价。所以如果程序中没有太多的阻塞操作,就没有必要启动太多的线程,使用CPU数量的两倍,一般这么多线程就够了
   2)操作与相关数据的绑定方式:在提交数据的时候用户对数据打上相应的标记,记录操作的类型,在用户处理操作结果的时候,通过检查自己打的标记和系统的操作结果进行相应的处理。
   3)操作返回的方式:一般操作完成后要通知程序进行后续处理。但写操作可以不通知用户,此时如果用户写操作不能马上完成,写操作的相关数据会被暂存到非交换缓冲区中,在操作完成的时候,系统会自动释放缓冲区,此时发起完写操作,使用的内存就可以释放了。但如果占用非交换缓冲太多会使系统停止响应。

3、完成端口(Completion Ports )相关数据结构和创建
    其实可以把完成端口看成系统维护的一个队列,操作系统把重叠IO操作完成的事件通知放到该队列里,由于是暴露 “操作完成”的事件通知,所以命名为“完成端口”(Completion Ports)。一个socket被创建后,就可以在任何时刻和一个完成端口联系起来。

OVERLAPPED数据结构
typedef struct _OVERLAPPED {
    ULONG_PTR Internal;  //被系统内部赋值,用来表示系统状态
    ULONG_PTR InternalHigh;  //被系统内部赋值,表示传输的字节数
    union {
        struct {
            DWORD Offset;  //与OffsetHigh合成一个64位的整数,用来表示从文件头部的多少字节开始操作 
            DWORD OffsetHigh;  //如果不是对文件I/O来操作,则Offset必须设定为0 
         }; 
       PVOID Pointer;
    }; 
   HANDLE hEvent;  //如果不使用,就务必设为0;否则请赋一个有效的Event句柄
} OVERLAPPED, *LPOVERLAPPED;

下面是异步方式使用ReadFile的一个例子
OVERLAPPED Overlapped;
Overlapped.Offset=345;
Overlapped.OffsetHigh=0;
Overlapped.hEvent=0;
//假定其他参数都已经被初始化
ReadFile(hFile,buffer,sizeof(buffer),&dwNumBytesRead,&Overlapped);
这样就完成了异步方式读文件的操作,然后ReadFile函数返回,由操作系统做自己的事情。

下面介绍几个与OVERLAPPED结构相关的函数。

等待重叠I/0操作完成的函数
BOOL GetOverlappedResult (
HANDLE hFile,
LPOVERLAPPED lpOverlapped, //接受返回的重叠I/0结构
LPDWORD lpcbTransfer, //成功传输了多少字节数
BOOL fWait  //TRUE只有当操作完成才返回,FALSE直接返回,如果操作没有完成,
                    //通过用GetLastError( )函数会返回ERROR_IO_INCOMPLETE
);

HasOverlappedIoCompleted可以帮助我们测试重叠I/0操作是否完成,该宏对OVERLAPPED结构的Internal成员进行了测试,查看是否等于STATUS_PENDING值。

   一般来说,一个应用程序可以创建多个工作线程来处理完成端口上的通知事件。工作线程的数量依赖于程序的具体需要。但是在理想的情况下,应该对应一个CPU 创建一个线程。因为在完成端口理想模型中,每个线程都可以从系统获得一个“原子”性的时间片,轮番运行并检查完成端口,线程的切换是额外的开销。但在实际开发的时候,还要考虑这些线程是否牵涉到其他堵塞操作的情况。如果某线程进行堵塞操作,系统则将其挂起,让别的线程获得运行时间。因此,如果有这样的情况,可以多创建几个线程来尽量利用时间。

创建完成端口的函数
成端口是一个内核对象,使用时它总是要和至少一个有效的设备句柄相关联,完成端口是一个复杂的内核对象,创建它的函数是:
HANDLE CreateIoCompletionPort(
    IN HANDLE FileHandle,
    IN HANDLE ExistingCompletionPort,
    IN ULONG_PTR CompletionKey,
    IN DWORD NumberOfConcurrentThreads
    );

通常创建工作分两步:
第一步,创建一个新的完成端口内核对象,可以使用下面的函数:
       HANDLE CreateNewCompletionPort(DWORD dwNumberOfThreads)
       {    
          return CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,NULL,dwNumberOfThreads);
       };      
第二步,将刚创建的完成端口和一个有效的设备句柄关联起来,可以使用下面的函数:
       bool AssicoateDeviceWithCompletionPort(HANDLE hCompPort,HANDLE hDevice,DWORD dwCompKey)
       {
          HANDLE h=CreateIoCompletionPort(hDevice,hCompPort,dwCompKey,0);
          return h==hCompPort;
       };
说明如下:
1)CreateIoCompletionPort函数也可以一次性的既创建完成端口对象,又关联到一个有效的设备句柄。
2)CompletionKey是一个可以自己定义的参数,我们可以把一个结构的地址赋给它,然后在合适的时候取出来使用,最好要保证结构里面的内存不是分配在栈上,除非你有十分的把握内存会保留到你要使用的那一刻。
3)NumberOfConcurrentThreads用来指定要允许同时运行的的线程的最大个数,通常我们指定为0,这样系统会根据CPU的个数来自动确定。
4)创建和关联的动作完成后,系统会将完成端口关联的设备句柄、完成键作为一条纪录加入到这个完成端口的设备列表中。如果你有多个完成端口,就会有多个对应的设备列表。如果设备句柄被关闭,则表中该纪录会被自动删除。

4、完成端口线程的工作原理

1)完成端口管理线程池
   完成端口可以帮助我们管理线程池,但是线程池中的线程需要我们自己使用_beginthreadex来创建,凭什么通知完成端口管理我们的新线程呢?答案在函数GetQueuedCompletionStatus。该函数原型:
BOOL GetQueuedCompletionStatus(
    IN HANDLE CompletionPort,
    OUT LPDWORD lpNumberOfBytesTransferred,
    OUT PULONG_PTR lpCompletionKey,
    OUT LPOVERLAPPED *lpOverlapped,
    IN DWORD dwMilliseconds
); 

   这个函数试图从指定的完成端口的I/0完成队列中提取纪录。只有当重叠I/O动作完成的时候,完成队列中才有纪录。凡是调用这个函数的线程将会被放入到完成端口的等待线程队列中,因此完成端口就可以在自己的线程池中帮助我们维护这个线程。完成端口的I/0完成队列中存放了当重叠I/0完成的结果---- 一条纪录,该纪录拥有四个字段,前三项就对应GetQueuedCompletionStatus函数的2、3、4参数,最后一个字段是错误信息dwError。我们也可以通过调用PostQueudCompletionStatus模拟完成一个重叠I/0操作。 

   当I/0完成队列中出现了纪录,完成端口将会检查等待线程队列,该队列中的线程都是通过调用GetQueuedCompletionStatus函数使自己加入队列的。等待线程队列很简单,只是保存了这些线程的ID。完成端口按照后进先出的原则将一个线程队列的ID放入到释放线程列表中,同时该线程将从等待GetQueuedCompletionStatus函数返回的睡眠状态中变为可调度状态等待CPU的调度。所以我们的线程要想成为完成端口管理的线程,就必须要调用GetQueuedCompletionStatus函数。出于性能的优化,实际上完成端口还维护了一个暂停线程列表,具体细节可以参考《Windows高级编程指南》,我们现在知道的知识,已经足够了。

2)线程间数据传递
   完成端口线程间传递数据最常用的办法是在_beginthreadex函数中将参数传递给线程函数,或者使用全局变量。但完成端口也有自己的传递数据的方法,答案就在于CompletionKey和OVERLAPPED参数
CompletionKey被保存在完成端口的设备表中,是和设备句柄一一对应的,我们可以将与设备句柄相关的数据保存到CompletionKey中,或者将CompletionKey表示为结构指针,这样就可以传递更加丰富的内容。这些内容只能在一开始关联完成端口和设备句柄的时候做,因此不能在以后动态改变。

   OVERLAPPED参数是在每次调用ReadFile这样的支持重叠I/0的函数时传递给完成端口的。我们可以看到,如果我们不是对文件设备做操作,该结构的成员变量就对我们几乎毫无作用。我们需要附加信息,可以创建自己的结构,然后将OVERLAPPED结构变量作为我们结构变量的第一个成员,然后传递第一个成员变量的地址给ReadFile这样的函数。因为类型匹配,当然可以通过编译。当GetQueuedCompletionStatus函数返回时,我们可以获取到第一个成员变量的地址,然后一个简单的强制转换,我们就可以把它当作完整的自定义结构的指针使用,这样就可以传递很多附加的数据了。太好了!只有一点要注意,如果跨线程传递,请注意将数据分配到堆上,并且接收端应该将数据用完后释放。我们通常需要将ReadFile这样的异步函数的所需要的缓冲区放到我们自定义的结构中,这样当GetQueuedCompletionStatus被返回时,我们的自定义结构的缓冲区变量中就存放了I/0操作的数据。CompletionKey和OVERLAPPED参数,都可以通过GetQueuedCompletionStatus函数获得

3)线程的安全退出
   很多线程为了不止一次地执行异步数据处理,需要使用如下语句
while (true)
{
       ......
       GetQueuedCompletionStatus(...); 
       ......
}
那么线程如何退出呢,答案就在于上面曾提到过的PostQueudCompletionStatus函数,我们可以向它发送一个自定义的包含了OVERLAPPED成员变量的结构地址,里面含一个状态变量,当状态变量为退出标志时,线程就执行清除动作然后退出


下面就是具体的代码的活了
待续............


posted @ 2011-10-10 13:51 Jerry zhang 阅读(1177) | 评论 (0)编辑 收藏

2011年7月22日 #

va_start、va_end、va_list的使用 (转)

 

1:当无法列出传递函数的所有实参的类型和数目时,可用省略号指定参数表
void foo(...);
void
foo(parm_list,...);

 

2:函数参数的传递原理
函数参数是以数据结构:栈的形式存取,从右至左入栈.eg:
#include
<iostream>
void fun(int a, ...)
{
int *temp =
&a;
temp++;
for (int i = 0; i < a; ++i)
{
cout << *temp
<< endl;
temp++;
}
}

 

int main()
{
int a = 1;
int b = 2;
int c = 3;
int d =
4;
fun(4, a, b, c, d);
system("pause");
return
0;
}
Output::
1
2
3
4

 

3:获取省略号指定的参数
在函数体中声明一个va_list,然后用va_start函数来获取参数列表中的参数,使用完毕后调用va_end()结束。像这段代码:
void
TestFun(char* pszDest, int DestLen, const char* pszFormat, ...)
{
va_list
args;
va_start(args,
pszFormat);
_vsnprintf(pszDest, DestLen, pszFormat,
args);
va_end(args);
}

 

4.va_start使argp指向第一个可选参数。va_arg返回参数列表中的当前参数并使argp指向参数列表中的下一个参数。va_end把argp指针清为NULL。函数体内可以多次遍历这些参数,但是都必须以va_start开始,并以va_end结尾。

 

  1).演示如何使用参数个数可变的函数,采用ANSI标准形式
  #include 〈stdio.h〉
  #include
〈string.h〉
  #include 〈stdarg.h〉
  
  int demo( char, ... );
  void
main( void )
  {
     demo("DEMO", "This", "is", "a", "demo!",
"");
  }
  
  int demo( char msg, ...
)
  {
      
     va_list
argp;
     int argno = 0;
     char para;

 

    
     va_start( argp, msg
);
     while (1)
      
{
      para = va_arg( argp,
char);
         if ( strcmp( para, "") == 0
)
      
break;
         printf("Parameter #%d is:
%s\n", argno, para);
         argno++;
  
}
   va_end( argp );
  
   return 0;
  }

 

2)//示例代码1:可变参数函数的使用
#include "stdio.h"
#include "stdarg.h"
void
simple_va_fun(int start, ...)
{
    va_list
arg_ptr;
   int nArgValue =start;
    int
nArgCout=0;     //可变参数的数目
    va_start(arg_ptr,start);
//以固定参数的地址为起点确定变参的内存起始地址。
    do
   
{
       
++nArgCout;
        printf("the %d th arg:
%d\n",nArgCout,nArgValue);    
//输出各参数的值
        nArgValue =
va_arg(arg_ptr,int);                     
//得到下一个可变参数的值
    } while(nArgValue !=
-1);               
   
return;
}
int main(int argc, char* argv[])
{
   
simple_va_fun(100,-1);
   
simple_va_fun(100,200,-1);
    return 0;
}

 

3)//示例代码2:扩展——自己实现简单的可变参数的函数。
下面是一个简单的printf函数的实现,参考了<The C Programming
Language>中的例子
#include "stdio.h"
#include "stdlib.h"
void
myprintf(char* fmt, ...)       
//一个简单的类似于printf的实现,//参数必须都是int 类型
{
    char*
pArg=NULL;              
//等价于原来的va_list
    char
c;
   
    pArg = (char*)
&fmt;          //注意不要写成p = fmt
!!因为这里要对//参数取址,而不是取值
    pArg +=
sizeof(fmt);         //等价于原来的va_start         

   
do
    {
        c
=*fmt;
        if (c !=
'%')
       
{
           
putchar(c);           
//照原样输出字符
       
}
       
else
       
{
          
//按格式字符输出数据
          
switch(*++fmt)
          
{
           
case'd':
               
printf("%d",*((int*)pArg));          
               
break;
           
case'x':
               
printf("%#x",*((int*)pArg));
               
break;
           
default:
               
break;
           
}
            pArg +=
sizeof(int);              
//等价于原来的va_arg
       
}
        ++fmt;
   
}while (*fmt != '\0');
    pArg =
NULL;                              
//等价于va_end
    return;
}
int main(int argc, char*
argv[])
{
    int i = 1234;
    int j =
5678;
   
    myprintf("the first
test:i=%d\n",i,j);
    myprintf("the secend test:i=%d;
%x;j=%d;\n",i,0xabcd,j);
   
system("pause");
    return 0;
}

posted @ 2011-07-22 15:02 Jerry zhang 阅读(131) | 评论 (0)编辑 收藏

2011年7月21日 #

你一直做的很好,现在看我的

刚出社会一年,到外面闯了下,才知道一切没那么容易
种什么因,得什么果
在学校的时候,很多的东西不想去做,来不及做,懒的做
没有办法回到过去,告诉那个傻小子这样是不对的

昨天是老头子的生日
仔细想起来,他把我们几个子女都保护的很好
现在轮到我了

posted @ 2011-07-21 13:59 Jerry zhang 阅读(117) | 评论 (0)编辑 收藏

2011年7月14日 #

C++运行时内存布局----------->this到底是什么?(转自CSDN )

     摘要: 先问一个问题,在C++里,成员函数里的this指针和调用此函数的对象地址总是一样的吗?如果你的回答是:不一定。那么至少你是个老手吧,下面的内容你就不用看了;如果你的回答是:是啊,那么强烈建议你看看下面的内容。   非静态成员函数,无论是不是虚函数,都隐藏了一个this指针参数。这个参数的目的就是给函数提供一个基地址,以便于函数体内能找到对象的成员变量。那非静态成员函数是如何根据thi...  阅读全文

posted @ 2011-07-14 14:04 Jerry zhang 阅读(202) | 评论 (0)编辑 收藏

2011年7月12日 #

纯真IP数据库格式(转)

原文链接 :http://blogold.chinaunix.net/u1/41420/showart_322320.html
感谢原作者

自从有了IP数据库这种东西,QQ外挂的显示IP功能也随之而生,本人见识颇窄,是否还有其他应用不得而知,不过,IP数据库确实是个不错的东西。如今网络上最流行的IP数据库我想应该是纯真版的(说错了也不要扁我),迄今为止其IP记录条数已经接近30000,对于有些IP甚至能精确到楼层,不亦快哉。 2004年4、5月间,正逢LumaQQ破土动工,为了加上这个人人都喜欢,但是好像人人都不知道为什么喜欢的显IP功能,我也采用了纯真版IP数据库,它的优点是记录多,查询速度快,它只用一个文件QQWry.dat就包含了所有记录,方便嵌入到其他程序中,也方便升级。

基本结构
QQWry.dat文件在结构上分为3块:文件头,记录区,索引区。一般我们要查找IP时,先在索引区查找记录偏移,然后再到记录区读出信息。由于记录区的记录是不定长的,所以直接在记录区中搜索是不可能的。由于记录数比较多,如果我们遍历索引区也会是有点慢的,一般来说,我们可以用二分查找法搜索索引区,其速度比遍历索引区快若干数量级。图1是QQWry.dat的文件结构图。


[img]http://lumaqq.linuxsir.org/article/images/1/qqwry_dat_overview.gif[img]
图1. QQWry.dat文件结构
要注意的是,QQWry.dat里面全部采用了little-endian字节序

一. 了解文件头
QQWry.dat的文件头只有8个字节,其结构非常简单,首四个字节是第一条索引的绝对偏移,后四个字节是最后一条索引的绝对偏移。

二. 了解记录区
每条IP记录都由国家和地区名组成,国家地区在这里并不是太确切,因为可能会查出来“清华大学计算机系”之类的,这里清华大学就成了国家名了,所以这个国家地区名和IP数据库制作的时候有关系。所以记录的格式有点像QName,有一个全局部分和局部部分组成,我们这里还是沿用国家名和地区名的说法。

于是我们想象着一条记录的格式应该是: [IP地址][国家名][地区名],当然,这个没有什么问题,但是这只是最简单的情况。很显然,国家名和地区名可能会有很多的重复,如果每条记录都保存一个完整的名称拷贝是非常不理想的,所以我们就需要重定向以节省空间。所以为了得到一个国家名或者地区名,我们就有了两个可能:第一就是直接的字符串表示的国家名,第二就是一个4字节的结构,第一个字节表明了重定向的模式,后面3个字节是国家名或者地区名的实际偏移位置。对于国家名来说,情况还可能更复杂些,因为这样的重定向最多可能有两次。

那么什么是重定向模式?根据上面所说,一条记录的格式是[IP地址][国家记录][地区记录],如果国家记录是重定向的话,那么地区记录是有可能没有的,于是就有了两种情况,我管他叫做模式1和模式2。我们对这些格式的情况举图说明:



图2. IP记录的最简单形式
图2表示了最简单的IP记录格式,我想没有什么可以解释的



图3. 重定向模式1
图3演示了重定向模式1的情况。我们看到在模式1的情况下,地区记录也跟着国家记录走了,在IP地址之后只剩下了国家记录的4字节,后面3个字节构成了一个指针,指向了实际的国家名,然后又跟着地址名。模式1的标识字节是0x01。



图4. 重定向模式2
图4演示了重定向模式2的情况。我们看到了在模式2的情况下(其标识字节是0x02),地区记录没有跟着国家记录走,因此在国家记录之后4个字节之后还是有地区记录。我想你已经明白了模式1和模式2的区别,即:模式1的国家记录后面不会再有地区记录,模式2的国家记录后会有地区记录。下面我们来看一下更复杂的情况。



图5. 混和情况1
图5演示了当国家记录为模式1的时候可能出现的更复杂情况,在这种情况下,重定向指向的位置仍然是个重定向,不过第二次重定向为模式2。大家不用担心,没有模式3了,这个重定向也最多只有两次,并且如果发生了第二次重定向,则其一定为模式2,而且这种情况只会发生在国家记录上,对于地区记录,模式1和模式 2是一样的,地区记录也不会发生2次重定向。不过,这个图还可以更复杂,如图7:



图6. 混和情况2
图6是模式1下最复杂的混和情况,不过我想应该也很好理解,只不过地区记录也来重定向而已,有一点我要提醒你,如果重定向的地址是0,则表示未知的地区名。

所以我们总结如下:一条IP记录由[IP地址][国家记录][地区记录]组成,对于国家记录,可以有三种表示方式:字符串形式,重定向模式1和重定向模式 2。对于地区记录,可以有两种表示方式:字符串形式和重定向,另外有一条规则:重定向模式1的国家记录后不能跟地区记录。按照这个总结,在这些方式中合理组合,就构成了IP记录的所有可能情况。

设计的理由
在我们继续去了解索引区的结构之前,我们先来了解一下为何记录区的结构要如此设计。我想你可能想到了答案:字符串重用。没错,在这种结构下,对于一个国家名和地区名,我只需要保存其一次就可以了。我们举例说明,为了表示方便,我们用小写字母代表IP记录,C表示国家名,A表示地区名:

有两条记录a(C1, A1), b(C2, A2),如果C1 = C2, A1 = A2,那么我们就可以使用图3显示的结构来实现重用
有三条记录a(C1, A1), b(C2, A2), c(C3, A3),如果C1 = C2, A2 = A3,现在我们想存储记录b,那么我们可以用图6的结构来实现重用
有两条记录a(C1, A1), b(C2, A2),如果C1 = C2,现在我们想存储记录b,那么我们可以采用模式2表示C2,用字符串表示A2

你可以举出更多的情况,你也会发现在这种结构下,不同的字符串只需要存储一次。

了解索引区
在"了解文件头"部分,我们说明了文件头实际上是两个指针,分别指向了第一条索引和最后一条索引的绝对偏移。如图8所示:



图8. 文件头指向索引区图示
实在是很简单,不是吗?从文件头你就可以定位到索引区,然后你就可以开始搜索IP了!每条索引长度为7个字节,前4个字节是起始IP地址,后三个字节就指向了IP记录。这里有些概念需要说明一下,什么是起始IP,那么有没有结束IP? 假设有这么一条记录:166.111.0.0 - 166.111.255.255,那么166.111.0.0就是起始IP,166.111.255.255就是结束IP,结束IP就是IP记录中的那头 4个字节,这下你应该就清楚了吧。于是乎,每条索引配合一条记录,构成了一个IP范围,如果你要查找166.111.138.138所在的位置,你就会发现166.111.138.138落在了166.111.0.0 - 166.111.255.255 这个范围内,那么你就可以顺着这条索引去读取国家和地区名了。那么我们给出一个最详细的图解吧:



图9. 文件详细结构
现在一切都清楚了是不是?也许还有一点你不清楚,QQWry.dat的版本信息存在哪里呢?答案是:最后一条IP记录实际上就是版本信息,最后一条记录显示出来就是这样:255.255.255.0 255.255.255.255 纯真网络 2004年6月25日IP数据。OK,到现在你应该全部清楚了。

posted @ 2011-07-12 17:49 Jerry zhang 阅读(263) | 评论 (0)编辑 收藏

仅列出标题