大话客户端网络编程(1)一个普通的网络模块
发表于2016-11-03
大部分项目对网络的需求都比较简单。主要需要满足登录、购买、显示背包物品等低频的协议请求。面对这部分需求,只要一个普通的网络模块就可以搞定。
我们就先从这一部分开始,编写一个普通的网络模块。
一、模块设计
1、重要
网络模块的重要性,无须多言。
2、神秘
一个项目的模块很多,但是网络模块只有一个。再结合网络模块的重要性,所以,大部分新来的同学,都没有机会做网络模块。
两个项目的背包模块几乎无法相同,但是网络模块却几乎通用。继续结合网络模块的重要性,所以一个项目的网络模块大概只会在——曾经做过网络模块的同学——手里不断重构和完善,最后几乎可以与业务无关。于是,大部分已经工作一段时间的同学,再也没有机会做网络模块。
我遇到很多新老同学过来打听网络模块的情况。大家觉得它很神秘,跃跃欲试,却不知从何入手。
现在就有一个机会,我们一起来设计和实现一个网络模块。
3、简单
其实网络模块没有什么神秘。它的一般性框架是这样的(火影手游的PVP网络模块是专用的网络模块,详见我之前的文章,以及后续的教程):
图1网络模块的一般性框架
看起来好像很简单,大致就分为两大部分:连接管理器和协议管理器。而且这两个管理器的实现也相当简单。大致如下:
图2连接管理器与协议管理器的实现
ConnectManager。它维护一个Connection实例列表。这些实例根据底层通讯接口的不同,有多种类型。Connection封装了数据的收发逻辑,它分别为Send和Receive提供Buffer。它主要实现以下功能:
创建连接。这部分功能主要在XXConnection类实现,不同的通讯接口,连接方式不同。比如Apollo的通讯接口,需要提供公司的一揽子参数。而Bluetooth接口而需要提供BluetoothMacAddress。而采用底层的Socket实现的UDP通讯接口,则无须Connect过程,那么我们就给它虚拟一个假的Connect过程,以保持IConnection接口的统一性。等等等等。
收发数据。大部分通讯接口对数据的Recv是通过轮询实现的,在ConnectManager里将轮询操作统一转换为事件方式。
断线重连。Connection分别为Send和Recv提供了Buffer,以便支持静默重连,使上层逻辑在大部分情况下无须关心网络是否断开,也可以发送数据。比如,网络突然断开了,假设A模块不负责维护在线状态,那么在它看来,它依然可以正常发送数据。假设B模块负责维护在线状态,那么它应该监听到网络断开,然后进行重连,最后重连成功。在网络重连成功后,缓存在Connection的数据,就可以发送出去了。整个过程,对A模块是不可感知的。(是不是觉得断线静默重连也没有想像中那么复杂了?)
ProtocolManager。相对ConnectManager,它简单得多。它维护一个从协议ID到协议类的映射(对于C#这种具有反射机制的语言,可以直接映射到协议类,但是对于C++则可以用其它方法来实现映射)。并且定义了协议格式。
到此为止,一个几乎通用的网络模块框架基本上搭完了。是不是很简单?
4、模块糖
模块糖,这是我杜撰的一个词。就像语法糖一样。对于不同的项目,可以给ConnectManager和ProtocolManager加一些糖,让它用起来更甜。比如将SendProtocol(pid,PTLObj,connId)包装成SendDirProtocol(pid,PTLObj)和SendZoneProtocol(pid,PTLObj)等;将CreateConnection(connId,type,ip,port)包装成CreateDirConnection(ip,port)和CreateZoneConnection(ip,port)等。Dir和Zone在网络模块中的含义大家应该都知道。
等等等等。
二、连接层实现
上面聊了一下网络模块的一般性框架。下面从具体实现来聊聊相关技术。掌握了这些技术点,便可以轻松实现一个网络模块的连接层。
1、关于Socket
Socket就是常说的套接字。说实话我对这个翻译是很懵逼的。Socket就是我们正常网络编程中能够接触到的最底层的通讯接口。对于它的原理,在这篇文章中,我们只意会,不言传。
对于Socket,我们最需要关注的是它的工作方式。在客户端Socket主要有2种工作方式:
同步方式。无论是UDP还是TCP,在用Socket进行连接、发送、接收的时候,在未完成工作前代码不再继续往下执行,处于等待状态,直到该语句完成对应个工作后才继续执行下一条语句。值得注意的是,UDP和TCP对于一件工作是否完成的定义不同,以Send为例,如下图。
对于TCP来说,未完成工作就是:(1)缓冲区满了,数据无法写入,(2)或者数据写入了但是还没轮到它发送,(3)或者数据发送了,但是未收到ACK确认。
对于UDP来说,未完成工作就是:缓冲区满了,数据无法写入。
图3Socket同步方式时序图
异步方式。即不论对应工作是否完成,都会继续往下执行。当工作完成后,是通过一个回调来通知调用者(千万注意:这个回调是在一个Socket内部创建的子线程上下文中)。参照上图,不需要单独用时序图来说明了。
在同步方式中,可以理解为有一个Loop在不停地轮询是否完成工作,直到工作完成才结束Loop。为了避免UI以及主逻辑被卡住,一般需要将以同步方式工作的操作都放在自制的子线程中。而在异步方式中,实质上是Socket内部创建了一个子线程。
那么综合以上情况,在实际使用中,我们应该选择“同步方式”还是“异步方式”呢?我做的不完全性能测试的结论是,同步方式的性能大概是异步方式的4倍。这是很容易理解的,因为这里所谓的异步方式其实就是Socket内部帮我们做了一个线程,而且它为了考虑到基础组件的通用性,肯定在性能方面会有所损耗。
看看下表的对比:
所以,如果对网络连接没有特别要求的情况下,比如独立小游戏,可优先考虑异步方式,省心省事。但是,我更愿意使用同步方式+自制线程,对于系列性能更加可控。
2、关于多线程
当我们不得不使用子线程时,就要面对一个令很多新同学都感到陌生神秘的东西:线程。由于使用多线程的情况并不多,所以主要掌握以下几点大概便可以在网络编程中使用多线程了。
线程函数。如果说主线程是从Main函数开始的(在Unity+C#里,你是看不到Main函数的。),那么子线程也是从一个函数开始。为了防止主线程与子线程的代码逻辑搞混,建议将线程函数定义在一个单独的类里。由这个函数所调用的所有被调用函数都在这个类里。
前台线程和后台线程。切记,系统默认创建的子线程是前台线程,它将带来一个问题,就是当主线程已经结束时,程序还会运行。如果将它设置为后台线程,则当主线程结束时,所有后台线程都会无异常中止。
线程同步。当主线程和子线程存在共用数据时,为了避免多线程同时操作同一数据,需要使用“锁”。C#有多种锁定方式,比较常用的是lock语句。建议不要直接lock需要操作的数据,而是为这个数据定义一个对应的object,lock这个object。因为有些类型的数据,比如int,是无法直接lock的。
异常处理。使用Try/Catch进行异常处理时,不要在线程的创建处TryCatch。一旦线程创建成功,线程执行过程中的异常,是无法在其它线程中被捕获的。正确的做法是在线程函数里TryCatch。
当然关于多线程的其它知识,有很多专门的文章介绍。
3、关于TryCatch
对于C#来讲,你使用或者不使用TryCatch,对于性能的消耗是一样的。甚至你在离Exception最近的地方使用了TryCatch,还会提高性能。因为如果一当发生Exception,运行时会依次向上递归寻找TryCatch代码,最终会找到运行时那一层去,然后成功被运行时Catch到。与其如此,为什么不自己去Catch呢?所以,应该积极地在适当的地方使用TryCatch,但是一定要在Catch后进行处理并且输出日志,否则就隐藏了问题!
4、关于连接
Connection是对底层或者基础通讯接口以及可能使用的线程相关逻辑进行封装。一般情况下,按照所使用的通讯接口类型进行封装。
如果使用Apollo的通讯组件,可以封装成ApolloConnection。
如果使用Socket,则封装成TCPConnection/UDPConnection。
如果使用Bluetooth,则封装一个BluetoothConnection。
如果使用RS232串口通讯,则封装一个RS232Connection。
这个世界上有很多种通讯方式,你都可以封装成对应的Connection,以便统一它的通讯接口。除此之外,它主要还将提供Send和Recv的数据缓存。
5、关于数据包/数据流
从图1中,我们看到,一个“协议实例”将转换为一个“协议数据包”,然后“协议数据包”将以“数据流/数据包”的形式发送出去。
在不同的传输协议中,数据的发送形式是不同的。在TCP传输中,数据是以流的形式发送。而在UDP传输中,数据是以包的形式发送。
它们的区别在于,一个数据包里包含一个协议的完整数据。而一段数据流里可能包含的是多个协议的数据,或者一个不完整的协议数据。
6、关于轮询
无论是使用同步方式还是异步方式,都会发生——主线程从子线程读取数据——操作。有些同学喜欢采用抛事件的方式,但是,那样只会使子线程的上下文扩散得更广泛更乱。如果有一天你发生在一个事件的回调函数里调用Time、realtimeSinceStartup一直莫名其妙报错,那么,这个回调函数一定是从一个子线程里调出来的。但是,你完全懵逼。
所以,建议采用轮询这种古老的方式,将子线程的上下文限制在一个轮小的范围里。
除了以上主要原因外,还有一个原因:异常隔离。防止业务层模块异常导致整个Connection的异常。因为Connection作为基础功能,还有其它模块在使用。
三、协议层实现
协议层相对连接层简单得多。它的主要相关技术如下。
1、协议格式
最基本的协议格式如下:
以上协议格式定义了一个协议数据包。其中DataBuff来自对协议实例的序列化。
为了实现对协议实例的序列化,我们可以自定义一个IProtocolBase接口,让具体协议来实现这个接口。
但是,在实际应用中,我们都是直接使用Google的ProtoBuf作为协议的基类。它已经提供了非常高效的序列化和反序列化功能。
2、协议流
在章节二、4中得知,有些情况下,协议层收到来自连接层的数据,并不一定是一个恰好完整的协议数据包,而有可能一段数据流。于是,为了统一逻辑,不管收到的是数据包,还是数据流,我都将它们统一为协议流。
在ProtocolManager中,需要对协议流进行合并或分割处理。其实很简单,它的逻辑流程如下所示。(需要注意的是,如果系统中同时存在多个Connection,需要为每一个Connection定义一个协议流。)
图4协议流处理逻辑
3、协议分类
一般情况下,协议可以分为这几类:
只发送,不需要监听回包。用于向服务器上报数值。
无发送,只需要监听回包。用于服务器Push数值,或者触发逻辑。
一处发送,多处监听回包。用于基础功能协议。
一处发送,一处监听回包。用于具体功能协议。
ProtocolManager应该对上面4种协议都能提供支持。
4、协议ID规则
后台喜欢把协议ID叫CMD,或者CmdID。我一般直译为PID。PID的规则一般有两种:
同一条协议,发包和回包时,PID相同。因为发包和回包时,虽然协议体内容不同,但却是一回一答,是为同一个功能服务的。
同一条协议,发包和回包时,PID不同。因为发包和回包时,协议体的内容不同。目前比较流行这种方式。为了使编程更方便,以及代码容易理解,一般将回包的PID定义为发包的PID+1。
四、调试
无论做什么模块开发,都离不开调试。而网络模块对于调试的要求更高。可以这么说,你编写一个网络模块可能需要2天,但是将来花在调试它的时间可能是直到项目结束。
所以,在你完成网络模块的代码编写之后,一定不要忘记,为了能够高效地调试,做好一切准备。
1、网络日志系统
我相信,你的项目中一定已经有了现成的日志系统。但是那远远不够。建议在其基础上封装一个网络日志系统,并且为它提供一个专用面板。它会比在总日志文本里看网络日志要高效得多,性能也可控得多。它应该提供如下功能:
单独输出网络模块的日志。
以16进制显示每一个Connection的Send和Recv缓冲区数据。这是你能够接触到的最底层接口的数据,后台会经常和你Check这些数据。
列出每一条被注册的协议。
记录发送和接收到的每一条协议的内容。如果该协议是注册的,则可以反序列化为结构性信息,如果未注册,则提示未注册,并且显示16进制数据。
统计断线重连次数,断线时长,网络延时等。
2、网络状况模拟
在研发阶段,这个功能是非常有用的。可以帮助你高效测试网络模块在各种网络情况下,是否正常工作。也可以为业务模块提供网络相关的测试手段。比如,测试在线模块,断线重连逻辑(再也不需要拨网线了)等。
3、抓包工具
一般使用Wireshark和Fiddler。网络编程必备工具。