网络游戏开发中的通讯杂谈

概述

目前主流游戏服务框架大部分使用tcp互相通讯,随着rudp方案的日趋成熟,客户端和服务器之间采用udp连接的方案也越来越多。本篇文章将会由浅入深地介绍一下服务端业务中采用的一些通讯方案和一些优化手段。
在这里插入图片描述

文章大致分为以下几块内容:

  • TCP在服务端的使用和优化
  • 使用基于UDP的协议对游戏延迟的优化。这块会主要介绍KCP的一些使用和对UDP参数的调整
  • 个人在开发中积累的一些关于网络连接的经验
  • 题外话:KCP在开源界的兴起

一些比较常见的基础知识在网上已经很多文章进行过介绍。本文由于篇幅限制就不再赘述了。

TCP在服务端的使用和优化

TCP为游戏服务端最常用的通讯手段,它的稳定性和可靠性毋庸置疑。从早期的局域网对战游戏红警、cs等到现在多到满地爬的各种类型的页游都少不了它的身影。很多开发小白可能会觉得,只要我学会socket的基本操作,再结合流行的epoll或者iocp模型,就可以跟别人吹嘘我做过服务器框架了,但是其实tcp的开发细节远不止这么简单。

TCP从三次握手到通讯期间的拥塞控制再到关闭过程的四次挥手,基本上每个阶段每个状态的切换都有各种参数供开发者调节(这里只针对linux下开发)。其实针对建立连接过程和中间通讯过程的优化,我们也主要是通过调一些内核参数来实现的。下面笔者展示一下debian下面可供调节的tcp参数。

linux下tcp相关系统内核参数调节

一些基础性的通用调优参数,建议记住它们是做什么用的,比如tcprmem/tcpwmem是调整tcp默认读写缓存,tcptwreuse重用time_wait连接等等。再复杂一些的参数可以google或者阅读linux的tcp源码。在shell下执行ls /proc/sys/net/ipv4/tcp_* 可以看到:
在这里插入图片描述
上面大致有几十项参数,没有接触过这块内容的同学可能觉得非常头大。 其实大部分的参数笔者也不知道是干什么用的,但是假如tcp在使用期间出现了各种各样的问题,就需要我们学会如何去查哪些参数能对应的解决问题,这就需要对tcp有更深入的了解。

一些基础性的通用调优参数,建议记住它们是做什么用的,比如tcprmem/tcpwmem是调整tcp默认读写缓存,tcptwreuse重用time_wait连接等等。再复杂一些的参数可以google或者阅读linux的tcp源码。

幸运的是,大部分的默认参数已经可以满足我们的各种需求,是不需要调整的。

对于线上运营的产品来说,一般的策略是如果没有问题就尽量不要改,不知道参数用途的不要瞎改,笔者只推荐开启一个参数:

net.core.defaultqdisc: fq
net.ipv4.tcpcongestion_control: bbr

Google的bbr算法是支持单边开启的,会替代原生的tcp拥塞策略,对于提升tcp通讯效率有帮助。

调整参数属于牵一发而动全身的行为,尽量先在测试机上做对比观察,然后线上做好AB测试,毕竟出了运营事故就麻烦了。

前面说到我们除了断开连接部分基本上都可以通过调整内核参数来优化,断开连接作为一个看似非常不起眼的操作,事实上里面的学问也多得很,下面针对断开连接做一下展开说明。

断开连接问题

先抛出一个问题:

服务端对某个客户端的socket,执行了send(socket, msg),然后 close(socket), 会发生什么?对方客户端能收到这个msg吗?这样的行为对服务端会有影响吗?

( 如果你能回答上述问题,可以直接跳过这一章节的内容。)

通常开发者使用close关闭socket,它默认行为是尽量在后台执行优雅关闭动作,但是本身这个行为并不算可控,socket虽然释放,程序失去了对这条连接的控制权,剩下的生命周期全权交给操作系统内核来维护,所以假设我们的网络处于非常拥堵的状态,系统可能会随时将连接回收掉。

这里我们可以看一下以下内容里的描述:

https://docs.microsoft.com/en-us/windows/win32/winsock/graceful-shutdown-linger-options-and-socket-closure-2

所以close之后我们并不清楚msg什么时候,或者到底有没有到达对端。close相当于我们告诉内核,这个socket我用完了,你来处理后续的工作,我不管了。

但是做为一个优秀的服务端框架开发者,我们应该把这个过程牢牢掌握在自己手里:我虽然关了你,但是我会尽量保证把没发出去的消息给你发完,假如我实在发不完,我就主动退出做清理工作释放连接。

TCP提供一个SOLINGER选项让开发者可以接管这个close行为,这个选项需要指定一个超时时间,系统会在指定时间内尽量发送未发送完的数据,当超过超时时间还没有传输完成系统会清理socket,这里网上流传的说法有的是清理sendbuff,但是笔者在wsl下测试的结果并不相同,表现上是tcp连接由内核接管依旧尝试发送完剩余包,然后走到timewait状态,但是不管如何,我们确定这里的超时行为依然是不确定的,而且这样设置会带来一个问题: close变成了阻塞调用(无论socket本身是阻塞还是非阻塞),会block线程。

那么还有没有更好的方式来控制这个关闭行为呢?

下面介绍一下行为可控的优雅关闭流程,如果大家看了上面的关于gracefull-shutdown的链接,链接中也介绍了这个流程:

由服务端发起shutdown(write)代替close,然后等待read终止,最后执行close。此种方式关闭能保证对端在关闭收到连接关闭请求(fin)前可以接收到所有服务端关闭前发出的数据。
在这里插入图片描述

根据图示,上面这个过程其实已经完整地走了一遍tcp的四次挥手,注意,当最后一个ack发出时,这个连接进入time_wait状态。

当程序发起shutdown时,意味着程序主动接管了后续的状态切换,只要进程没有做出行为,关闭端始终会保持finwait1状态。

当然无论任何情况下,我们在主动关闭端都要做好异常处理,尤其是主动关闭端为服务端的情况。因为网络环境的不可控,关闭过程的每个步骤都可能出现发包或者收包中断从而出现中间状态,比如客户端故意卡在那里、程序不退出、不调用close或者shutdown,甚至客户端提前崩溃了。这种情况下需要做一个超时清理的工作,在shutdown(wr)开始一段时间后将socket强制close掉。

注意此时的close只是释放了socket,并不代表这个连接不存在了,超时情况下的socket可能存在各种状态连接比如timewait,finwait1和finwait_2,linux内核针对这几个状态都有可调节的参数(详见google),我们也可以设置一个较长的超时时间,超过之后之间触发主动reset来清理连接(nginx的处理方式),这样主动方不会残留任何连接状态,是最干净的清理方式。

但是,超时触发reset可能会导致数据串台。为什么会串台,我们可以看下图。
在这里插入图片描述
对端的msg还没到达主动方,主动方触发超时进行reset,之后对端建立了新连接,假设依然是ip1:port1和ip2:port的四元组,连接建立后seq=10的消息到达主动方,假如主动方当前的收包buffer只收到了对方seq为3的消息,那么收到这个seq=10的消息,它就可能会将其缓存进来做为一个合法包处理,这样这条tcp连接的数据就乱掉了,这也是time_wait状态存在的一个意义。

理论上我们设置的主动超时时间越久越安全,当我们把超时时间设为2*msl时基本上退化成了time_wait的维护周期,因为本身造成数据串台的条件已经非常苛刻:对端刚好在这个时间内又再次使用了port2端口建立了连接,恰好延迟未到的数据seq在主动方的可接受范围内。所以我们可以认为这个情况算是一个小概率事件。一旦触发这个小概率事件会有什么后果:服务器解析数据出错、连接断开、如果对端有连接断开的处理机制可能会尝试重连。假设我们能接受这样的处理结果,那么我认为发送这个reset包是有意义的,在对客户端影响忽略不计的情况下可以尽早地回收服务端连接。

当然我们也可以在这里采用close和reset结合的方式来处理超时情况,默认使用close,当服务器出现time_wait异常堆积时,我们打开reset开关,让系统能及时地清理不良数据。

注:通过开启tcp的SO_LINGER选项并将time设置为0可以主动触发reset,请谨慎使用。

服务端TIME_WAIT堆积

前面我们已经知道了,当服务端主动close连接或者shutdown时,就会产生timewait状态的连接。然而,timewait持续堆积不应该作为一个常态出现,过多的timewait将会影响系统tcp的可用连接数。因为系统连接资源有限,当出现连接数瓶颈时,我们应该尽量避免timewait或者使用更快的方式来释放time_wait连接。

一般服务器这些行为都可能会导致time_wait:

1.服务器踢人强制close socket
2.关服强制close连接
3.服务端ping/pong客户端无响应时强制断开连接
4.其他行为

我们可以调整为下面的步骤来尽量减少服务器主动关闭动作:

1.尽量通知客户端主动关闭:让客户端自己产生time_wait,客户端本身不会有很多的连接(这里只针对长连接游戏来说),无所谓堆积不堆积。

2.客户端一段时间内没有关闭连接后,由服务器决定走graceful shutdown或reset另外一种形式的time_wait堆积。
在这里插入图片描述

当我们的服务端和一些中间件服务通讯,比如php连接mysql、redis等,如果有大量的连接断开操作,那么服务端同样会出现timewait堆积,不同于上面的案例,这类timewait存在于"客户端"。

这种情况下,我们通常有两种方案解决:一种是使用长连接;另外一种是设置linux内核参数中的twreuse可以减轻timewait的影响,详见https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux#netipv4tcptwreuse的解释。

如果服务端支持水平扩展,而且time_wait还不至于影响到服务正常业务的情况下,也可以加机器解决问题。

UDP在游戏开发中的应用

随着玩家对游戏体验要求越来越高,udp协议在各类竞技游戏中已经成为不可或缺的存在。相比tcp协议中拥塞控制,慢启动等保守策略,基于udp的可靠协议更多地采用了一些比较激进的方式来实现这些功能,有的甚至直接抛弃了一些特性来保障传输效率。

所以开发商通常会找一些成熟的rudp协议(可靠用户数据报协议)来替代tcp与客户端通讯以降低延迟。

常见的rudp协议如enet、kcp、谷歌的quic、udt等等。其中kcp协议的性能表现非常出色。我们可以在它的官网看下benchmark(https://github.com/libinzhangyuan/reliable_udp_bench_mark/blob/master/bench_mark.md)。除了优秀的性能,kcp无论在代码简洁度还是易用性和可配置性上都有非常高的标准,再加上它是国人开发的,所以近些年在国内游戏中的使用率非常高。网易有很多游戏都相继接入了kcp协议,接下来我们以kcp为蓝本介绍一下udp通讯方案的具体细节。

针对kcp的代码解读和参数调优在github主页都有非常详细的介绍,这里就不再具体阐述,下面介绍一下kcp在具体使用过程中的一些网络设置技巧(本篇文章不涉及具体参数数值细节,需要自行根据需求调试)。

  1. kcp开启nodelay,设定合理的快速重传值resend,关闭流控,关于kcp本身参数设置,可以结合官方issue和源代码参照对比调整。

  2. 针对udp socket的优化。

优化tos参数和套接字优先级

针对tos字段的调优笔者习惯称之为“玄学调优”,因为它的效果很大程度依赖操作系统和路由网关本身行为,本身有效性是不确定的,这里列一下仅供参考。

IP头中有7bit用来设置包的优先级(SOPRIORITY)和服务类型(IPTOS),优先级从0到6,6为最高。假如你的手机端两条连接,一条优先级0一条优先级6,那么内核会优先发送6的队列数据。

服务类型分为4类:

IPTOS_LOWDELAY to minimize delays for interactive traffic,

IPTOS_THROUGHPUT to optimize throughput,

IPTOS_RELIABILITY to optimize for reliability,

IPTOS_MINCOST should be used for “filler data” where slow transmission doesn’t matter.

我们可以把前两个或者前三个标记置为1,然后通过setsockopt来使之生效。

bind udp socket

udp的socket可以通过connect操作,对socket进行半绑定。绑定后可通过send和recv收发数据。客户端可以通过此设置优化收发效率(苍蝇肉也是肉),详见http://www.masterraghu.com/subjects/np/introduction/unixnetworkprogramming_v1.3/ch08lev1sec11.html

此外,connect过的udp socket,如果遇到对端地址不可达返回的icmp包可直接通过recv接收处理。

KCP上层处理方案介绍

一般对于kcp封装收发框架大致如下:
在这里插入图片描述

• 上层包头+超时检查ping+其他游戏发包类型+数据

上层包头,主要对接游戏逻辑包,比如rpc和其他同步请求等。我们在游戏开发前期最好根据不同的请求类型预留出type位方便后面扩展。独立类型的好处就是当我们需要处理和普通的收发包不同行为时,添加新的类型有助于降低代码耦合,减少逻辑干扰。比如,当我们需要定时地和客户端进行ping/pong来同步对方时间并检测心跳延迟,我们完全可以将这个类型独立出来,ping/pong可能需要更高的实时性反馈,那么当其他类型包全部需要走发包队列排队发出时,ping类型的包完全可以不走队列直发,从而提高优先级。再比如,我们会有有些系统之间互相汇报状态的请求,这类请求完全可以和用户态的发包类型独立开来单独做发包和解包处理。

一个特殊的例子,很多实时对抗类游戏,会定时发送玩家的同步状态数据,这类请求其实没有必要通过rudp(即走kcp层)发送,因为后面始终会有更新的状态信息推过来,即便我前面丢包了,后面跟上来,数据就可以保持客户端达到最新的状态,所以这类数据是可以直接跳过中间层封装,直接走第三步下层打包。

• 中间kcp封装

封装无需说明,看官网即可

• 下层包头+ping/握手(如果有p2p需求,用于nat端口保活) + /fec纠错 +非可靠层传输标识, 包体 + 加密 +xor抑或

这一层数据其实和上层业务基本上无关,但是可以做的事情也非常多。

如果是打洞的p2p连接,是一定需要有定时的ping/pong包做映射关系保活的,不然你的通道会被nat设备回收掉,这个保活机制就可以直接通过这一层作为一个独立类型数据由上层定时push。

我们为了维护udp会话,需要实现自己的"三次握手"和"四次挥手"以及连接管理,那么这些类型的包就需要作为独立类型区分出来。

为了降低丢包率对延迟的影响,我们可以在下层加上前向纠错机制(这个机制在kcp官网以及众多kcp协议的应用中都有提及,可去查阅相关资料),通过牺牲一定的带宽发送冗余包的形式,间接降低丢包率。前向纠错策略需要我们将发送的数据包按序分组,每个组每个成员包需要分配一个连续自增id,所以这个id也需要在协议头体现。

一般握手或者断开挥手阶段tcp的话是没加密状态的,完全可通过tcpdump或者wireshark看到我们握手期间互相交流了什么数据,但是udp完全可以做到加密,因为握手过程完全是由用户定义,我们可以在两端加入异或将整个数据包扰乱,只要两端协商好一致的xor key即可。

通常,udp在游戏中只作为辅助连接,我们会有一条加密的tcp长连接或者https短连接的形式和服务端通讯,那么在建立udp连接之前,xor key还有其他属性就可以提前汇报给c/s两端,以便辅助我们无痛创建udp连接。
在这里插入图片描述
如果需要上层做分片,kcp可改成stream模式(不改也可以),上层做数据分片,保证传到kcp端的包体数据小于mss。上层分片的话,kcp包头的frg字段可以去掉,另外上层做握手,kcp头的conv也可以去掉,这样每个kcp包头可以小5字节。

什么时候需要上层分片?通常我们发送的数据超过了kcp的发包上限,就需要做分片处理。如果不确定是否会超,要么做好发送失败的日志记录和异常处理,要么做分片。同时我们也可以调大kcp本身的发送上限限制,默认的上限应该是128*mss个字节。

游戏中connection封装

随着电脑端网页端页游衰落,移动端已作为主流的游戏平台,重连机制在长连接游戏服务端中属于功能标配。没有重连功能,游戏体验必然会受影响。直接裸用tcp来维护和客户端的连通关系其实并不是很合适,我们可以在上层封装一层connection抽象连接。

Connection定义了A端到B端的一条连接,内部维护一条tcp连接(或者udp,或者两种连接各一条。Connection对外无需暴露内部连接,当有网络波动导致tcp断开时,内部尝试重连。我们可以开启tcp fastopen机制。

详请参考http://abcdxyzk.github.io/blog/2018/01/25/kernel-net-fastopen/,如果是udp也可以自行实现这个机制。

向上层只暴露标准的常用接口,比如连接、断开、监听等,以及各种回调,比如连接回调、断开回调、内部连接断开回调。(有这个回调的意义在于服务端可能有需求在客户端断开的时候实时处理一些行为。)
在这里插入图片描述
Connection内部实现发送队列。发送队列存在的意义,一是为了保证发包时序,二是为了内部连接断开时提供发送缓存。

举个例子说明:

Connection内部实现ping/pong,随时监控心跳、对时等,并且提供可配置的超时时间,超时或者主动断开才认为连接断开。

另外connection针对发送队列提供可配置的watermark阈值,当发送数据堆积到阈值后断开,这代表我们的这条连接可能真的出现问题,需要及时回收资源。

另外我们也可以实现udp+tcp双连接,根据延迟情况随时切换。(网游需求可能不多,跨境数据传输可能更有需求。因为通常我们使用udp加速,但是udp在跨境的环境下有时并不是很稳定。)

重连

游戏重连其实是一个大坑。对于策划来讲,这个功能实现很简单,网络断开了,你给我实现重新连回来,恢复以前的状态就行了。但是实际上,重连的策略有非常多的细节需要处理,稍微一个地方没处理好,就会影响游戏体验。因为你断开游戏的前后,很多上下文其实都已经不一样了。举个例子,如果玩家正在打的一局副本,网络断开重连回来时副本早就被销毁了,那么需要把玩家恢复到战斗之前的状态,比如如果玩家是从场景A过来的,那就要把玩家传回场景A;还要恢复原本的战斗状态,需要重设回normal状态等等。

在设计每个系统模块之前,我们都要为重连做好对应的策略。比如做聊天系统时,需要思考玩家加入了聊天频道这个状态,是由聊天系统维护?还是由玩家维护?如果是聊天系统维护,那么每次玩家重连回来,聊天系统就要做一次恢复动作;如果是由玩家本身维护状态,那么最好把这个状态挂在这个玩家统一用作恢复的地方来存储数据,在重连回来时恢复。

短连接在重连问题上是天生友好的,因为它完全不需要考虑重连。它维持的在线状态是由cookie时效决定的。那么我们长连接服务中完全可以实现一套同样的机制,当玩家登录成功后为客户端生成cookie,后期状态全部放到服务端cookie中,就可以解决大部分问题。

什么时候需要重连?通常,只要不是客户端的主动退出行为导致的服务端connection断开,我们都可以认为需要重连。事实上,connection没断开期间,内部其实可能已经有一层重连了,这层重连失效后,才会触发我们第二层的上层逻辑重连,需要手动恢复玩家状态。

然而玩家在游戏过程中可能不止有一条连接跟服务器通讯,比如一条长连接是从登录初期到玩家登出始终伴随玩家整个在线周期,而期间进入战斗的时候会有一条udp连接(前面讲的)。

那么我们可以认为第一条连接断开了就可以视为玩家掉线,而第二条连接断开只影响玩家的战斗,第二条连接重连失败时只是将玩家恢复至非战斗状态或场景。有的游戏设计跟前面的案例不一样,当出现战斗时会直接将第一条连接断开,保证客户端有且只有一条连接存在,当战斗结束时需要进行一次重登录过程或者快速连回游戏后再恢复状态。

所以重连需要系统设计者在开发初期就根据服务端架构定好恢复策略,理清重连规则,避免开发期间连续踩坑。

下图简单展示了上述重连流程:
在这里插入图片描述

在网络连接方面的一些未来方向的思考和脑洞

结合P2P打洞策略

P2P依赖于网关环境,可作为一个参考方向,但不一定用得上。可结合upnp辅助打洞,同一局域网时不同客户端之间可退化为udp直连。比如聊天或者一些多人互动逻辑,可以通过P2P的方式通讯,减少服务器压力并且提高通讯效率,具体的打洞方法就不在这里展开说明。

对战型战斗同步的一个优化思路:
在这里插入图片描述

通常服务器同步形式如上图,每个客户端的请求(状态或者操作)发给服务器,然后服务器校验后向所有客户端广播(大场景一般会考虑AOI优化)。

笔者在这里提供一种新的思路方案如下:
在这里插入图片描述

服务器通过某种策略指定客户端c1做为代理服务器做检查和反馈。前面我们讲过connection的抽象,在这个应用场景中,我们connection代表其他客户端和客户端c1的连接,内部实现为先尝试p2p打洞,打洞失败改成 客户端->server->c1的抽象。

当打洞成功的客户端占半数以上,服务器承认c1的地位,开始通过c1进行游戏。假如没超过一半,那么继续由server端按照最原始的方式进行同步。这种方式的好处,一是减轻server计算负载。当我们所有玩家都在一个局域网时,那么这局对战近乎可视为局域网游戏,server端只做定时的合法性检查。二是减轻server网络io,同时降低客户端通讯延迟。我们在server端决策是否将服务权利下发时可以加入更多策略,比如客户端之间的延迟和客户端与服务端之间的延迟对比等等。

这个形式的缺点是:我们究竟该不该相信这个客户端?万一这个客户端是被用户篡改过的,那么被玩家刷道具经验之类的风险就会提高。

为了应对这个问题我们会在执行策略上加入更多的限制。比如一个开房间类型的游戏,那么必须要求彼此不为好友,ip不一致,匹配进入游戏且当前在线超过一定人数后才尝试开启策略。

每个玩家需要一个举报阈值持久记录。当有一半的客户端举报时,触发服务器强制检查,服务器检查到有犯罪记录就直接采取惩罚措施。举报值永久跟随玩家,进行其他场次也会更新阈值,全场没有举报,阈值减少,有举报就按举报人肉增加。

举报分为代码自动判断行为举报和玩家主动举报(值低,和客户端自动举报乘起来)。举报到一定阈值就惩罚。

一段不为人知的历史,kcp在开源界的兴起

2013年,BTC兴起,笔者揣着6块显卡加入了挖矿大军。从攒机到开始挖矿,一路经历了烧主板、烧主板接口、烧硬盘、烧硬盘、烧硬盘后(后来去修理店给我确诊是电源问题),矿机终于开始稳定工作。这时电费已经涨到每天200,BTC已经跌倒1500rmb左右,于是果断弃坑。当时的矿机长这样:
在这里插入图片描述
图片来源:页游http://www.hp91.cn/页游

由于挖矿期间需要随时进行远程操作。如果这个矿池算力不够了或者快挖空了,或者这个币不好挖而另外的币好挖时,都需要随时更改配置文件重启程序(笔者挖矿的时候刚好过年回老家),因此就急需一个可以随时连到家里ssh-server的工具。当时笔者只有一台国外vps,一开始通过两端连到vps做跳板,但非常的不稳定,每个请求都要跳到国外延迟double,有的时候甚至都卡死几分钟。笔者想用个ddns映射端口出来,但是总感觉麻烦而且对外暴露端口又比较危险,于是萌生了一个想法,为何不自己做个p2p打洞工具呢?于是我就恶补了一些p2p穿透的知识,在不同网络环境下反复调试终于成功打洞。

笔者最早是使用了停等协议来维护连接,请求一个request然后返回response,实现方式简单快速但是效率很低。当时能满足我远程ssh的基本需求,但是后面随着需求变多,笔者逐渐发现这个方式跟不上节奏,急需一套rudp协议来替代。后来笔者想到了kcp, kcp最早被用在cc语音做实时传输,当时kcp的作者韦易笑还在网易工作,项目虽然在googlecode上开源,但是大家好像都不知道也没有关注过。笔者因为在韦易笑的popo(网易的内部IM)签名中看到过这个项目地址,便连夜将代码checkout然后人肉翻译出一个go语言版本(当时打洞逻辑都是go做的),并接入了自己的程序中,测试效果非常完美,和远程直连后的udp连接基本上感受不到延迟(笔者老家到杭州横跨1000多公里)。后来笔者将这个项目在github开源(为了避嫌,隐去名字)并提供了免费的公共p2p端口映射服务,吸引了一批开发人员以及部分上网爱好者的注意,之后各种基于kcp的应用便如雨后春笋般逐渐涌现出来,其中不乏一些优秀的作品比如kcptun,frp等。
在这里插入图片描述

结尾

网络连接做为服务端开发中最基础也是最需要打好基础的一门技能,需要大家平时多学习理论知识,多结合抓包工具写demo练习,并且多多参与游戏开发和运营,当有了一定程度的积累后,你就可以随心所欲的驾驭它,使用它。

本篇文章文字较多作者表达能力有限,希望能给大家在工作中提供一些参考,如有疑问或者建议欢迎大家踊跃讨论。

猜你喜欢

转载自blog.csdn.net/weixin_52308504/article/details/113351332