从0实现基于Linux socket聊天室-多线程服务器模型-1

<h1>前言</h1><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-ccad3388c4a7e5fb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p><p>Socket在实际系统程序开发张中,应用非常广泛,也非常重要。实际应用中服务器经常需要支持多个客户端连接,实现高并发服务器模型显得尤为重要。高并发服务器从简单的循环服务器模型处理少量网络并发请求,演进到解决C10K,C10M问题的高并发服务器模型。</p><p>
</p><p><strong>C/S架构</strong></p><p/><p>服务器-客户机,即Client-Server(C/S)结构。C/S结构通常采取两层结构。服务器负责数据的管理,客户机负责完成与用户的交互任务。</p><p class="image-package">在C/S结构中,应用程序分为两部分:服务器部分和客户机部分。服务器部分是多个用户共享的信息与功能,执行后台服务,如控制共享数据库的操作等;客户机部分为用户所专有,负责执行前台功能,在出错提示、在线帮助等方面都有强大的功能,并且可以在子程序间自由切换。<img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-553f551c2c02a378.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/>如上图所示:这是基于套接字实现客户端和服务器相连的函数调用关系,socket API资料比较多,本文不再过多叙述。</p><p><strong>pthread线程库:(POSIX)</strong></p><p/><p>pthread线程库是Linux下比较常用的一个线程库,关于他的用法和特性大家可以自行搜索相关文章,下面只简单介绍他的用法和编译。</p><p><strong>线程标识</strong></p><p/><p>线程有ID, 但不是系统唯一, 而是进程环境中唯一有效.
线程的句柄是pthread_t类型, 该类型不能作为整数处理, 而是一个结构.
下面介绍两个函数:</p><pre>头文件: <pthread.h>
原型: int pthread_equal(pthread_t tid1, pthread_t tid2);
返回值: 相等返回非0, 不相等返回0.说明: 比较两个线程ID是否相等.

头文件: <pthread.h>
原型: pthread_t pthread_self();
返回值: 返回调用线程的线程ID.</pre><p/><p><strong>线程创建</strong></p><p>在执行中创建一个线程, 可以为该线程分配它需要做的工作(线程执行函数), 该线程共享进程的资源. 创建线程的函数pthread_create()</p><pre>头文件: <pthread.h>
原型: int pthread_create(pthread_t restrict tidp, const pthread_attr_t restrict attr, void (start_rtn)(void), void restrict arg);
返回值: 成功则返回0, 否则返回错误编号.
参数:
tidp: 指向新创建线程ID的变量, 作为函数的输出.
attr: 用于定制各种不同的线程属性, NULL为默认属性(见下).
start_rtn: 函数指针, 为线程开始执行的函数名.该函数可以返回一个void *类型的返回值,
而这个返回值也可以是其他类型,并由 pthread_join()获取
arg: 函数的唯一无类型(void)指针参数, 如要传多个参数, 可以用结构封装.</pre><p/><p><strong>编译</strong></p><pre>因为pthread的库不是linux系统的库,所以在进行编译的时候要加上     -lpthread

 gcc filename -lpthread  //默认情况下gcc使用c库,要使用额外的库要这样选择使用的库</pre><h1>常见的网络服务器模型</h1><p>本文结合自己的理解,主要以TCP为例,总结了几种常见的网络服务器模型的实现方式,并最终实现一个简单的命令行聊天室。</p><p><strong>单进程循环</strong></p><p/><p>单线进程循环原理就是主进程没和客户端通信,客户端都要先连接服务器,服务器接受一个客户端连接后从客户端读取数据,然后处理并将处理的结果返还给客户端,然后再接受下一个客户端的连接请求。</p><p><strong>优点</strong></p><p>单线程循环模型优点是简单、易于实现,没有同步、加锁这些麻烦事,也没有这些开销。</p><p><strong>缺点</strong></p><ol><li><p>阻塞模型,网络请求串行处理;</p></li><li><p>没有利用多核cpu的优势,网络请求串行处理;</p></li><li><p>无法支持同时多个客户端连接;</p></li><li><p class="image-package">程序串行操作,服务器无法实现同时收发数据。<img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-771dd2e04efd4c7a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p></li></ol><p/><p>
</p><p><strong>单线程IO复用</strong></p><p>linux高并发服务器中常用epoll作为IO复用机制。线程将需要处理的socket读写事件都注册到epoll中,当有网络IO发生时,epoll_wait返回,线程检查并处理到来socket上的请求。</p><p><strong>优点</strong></p><ol><li><p>实现简单, 减少锁开销,减少线程切换开销。</p></li></ol><p><strong>缺点</strong></p><ol><li><p>只能使用单核cpu,handle时间过长会导致整个服务挂死;</p></li><li><p>当有客户端数量超过一定数量后,性能会显著下降;</p></li><li><p>只适用高IO、低计算,handle处理时间短的场景。</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-760f433980378b9f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/> </p><p><strong>多线程/多进程</strong></p><p/><p>多线程、多进程模型主要特点是每个网络请求由一个进程/线程处理,线程内部使用阻塞式系统调用,在线程的职能划分上,可以由一个单独的线程处理accept连接,其余线程处理具体的网络请求(收包,处理,发包);还可以多个进程单独listen、accept网络连接。</p><p><strong>优点:</strong></p><p/><p>1、实现相对简单;</p><p> 2、利用到CPU多核资源。</p><p><strong>缺点:</strong></p><p/><p class="image-package">1、线程内部还是阻塞的,举个极端的例子,如果一个线程在handle的业务逻辑中sleep了,这个线程也就挂住了。<img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-2680da6b297750b2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p><p/><p>
</p><p><strong>多线程/多进程IO复用</strong></p><p class="image-package">多线程、多进程IO服用模型,每个子进程都监听服务,并且都使用epoll机制来处理进程的网络请求,子进程 accept() 后将创建已连接描述符,然后通过已连接描述符来与客户端通信。该机制适用于高并发的场景。</p><p><strong>优点:</strong></p><p/><ol><li><p>支撑较高并发。</p><p>
</p></li></ol><p><strong>缺点:</strong></p><p/><ol><li><p>异步编程不直观、容易出错</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-f2553c800804f2f4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/>
</p><p><strong>多线程划分IO角色</strong></p><p/><p>多线程划分IO角色主要功能有:一个accept thread处理新连接建立;一个IO thread pool处理网络IO;一个handle thread pool处理业务逻辑。使用场景如:电销应用,thrift TThreadedSelectorServer。</p><p><strong>优点:</strong></p><p/><ol><li><p>按不同功能划分线程,各线程处理固定功能,效率更高</p></li><li><p>可以根据业务特点配置线程数量来性能调优</p></li></ol><p><strong>缺点:</strong></p><p/><ol><li><p>线程间通信需要引入锁开销</p></li><li><p>逻辑较复杂,实现难度大</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-d769c3ef1dc9553a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/>
</p><p>
</p><p><strong>小结</strong></p><p/><p>上面介绍了常见的网络服务器模型,还有AIO、协程,甚至还有其他的变型,在这里不再讨论。重要的是理解每种场景中所面临的问题和每种模型的特点,设计出符合应用场景的方案才是好方案。</p><h1>多线程并发服务器模型</h1><p>下面我们主要讨论多线程并发服务器模型。</p><p/><p><strong>代码结构</strong></p><p>并发服务器代码结构如下:</p><pre>thread_func()

{  while(1) {
    recv(...);
    process(...);
    send(...);
  }
  close(...);
}
main(
 socket(...); 
 bind(...);
 listen(...); while(1) { 
  accept(...);
  pthread_create();
 }
}</pre><p>由上可以看出,服务器分为两部分:主线程、子线程。</p><p><strong>主线程</strong></p><p/><p>main函数即主线程,它的主要任务如下:</p><ol><li><p>socket()创建监听套字;</p></li><li><p>bind()绑定端口号和地址;</p></li><li><p>listen()开启监听;</p></li><li><p>accept()等待客户端的连接,</p></li><li><p>当有客户端连接时,accept()会创建一个新的套接字new_fd;</p></li><li><p>主线程会创建子线程,并将new_fd传递给子线程。</p></li></ol><p><strong>子线程</strong></p><p/><ol><li><p>子线程函数为thread_func(),他通过new_fd处理和客户端所有的通信任务。</p></li></ol><p/><p><strong>客户端连接服务器详细步骤</strong></p><p>下面我们分步骤来看客户端连接服务器的分步说明。</p><p><strong>1. 客户端连接服务器</strong></p><p/><ol><li><p>服务器建立起监听套接字listen_fd,并初始化;</p></li><li><p>客户端创建套接字fd1;</p></li><li><p>客户端client1通过套接字fd1连接服务器的listen_fd;</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-1d21ca8683bdc985.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p><p><strong>2. 主线程创建子线程thread1</strong></p><p/><ol><li><p>server收到client1的连接请求后,accpet函数会返回一个新的套接字newfd1;</p></li><li><p>后面server与client1的通信就依赖newfd1,监听套接字listen_fd会继续监听其他客户端的连接;</p></li><li><p>主线程通过pthead_create()创建一个子线程thread1,并把newfd1传递给thread1;</p></li><li><p>server与client1的通信就分别依赖newfd1、fd1。</p></li><li><p>client1为了能够实时收到server发送的信息,同时还要能够从键盘上读取数据,这两个操作都是阻塞的,没有数据的时候进程会休眠,所以必须创建子线程read_thread;</p></li><li><p>client1的主线负责从键盘上读取数据并发送给,子线程read_thread负责从server接受信息。</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-5fa94fc0c45eaf71.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p><p><strong>3. client2连接服务器</strong></p><p/><ol><li><p>客户端client2创建套接字fd2;</p></li><li><p class="image-package">通过connect函数连接server的listen_fd;<img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-35f30e13f359f8de.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p></li></ol><p/><p><strong>4. 主线程创建子线程thread2</strong></p><ol><li><p>server收到client2的连接请求后,accpet函数会返回一个新的套接字newfd2;</p></li><li><p>后面server与client2的通信就依赖newfd2,监听套接字listen_fd会继续监听其他客户端的连接;</p></li><li><p>主线程通过pthead_create()创建一个子线程thread2,并把newfd2传递给thread2;</p></li><li><p>server与client1的通信就分别依赖newfd2、fd2。</p></li><li><p>同样client2为了能够实时收到server发送的信息,同时还要能够从键盘上读取数据必须创建子线程read_thread;</p></li><li><p>client1的主线负责从键盘上读取数据并发送给,子线程read_thread负责从server接受信息。</p></li></ol><p class="image-package"><img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-f49eacd8f5b8097b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/>在这里插入图片描述</p><p>由上图可见,每一个客户端连接server后,server都要创建一个专门的thread负责和该客户端的通信;每一个客户端和server都有一对固定的fd组合用于连接。</p><h1>实例</h1><p>好了,理论讲完了,根据一口君的惯例,也继承祖师爷的教诲:talk is cheap,show you my code.不上代码,只写理论的文章都是在耍流氓。</p><p>本例的主要功能描述如下:</p><ol><li><p>实现多个客户端可以同时连接服务器;</p></li><li><p>客户端可以实现独立的收发数据;</p></li><li><p>客户端发送数据给服务器后,服务器会将数据原封不动返回给客户端。</p></li></ol><p><strong>服务器端</strong></p><p/><pre>/*********************************************
           服务器程序  TCPServer.c  
           公众号:一口Linux
*********************************************/#include <stdio.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>#include <errno.h>#include <string.h>#include <pthread.h>#include <stdlib.h>#define RECVBUFSIZE 2048void rec_func(void arg){ int sockfd,new_fd,nbytes; char buffer[RECVBUFSIZE]; int i;
 new_fd = ((int ) arg); free(arg); 
 
 while(1)
 {  if((nbytes=recv(new_fd,buffer, RECVBUFSIZE,0))==-1)
  {   fprintf(stderr,"Read Error:%s\n",strerror(errno));   exit(1);
  }  if(nbytes == -1)
  {//客户端出错了 返回值-1
   close(new_fd);   break;   
  }  if(nbytes == 0)
  {//客户端主动断开连接,返回值是0
   close(new_fd);   break;
  }
  buffer[nbytes]='\0'; 
  printf("I have received:%s\n",buffer); 
  
  
  if(send(new_fd,buffer,strlen(buffer),0)==-1)
  {   fprintf(stderr,"Write Error:%s\n",strerror(errno));   exit(1);
  }
   
 }

}int main(int argc, char argv[]){ char buffer[RECVBUFSIZE]; int sockfd,new_fd,nbytes; struct sockaddr_in server_addr;
 struct sockaddr_in client_addr;
 int sin_size,portnumber; char hello[]="Hello! Socket communication world!\n"; pthread_t tid; int 
pconnsocke = NULL; int ret,i; 
 if(argc!=2)
 {  fprintf(stderr,"Usage:%s portnumber\a\n",argv[0]);  exit(1);
 } /端口号不对,退出/
 if((portnumber=atoi(argv[1]))<0)
 {  fprintf(stderr,"Usage:%s portnumber\a\n",argv[0]);  exit(1);
 } /服务器端开始建立socket描述符  sockfd用于监听/
 if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)  
 {  fprintf(stderr,"Socket error:%s\n\a",strerror(errno));  exit(1);
 } 
 /服务器端填充 sockaddr结构
 bzero(&server_addr,sizeof(struct sockaddr_in));
 server_addr.sin_family     =AF_INET; /自动填充主机IP/
 server_addr.sin_addr.s_addr=htonl(INADDR_ANY);//自动获取网卡地址
 server_addr.sin_port       =htons(portnumber); 
 /捆绑sockfd描述符
 if(bind(sockfd,(struct sockaddr )(&server_addr),sizeof(struct sockaddr))==-1)
 {  fprintf(stderr,"Bind error:%s\n\a",strerror(errno));  exit(1);
 } 
 /
监听sockfd描述符/
 if(listen(sockfd, 10)==-1)
 {  fprintf(stderr,"Listen error:%s\n\a",strerror(errno));  exit(1);
 } while(1)
 {  /
服务器阻塞,直到客户程序建立连接/
  sin_size=sizeof(struct sockaddr_in);  if((new_fd = accept(sockfd,(struct sockaddr 
)&client_addr,&sin_size))==-1)
  {   fprintf(stderr,"Accept error:%s\n\a",strerror(errno));   exit(1);
  }
  
  pconnsocke = (int ) malloc(sizeof(int));
  
pconnsocke = new_fd;
  
  ret = pthread_create(&tid, NULL, rec_func, (void ) pconnsocke);  if (ret < 0) 
  {
   perror("pthread_create err");   return -1;
  } 
 } //close(sockfd);
 exit(0);
}</pre><p/><p><strong>客户端</strong></p><pre>/*********************************************
           服务器程序  TCPServer.c  
           公众号:一口Linux
*********************************************/#include <stdio.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>#include <errno.h>#include <string.h>#include <pthread.h>#include <stdlib.h>#define RECVBUFSIZE 1024void 
func(void arg){ int sockfd,new_fd,nbytes; char buffer[RECVBUFSIZE];
 
 new_fd = 
((int *) arg); free(arg); 
 while(1)
 {  if((nbytes=recv(new_fd,buffer, RECVBUFSIZE,0))==-1)
  {   fprintf(stderr,"Read Error:%s\n",strerror(errno));   exit(1);
  }
  buffer[nbytes]='\0';  printf("I have received:%s\n",buffer); 
 }

}int main(int argc, char argv[]){ int sockfd; char buffer[RECVBUFSIZE]; struct sockaddr_in server_addr;
 struct hostent 
host;
 int portnumber,nbytes; 
 pthread_t tid; int *pconnsocke = NULL; int ret; 
 //检测参数个数
 if(argc!=3)
 {  fprintf(stderr,"Usage:%s hostname portnumber\a\n",argv[0]);  exit(1);
 } //argv2 存放的是端口号 ,读取该端口,转换成整型变量
 if((portnumber=atoi(argv[2]))<0)
 {  fprintf(stderr,"Usage:%s hostname portnumber\a\n",argv[0]);  exit(1);
 } //创建一个 套接子
 if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)
 {  fprintf(stderr,"Socket Error:%s\a\n",strerror(errno));  exit(1);
 } //填充结构体,ip和port必须是服务器的
 bzero(&server_addr,sizeof(server_addr));
 server_addr.sin_family=AF_INET;
 server_addr.sin_port=htons(portnumber);
 server_addr.sin_addr.s_addr = inet_addr(argv[1]);//argv【1】 是server ip地址

 /¿Í»§³ÌÐò·¢ÆðÁ¬œÓÇëÇó
 if(connect(sockfd,(struct sockaddr )(&server_addr),sizeof(struct sockaddr))==-1)
 {  fprintf(stderr,"Connect Error:%s\a\n",strerror(errno));  exit(1);
 } 
 //创建线程
 pconnsocke = (int 
) malloc(sizeof(int));
 pconnsocke = sockfd;
 
 ret = pthread_create(&tid, NULL, func, (void 
) pconnsocke); if (ret < 0) 
 {
  perror("pthread_create err");  return -1;
 } 
 while(1)
 { #if 1
  printf("input msg:");  scanf("%s",buffer);  if(send(sockfd,buffer,strlen(buffer),0)==-1)
  {   fprintf(stderr,"Write Error:%s\n",strerror(errno));   exit(1);
  }  #endif
 }
 close(sockfd); exit(0);
}</pre><p><strong>编译</strong></p><p>编译线程,需要用到pthread库,编译命令如下:</p><ol><li><p>gcc s.c -o s -lpthread</p></li><li><p>gcc cli.c -o c -lpthread
先本机测试</p></li><li><p>开启一个终端 ./s 8888</p></li><li><p>再开一个终端 ./cl 127.0.0.1 8888,输入一个字符串"qqqqqqq"</p></li><li><p class="image-package">再开一个终端 ./cl 127.0.0.1 8888,输入一个字符串"yikoulinux"<img class="uploaded-img" src="https://upload-images.jianshu.io/upload_images/23850874-b2a7183741d33af5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" width="auto" height="auto"/></p></li></ol><p>有读者可能会注意到,server创建子线程的时候用的是以下代码:</p><pre> pconnsocke = (int ) malloc(sizeof(int));
  
pconnsocke = new_fd;
  
  ret = pthread_create(&tid, NULL, rec_func, (void *) pconnsocke);  if (ret < 0) 
  {
   perror("pthread_create err");   return -1;
  }</pre><p>为什么必须要malloc一块内存专门存放这个新的套接字呢?
这个是一个很隐蔽,很多新手都容易犯的错误。下一章,我会专门给大家讲解。</p><p>本系列文章预计会更新4-5篇。最终目的是写出一个带登录注册公聊私聊等功能的聊天室。喜欢的话请收藏关注。</p><p>图片参考网络文章:https://cloud.tencent.com/developer/article/1376352</p><p>获取更多关于Linux的资料,请关注公众号「一口Linux」,回复"进群",带你加入大咖云集的技术讨论群。</p><p>
</p><p>
</p>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,245评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,749评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,960评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,575评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,668评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,670评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,664评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,422评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,864评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,178评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,340评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,015评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,646评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,265评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,494评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,261评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,206评论 2 352