高性能服务器开发

https://blog.csdn.net/weixin_41563161/article/details/104781264  结合这个看

 (一)主线程与工作线程的分工

服务器端为了能流畅处理多个客户端链接,一般在某个线程A里面accept新的客户端连接并生成新连接的socket fd,然后将这些新连接的socketfd给另外开的数个工作线程B1、B2、B3、B4,这些工作线程处理这些新连接上的网络IO事件(即收发数据),同时,还处理系统中的另外一些事务。这里我们将线程A称为主线程,B1、B2、B3、B4等称为工作线程。工作线程的代码框架一般如下:

epoll_or_select_func()中通过select()或者poll/epoll()去检测socket fd上的io事件,

若存在这些事件则下一步handle_io_events()来处理这些事件(收发数据),

做完之后可能还要做一些系统其他的任务,即调用handle_other_things()

 

这样做有三个好处

  1. 线程A只需要处理新连接的到来即可,不用处理网络IO事件。由于网络IO事件处理一般相对比较慢,如果在线程A里面既处理新连接又处理网络IO,则可能由于线程忙于处理IO事件,而无法及时处理客户端的新连接,这是很不好的。

  2. 线程A接收的新连接,可以根据一定的负载均衡原则将新的socket fd分配给工作线程。常用的算法,比如round robin,即轮询机制,即,假设不考虑中途有连接断开的情况,一个新连接来了分配给B1,又来一个分配给B2,再来一个分配给B3,再来一个分配给B4。如此反复,也就是说线程A记录了各个工作线程上的socket fd数量,这样可以最大化地来平衡资源,避免一些工作线程“忙死”,另外一些工作线程“闲死”的现象。

  3. 即使工作线程不满载的情况下,也可以让工作线程做其他的事情。比如现在有四个工作线程,但只有三个连接。那么线程B4就可以在handle_other_thing()做一些其他事情。

 

下面讨论一个很重要的效率问题

在上述while循环里面,epoll_or_selec_func()中的epoll_wait/poll/select等函数一般设置了一个超时时间。如果设置超时时间为0,那么在没有任何网络IO时间和其他任务处理的情况下,这些工作线程实际上会空转,白白地浪费cpu时间片。如果设置的超时时间大于0,在没有网络IO时间的情况,epoll_wait/poll/select仍然要挂起指定时间才能返回,导致handle_other_thing()不能及时执行,影响其他任务不能及时处理,也就是说其他任务一旦产生,其处理起来具有一定的延时性。这样也不好。

 

那如何解决该问题呢?

其实我们想达到的效果是,如果没有网络IO时间和其他任务要处理,那么这些工作线程最好直接挂起而不是空转;如果有其他任务要处理,这些工作线程要立刻能处理这些任务而不是在epoll_wait/poll/selec挂起指定时间后才开始处理这些任务。

我们采取如下方法来解决该问题,以linux为例,不管epoll_fd上有没有文件描述符fd,我们都给它绑定一个默认的fd,这个fd被称为唤醒fd。当我们需要处理其他任务的时候,向这个唤醒fd上随便写入1个字节的,这样这个fd立即就变成可读的了,epoll_wait()/poll()/select()函数立即被唤醒,并返回,接下来马上就能执行handle_other_thing(),其他任务得到处理。反之,没有其他任务也没有网络IO事件时,epoll_or_select_func()就挂在那里什么也不做。

 

这个唤醒fd,在linux平台上可以通过以下几种方法实现:

  1. 管道pipe,创建一个管道,将管道绑定到epoll_fd上。需要时,向管道一端写入一个字节,工作线程立即被唤醒。

  2. linux 2.6新增的eventfd:

1int eventfd(unsigned int initval, int flags); 

  步骤也是一样,将生成的eventfd绑定到epoll_fd上。需要时,向这个        eventfd上写入一个字节,工作线程立即被唤醒。

  1. 第三种方法最方便。即linux特有的socketpair,socketpair是一对相互连接的socket,相当于服务器端和客户端的两个端点,每一端都可以读写数据。

1int socketpair(int domain, int type, int protocol, int sv[2]);

调用这个函数返回的两个socket句柄就是sv[0],和sv[1],在一个其中任何一个写入字节,在另外一个收取字节。

将收取的字节的socket绑定到epoll_fd上。需要时,向另外一个写入的socket上写入一个字节,工作线程立即被唤醒。

如果是使用socketpair,那么domain参数一定要设置成AFX_UNIX。

由于在windows,select函数只支持检测socket这一种fd,所以windows上一般只能用方法3的原理。而且需要手动创建两个socket,然后一个连接另外一个,将读取的那一段绑定到select的fd上去。这在写跨两个平台代码时,需要注意的地方。

 

(二)Reactor模式

 

(三)一个服务器程序的架构介绍

(一)、数据库工作线程的用途

9个数据库工作线程在线程启动之初,与mysql建立连接,也就是说每个线程都与mysql保持一路连接,共9个数据库连接。
每个数据库工作线程同时存在两个任务队列,第一个队列A存放需要执行数据库增删查改操作的任务sqlTask,第二个队列B存放sqlTask执行完成后的结果。sqlTask执行完成后立即放入结果队列中,因而结果队列中任务也是一个个的需要执行的任务。大致伪代码如下:

现在的问题来了:

任务队列A中的任务从何而来,目前只有消费者,没有生产者,那么生产者是谁?

任务队列B中的任务将去何方,目前只有生产者没有消费者。
这两个问题先放一会儿,等到后面我再来回答。

(二)工作线程和主线程

在介绍主线程和工作线程具体做什么时,我们介绍下服务器编程中常常抽象出来的几个概念(这里以tcp连接为例):

TcpServer 即Tcp服务,服务器需要绑定ip地址和端口号,并在该端口号上侦听客户端的连接(往往由一个成员变量TcpListener来管理侦听细节)。所以一个TcpServer要做的就是这些工作。除此之外,每当有新连接到来时,TcpServer需要接收新连接,当多个新连接存在时,TcpServer需要有条不紊地管理这些连接:连接的建立、断开等,即产生和管理下文中说的TcpConnection对象。

一个连接对应一个TcpConnection对象,TcpConnection对象管理着这个连接的一些信息:如连接状态、本端和对端的ip地址和端口号等。

数据通道对象Channel,Channel记录了socket的句柄,因而是一个连接上执行数据收发的真正执行者,Channel对象一般作为TcpConnection的成员变量。

TcpSession对象,是将Channel收取的数据进行解包,或者对准备好的数据进行装包,并传给Channel发送。

 

归纳起来:

一个TcpServer依靠TcpListener对新连接的侦听和处理,依靠TcpConnection对象对连接上的数据进行管理,TcpConnection实际依靠Channel对数据进行收发,依靠TcpSession对数据进行装包和解包。也就是说一个TcpServer存在一个TcpListener,对应多个TcpConnection,有几个TcpConnection就有几个TcpSession,同时也就有几个Channel。

以上说的TcpServer、TcpListener、TcpConnection、Channel和TcpSession是服务器框架的网络层。一个好的网络框架,应该做到与业务代码脱耦。即上层代码只需要拿到数据,执行业务逻辑,而不用关注数据的收发和网络数据包的封包和解包以及网络状态的变化(比如网络断开与重连)。

拿数据的发送来说:


当业务逻辑将数据交给TcpSession,TcpSession将数据装好包后(装包过程后可以有一些加密或压缩操作),交给TcpConnection::SendData(),而TcpConnection::SendData()实际是调用Channel::SendData(),因为Channel含有socket句柄,所以Channel::SendData()真正调用send()/sendto()/write()方法将数据发出去。

 

对于数据的接收,稍微有一点不同:
通过select()/poll()/epoll()等IO multiplex技术,确定好了哪些TcpConnection上有数据到来后,激活该TcpConnection的Channel对象去调用recv()/recvfrom()/read()来收取数据。数据收到以后,将数据交由TcpSession来处理,最终交给业务层。注意数据收取、解包乃至交给业务层是一定要分开的。我的意思是:最好不要解包并交给业务层和数据收取的逻辑放在一起。因为数据收取是IO操作,而解包和交给业务层是逻辑计算操作。IO操作一般比逻辑计算要慢。到底如何安排要根据服务器业务来取舍,也就是说你要想好你的服务器程序的性能瓶颈在网络IO还是逻辑计算,即使是网络IO,也可以分为上行操作和下行操作,上行操作即客户端发数据给服务器,下行即服务器发数据给客户端。有时候数据上行少,下行大。(如游戏服务器,一个npc移动了位置,上行是该客户端通知服务器自己最新位置,而下行确是服务器要告诉在场的每个客户端)。

高性能服务器架构设计总结

所谓高性能就是服务器能流畅地处理各个客户端的连接并尽量低延迟地应答客户端的请求;

所谓高并发,指的是服务器可以同时支持多的客户端连接,且这些客户端在连接期间内会不断与服务器有数据来往。

 

这篇文章将从两个方面来介绍,一个是服务器的框架,即单个服务器程序的代码组织结构;另外一个是一组服务程序的如何组织与交互,即架构。注意:本文以下内容中的客户端是相对概念,指的是连接到当前讨论的服务程序的终端,所以这里的客户端既可能是我们传统意义上的客户端程序,也可能是连接该服务的其他服务器程序。

(一)、网络通信

既然是服务器程序肯定会涉及到网络通信部分,那么服务器程序的网络通信模块要解决哪些问题?

笔者认为至少要解决以下问题:

  1. 如何检测有新客户端连接?

  2. 如何接受客户端连接?

  3. 如何检测客户端是否有数据发来?

  4. 如何收取客户端发来的数据?

  5. 如何检测连接异常?发现连接异常之后,如何处理?

  6. 如何给客户端发送数据?

  7. 如何在给客户端发完数据后关闭连接?

 

稍微有点网络基础的人,都能回答上面说的其中几个问题,比如接收客户端连接用socket API的accept函数,收取客户端数据用recv函数,给客户端发送数据用send函数,检测客户端是否有新连接和客户端是否有新数据可以用IO multiplexing技术(IO复用)的select、poll、epoll等socket API。确实是这样的,这些基础的socket API构成了服务器网络通信的地基,不管网络通信框架设计的如何巧妙,都是在这些基础的socket API的基础上构建的。但是如何巧妙地组织这些基础的socket API,才是问题的关键。我们说服务器很高效,支持高并发,实际上只是一个技术实现手段,不管怎样从软件开发的角度来讲无非就是一个程序而已,所以,只要程序能最大可能地满足“尽量减少等待”就是高效。也就是说高效不是“忙的忙死,闲的闲死”,而是大家都可以闲着,但是如果有活要干,大家尽量一起干,而不是一部分忙着依次做事情123456789,另外一部分闲在那里无所事事。说的可能有点抽象,下面我们来举一些例子具体来说明一下。

 

比如默认recv函数如果没有数据的时候,线程就会阻塞在那里;
默认send函数,如果tcp窗口不是足够大,数据发不出去也会阻塞在那里;

connect函数默认连接另外一端的时候,也会阻塞在那里;

又或者是给对端发送一份数据,需要等待对端回答,如果对方一直不应答,当前线程就阻塞在这里。

以上都不是高效服务器的开发思维方式,因为上面的例子都不满足“尽量减少等待”的原则,为什么一定要等待呢?有没用一种方法,这些过程不需要等待,最好是不仅不需要等待,而且这些事情完成之后能通知我。这样在这些本来用于等待的cpu时间片内,我就可以做一些其他的事情。有,也就是我们下文要讨论的IO Multiplexing技术(IO复用技术)

(二)、几种IO复用机制的比较

目前windows系统支持select、WSAAsyncSelect、WSAEventSelect、完成端口(IOCP),linux系统支持select、poll、epoll。这里我们不具体介绍每个具体的函数的用法,我们来讨论一点深层次的东西,以上列举的API函数可以分为两个层次:

  • 层次一 select和poll

  • 层次二 WSAAsyncSelect、WSAEventSelect、完成端口(IOCP)、epoll

为什么这么分呢?先来介绍第一层次,select和poll函数本质上还是在一定时间内主动去查询socket句柄(可能是一个也可能是多个)上是否有事件,比如可读事件,可写事件或者出错事件,也就是说我们还是需要每隔一段时间内去主动去做这些检测,如果在这段时间内检测出一些事件来,我们这段时间就算没白花,但是倘若这段时间内没有事件呢?我们只能是做无用功了,说白了,还是在浪费时间,因为假如一个服务器有多个连接,在cpu时间片有限的情况下,我们花费了一定的时间检测了一部分socket连接,却发现它们什么事件都没有,而在这段时间内我们却有一些事情需要处理,那我们为什么要花时间去做这个检测呢?把这个时间用在做我们需要做的事情不好吗?

 

所以对于服务器程序来说,要想高效,我们应该尽量避免花费时间主动去查询一些socket是否有事件,而是等这些socket有事件的时候告诉我们去处理。这也就是层次二的各个函数做的事情,它们实际相当于变主动查询是否有事件为当有事件时,系统会告诉我们,此时我们再去处理,也就是“好钢用在刀刃”上了。只不过层次二的函数通知我们的方式是各不相同,比如WSAAsyncSelect是利用windows消息队列的事件机制来通知我们设定的窗口过程函数,IOCP是利用GetQueuedCompletionStatus返回正确的状态,epoll是epoll_wait函数返回而已。

 

比如connect函数连接另外一端,如果连接socket是异步的,那么connect虽然不能立刻连接完成,但是也是会立刻返回,无需等待,等连接完成之后,WSAAsyncSelect会返回FD_CONNECT事件告诉我们连接成功,epoll会产生EPOLLOUT事件,我们也能知道连接完成。甚至socket有数据可读时,WSAAsyncSelect产生FD_READ事件,epoll产生EPOLLIN事件,等等。

所以有了上面的讨论,我们就可以得到网络通信检测可读可写或者出错事件的正确姿势。这是我这里提出的第二个原则:尽量减少做无用功的时间。这个在服务程序资源够用的情况下可能体现不出来什么优势,但是如果有大量的任务要处理,个人觉得这个可能带来无用。

 

(三)、检测网络事件的正确姿势

根据上面的介绍,第一,为了避免无意义的等待时间,第二,不采用主动查询各个socket的事件,而是采用等待操作系统通知我们有事件的状态的策略。我们的socket都要设置成异步的。在此基础上我们回到栏目(一)中提到的七个问题:

  1. 如何检测有新客户端连接?

  2. 如何接受客户端连接?

    默认accept函数会阻塞在那里,如果epoll检测到侦听socket上有EPOLLIN事件,或者WSAAsyncSelect检测到有FD_ACCEPT事件,那么就表明此时有新连接到来,这个时候调用accept函数,就不会阻塞了。当然产生的新socket你应该也设置成非阻塞的。这样我们就能在新socket上收发数据了。

  3. 如何检测客户端是否有数据发来?

  4. 如何收取客户端发来的数据?

    同理,我们也应该在socket上有可读事件的时候才去收取数据,这样我们调用recv或者read函数时不用等待。

    至于一次性收多少数据好呢?

    我们可以根据自己的需求来决定,甚至你可以在一个循环里面反复recv或者read,对于非阻塞模式的socket,如果没有数据了,recv或者read也会立刻返回,错误码EWOULDBLOCK会表明当前已经没有数据了。示例:

  5. 如何检测连接异常?发现连接异常之后,如何处理?

    同样当我们收到异常事件后例如EPOLLERR或关闭事件FD_CLOSE,我们就知道了有异常产生,我们对异常的处理一般就是关闭对应的socket。另外,如果send/recv或者read/write函数对一个socket进行操作时,如果返回0,那说明对端已经关闭了socket,此时这路连接也没必要存在了,我们也可以关闭对应的socket。

  6. 如何给客户端发送数据?

    给客户端发送数据,比收数据要稍微麻烦一点,也是需要讲点技巧的。首先我们不能像检测数据可读一样检测数据可写,因为如果检测可写的话,一般情况下只要对端正常收取数据,我们的socket就都是可写的,如果我们设置监听可写事件,会导致频繁地触发可写事件,但是我们此时并不一定有数据需要发送。所以正确的做法是:如果有数据要发送,则先尝试着去发送,如果发送不了或者只发送出去部分,剩下的我们需要将其缓存起来,然后设置检测该socket上可写事件,下次可写事件产生时,再继续发送,如果还是不能完全发出去,则继续设置侦听可写事件,如此往复,一直到所有数据都发出去为止。一旦所有数据都发出去以后,我们要移除侦听可写事件,避免无用的可写事件通知。不知道你注意到没有,如果某次只发出去部分数据,剩下的数据应该暂且存起来,这个时候我们就需要一个缓冲区来存放这部分数据,这个缓冲区我们称为“发送缓冲区”。发送缓冲区不仅存放本次没有发完的数据,还用来存放在发送过程中,上层又传来的新的需要发送的数据。为了保证顺序,新的数据应该追加在当前剩下的数据的后面,发送的时候从发送缓冲区的头部开始发送。也就是说先来的先发送,后来的后发送。

  7. 如何在给客户端发完数据后关闭连接?

    这个问题比较难处理,因为这里的“发送完”不一定是真正的发送完,我们调用send或者write函数即使成功,也只是向操作系统的协议栈里面成功写入数据,至于能否被发出去、何时被发出去很难判断,发出去对方是否收到就更难判断了。所以,我们目前只能简单地认为send或者write返回我们发出数据的字节数大小,我们就认为“发完数据”了。然后调用close等socket API关闭连接。关闭连接的话题,我们再单独开一个小的标题来专门讨论一下。

(四)被动关闭连接和主动关闭连接

在实际的应用中,被动关闭连接是由于我们检测到了连接的异常事件,比如EPOLLERR,或者对端关闭连接,send或recv返回0,这个时候这路连接已经没有存在必要的意义了,我们被迫关闭连接。

主动关闭连接,是我们主动调用close/closesocket来关闭连接。比如客户端给我们发送非法的数据,比如一些网络攻击的尝试性数据包。这个时候出于安全考虑,我们关闭socket连接。

 

(五)发送缓冲区和接收缓冲区

上面已经介绍了发送缓冲区了,并说明了其存在的意义。接收缓冲区也是一样的道理,当收到数据以后,我们可以直接进行解包,但是这样并不好,

  1. 理由一:除非一些约定俗称的协议格式,比如http协议,大多数服务器的业务的协议都是不同的,也就是说一个数据包里面的数据格式的解读应该是业务层的事情,和网络通信层应该解耦,为了网络层更加通用,我们无法知道上层协议长成什么样子,因为不同的协议格式是不一样的,它们与具体的业务有关。

  2. 理由二:即使知道协议格式,我们在网络层进行解包处理对应的业务,如果这个业务处理比较耗时,比如读取磁盘文件,或者连接数据库进行账号密码验证,那么我们的网络线程会需要大量时间来处理这些任务,这样其它网络事件可能没法及时处理。鉴于以上二点,我们确实需要一个接收缓冲区,将收取到的数据放到该缓冲区里面去,并由专门的业务线程或者业务逻辑去从接收缓冲区中取出数据,并解包处理业务。

说了这么多,那发送缓冲区和接收缓冲区该设计成多大的容量?这是一个老生常谈的问题了,因为我们经常遇到这样的问题:预分配的内存太小不够用,太大的话可能会造成浪费。怎么办呢?答案就是像string、vector一样,设计出一个可以动态增长的缓冲区,按需分配,不够还可以扩展。

需要特别注意的是,这里说的发送缓冲区和接收缓冲区是每一个socket连接都存在一个。这是我们最常见的设计方案。

(六)协议的设计

除了一些通用的协议,如http、ftp协议以外,大多数服务器协议都是根据业务制定的。协议设计好了,数据包的格式就根据协议来设置。我们知道tcp/ip协议是流式数据,所以流式数据就是像流水一样,数据包与数据包之间没有明显的界限。比如A端给B端连续发了三个数据包,每个数据包都是50个字节,B端可能先收到10个字节,再收到140个字节;或者先收到20个字节,再收到20个字节,再收到110个字节;也可能一次性收到150个字节。这150个字节可以以任何字节数目组合和次数被B收到。

所以我们讨论协议的设计第一个问题就是如何界定包的界线,也就是接收端如何知道每个包数据的大小。目前常用有如下三种方法:

  1. 固定大小,这种方法就是假定每一个包的大小都是固定字节数目,比如上文中讨论的每个包大小都是50个字节,接收端每收齐50个字节就当成一个包;

  2. 指定包结束符,比如以一个\r\n(换行符和回车符)结束,这样对端只要收到这样的结束符,就可以认为收到了一个包,接下来的数据是下一个包的内容;

  3. 指定包的大小,这种方法结合了上述两种方法,一般包头是固定大小,包头中有一个字段指定包体或者整个大的大小,对端收到数据以后先解析包头中的字段得到包体或者整个包的大小,然后根据这个大小去界定数据的界线。

协议要讨论的第二个问题是,设计协议的时候要尽量方便解包,也就是说协议的格式字段应该尽量清晰明了。

协议要讨论的第三个问题是,根据协议组装的数据包应该尽量小,这样有如下好处:

  1. 第一、对于一些移动端设备来说,其数据处理能力和带宽能力有限,小的数据不仅能加快处理速度,同时节省大量流量费用;

  2. 第二、如果单个数据包足够小的话,对频繁进行网络通信的服务器端来说,可以大大减小其带宽压力,其所在的系统也能使用更少的内存。试想:假如一个股票服务器,如果一只股票的数据包是100个字节或者1000个字节,那100只股票和10000只股票区别呢?

协议要讨论的第四个问题是,对于数值类型,我们应该显式地指定数值的长度,比如long型,如果在32位机器上是32位的4个字节,

但是如果在64位机器上,就变成了64位8个字节了。这样同样是一个long型,发送方和接收方可能会用不同的长度去解码。所以建议最好在涉及到跨平台使用的协议最好显式地指定协议中整型字段的长度,比如int32,int64等等。下面是一个协议的接口的例子:

(七)、服务器程序结构的组织

上面的六个标题,我们讨论了很多具体的细节问题,现在是时候讨论将这些细节组织起来了。根据我的个人经验,目前主流的思想是one thread one loop的策略。通俗点说就是在一个线程的函数里面不断地循环依次做一些事情,这些事情包括检测网络事件、解包数据产生业务逻辑。我们先从最简单地来说,设定一些线程在一个循环里面做网络通信相关的事情,伪码如下:

 while(退出标志)  
 {  
     //IO复用技术检测socket可读事件、出错事件  
    //(如果有数据要发送,则也检测可写事件)  
 
    //如果有可读事件,对于侦听socket则接收新连接;  
   //对于普通socket则收取该socket上的数据,收取的数据存入对应的接收缓冲区,如果出错则关闭连接;  
 
     //如果有数据要发送,有可写事件,则发送数据  

    //如果有出错事件,关闭该连接   
}

另外设定一些线程去处理接收到的数据,并解包处理业务逻辑,这些线程可以认为是业务线程了,伪码如下:

//从接收缓冲区中取出数据解包,分解成不同的业务来处理

 

//从接收缓冲区中取出数据解包,分解成不同的业务来处理  

 

上面的结构是目前最通用的服务器逻辑结构,但是能不能再简化一下或者说再综合一下呢?我们试试,你想过这样的问题没有:假如现在的机器有两个cpu,我们的网络线程数量是2个,业务逻辑线程也是2个,这样可能存在的情况就是:业务线程运行的时候,网络线程并没有运行,它们必须等待,如果是这样的话,干嘛要多建两个线程呢?除了程序结构上可能稍微清楚一点,对程序性能没有任何实质性提高,而且白白浪费cpu时间片在线程上下文切换上。所以,我们可以将网络线程与业务逻辑线程合并,合并后的伪码看起来是这样子的:

 while(退出标志)  
 {  
     //IO复用技术检测socket可读事件、出错事件  
     //(如果有数据要发送,则也检测可写事件)  
 
     //如果有可读事件,对于侦听socket则接收新连接;  
     //对于普通socket则收取该socket上的数据,
     //收取的数据存入对应的接收缓冲区,如果出错则关闭连接;  
 
    //如果有数据要发送,有可写事件,则发送数据  

    //如果有出错事件,关闭该连接  

    //从接收缓冲区中取出数据解包,分解成不同的业务来处理  
}

`你没看错,其实就是简单的合并,合并之后和不仅可以达到原来合并前的效果,而且在没有网络IO事件的时候,可以及时处理我们想处理的一些业务逻辑,并且减少了不必要的线程上下文切换时间。

我们再更进一步,甚至我们可以在这个while循环增加其它的一些任务的处理,比如程序的逻辑任务队列、定时器事件等等,伪码如下:

注意:之所以将定时器事件的处理放在网络IO事件的检测之前,是因为避免定时器事件过期时间太长。假如放在后面的话,可能前面的处理耗费了一点时间,等到处理定时器事件时,时间间隔已经过去了不少时间。虽然这样处理,也没法保证定时器事件百分百精确,但是能尽量保证。

 

 

参考文章

https://mp.weixin.qq.com/s/CIEkaIdbAC8uGgCIOwNWsg

 https://mp.weixin.qq.com/s/0wWEfYnyIqx3da_o2gVtdg

https://mp.weixin.qq.com/s/CM-VwxsDZWdTl9x7FvRdXg

https://mp.weixin.qq.com/s/YUC74hv66psJAU1mKPFmtQ

https://mp.weixin.qq.com/s/tf9uZn3ha9Q2XjZwRNow3w

https://mp.weixin.qq.com/s/6Exd1U13iNR1iGWzCFgp4A

https://mp.weixin.qq.com/s/ke7_a3rkHJLHGAWbXCOlXQ