最近业余时间在写一个小游戏。在为客户端封装socket层时头脑一热,有了一些新的想法, 在这里记录一下。
客户端使用的是Unity3d引擎。而在Unity3d中,基础的socket库只提供两种模式,一种是阻塞模式,一种是异步callback模式。
一般都需要基于这两种模式下进一步封装,才可以更方便的使用。
咨询了几个做客户端的并搜了一下,发现大家的惯用手法都是开一个线程去使用socket阻塞去读,然后把读到的数据通过队列传回主线程进行处理。
但是也许是单线程的思维模式已经深入我心了,所以我个人并不是很喜欢这个实现。
在我的设想中,我希望能够在直接在主线程完成对socket的读写及拆包工作。这仅仅是客户端自己的数据,理论上量不会太大,所以即使把这部分工作放入主线程也不会影响渲染。
但是在这种设计下,阻塞模式和异步callback模式都不太合适。因为阻塞模式在read时会使主线程卡住影响渲染,而callback模式则很容易掉入callback hell。
只有非阻塞模式才能满足需要。即,调用socket.read函数时,可以传入任意大小的长度,但是不管有没有读到数据socket.read一定会立即返回。
我基于callback模式重新抽象出了NetSocket模块。
NetSocket模块提供了Connect, Read, Send, Close等4个接口。
NetSocket.Connect提供非阻塞连接而NetSocket.Close提供非阻塞关闭。
NetSocket.Read可以指定任意读取长度,但是不管是否能读取到数据,它都会立即返回。
NetSocket.Send可以发送任意长度数据,并且一定会立即返回。
有了这一组接口后,就可以在主线程毫无顾及的去操作socket而不用担心阻塞及并发问题了。
有了可用的socket组件,下面就需要封装协议包的组成布局了。
为了不粘包,一般都会首先在包头加2~4个字节的包长,指出后面还有多少个数据属于当前这一个包的内容。这个包头长度一般用于数据包拆分。
不管是客户端还是服务器,都需要有一个东西,可以识别这个数据包的内容是什么,那就是command id,即协议ID。
一般来讲client向server请求的并不是都可以成功,如果出错,服务器需要指出这个协议ID的出错信息,即错误码。
而几乎99%的协议请求都不能100%保证必成功,因此将错误码加入包头部分是合理的,那么一整个协议包的内容可能就是这个样子的。
————————–
|包长度|协议ID|错误码|协议内容|
————————–
如包长度,协议ID和协议内容出现在协议包内都是毫无疑问的事,但是错误码很让人纠结。
虽然99%的请求都不一定100%成功,但是也并不会100%失败。而在请求成功时,协议包依然携带了一个0错误码(一般0为Success),我认为这是一种无意义的浪费。
在纠结了一段时间之后,我修改了协议包的组成布局。将错误码从包头中去掉,如下:
———————
|包长度|命令码|协议内容|
———————
至于返回错误码,我把这件事交给了一个通用协议,协议内容定义如下:
struct error { int cmd; int err; }
所有请求出错后,都不再返回相应的协议ID,而是用一个ERROR的协议取代。ERROR协议ID对应的协议结构体是error。
error::cmd用于指出是哪个命令出错了,而error::err用于指出这个命令的出错码。
在接收到ERROR协议之后,上层自动将ERROR协议转换为error::cmd所对应的协议,调用并将error::err作为错误码传给error::cmd对应的处理函数。
由此,我们就可以做到,如果不需要错误码,就不必承受它所带来的开销。
封装完数据包结构,下面就是封装协议序列化了。
发送功能一般没什么好说的,序列化成byte array,然后直接发出去即可。
接收协议就比较麻烦,因为不管怎么样总觉得这样不够完美。最常用的封装方式一般如下:
//Module1.cs void process_cmd1(int cmd, byte[] dat) { cmd1_packet ack = new cmd1_packet(); ack.pares(dat) //do some for request } //NetProtcol.cs void process() { ... //int cmd; //byte[] data; //假设cmd和data已经读取完毕,准备进行反序列化 //假设所有的通讯协议结构均采用类protobuf之类的方式定义 switch (cmd) { case CMD1: Module1.Instance.process_cmd1(cmd, data) break; } ... }
这种方式最大的问题就是,随着命令条数的增加,case会越来越长,不利于阅读。并且每一个函数的开头都有两行固定用于解析协议的代码。
当然case的问题,其实很容易就可以优化掉,只要实现一个map/Dictionary就可以了,比如下面代码:
//NetProtcol.cs 修改代码 Dictionary<int, callback_t> protocol = new Dictionary<int, callback_t>(); void register(int cmd, callback_t cb) { protocol[cmd] = cb; } void process() { ... //int cmd; //byte[] data; //假设cmd和data已经读取完毕,准备进行反序列化 //假设所有的通讯协议结构均采用类protobuf之类的方式定义 //然后把switch语句换成下面代码 if (protocol.ContainsKey(cmd)) protocol[cmd](cmd, data) ... } //Module1.cs 增加代码 void Start() { NetProtocol.Instance.register(CMD1, process_cmd1) }
但是他依然解决不掉每个协议处理函数最开头的那两行协议解析语句。
接收协议部分的封装我并不陌生,在写服务器程序时,我不止一次实现过上述类似的代码,但都只能做到类似map/Dictionary的样子(在强类型语言中)。
这一次在实现时,突发奇想。如果在调用NetProtocol.register函数时,提前把协议包new好,并与cmd进行关联。
那么在处理协议时就可以把ack.pares(dat)之类的协议解析语句,直接放入NetProtocol.process函数中处理。
但是这里需要有一个前提就是所有的协议包都需要有一个基类,并且这个基类提供Parse接口。假设所有的协议包都继承自class wire。那么代码看上去可能就是下面这个样子。
//NetProtcol.cs 修改代码 Dictionary<int, callback_t> protocol_cb = new Dictionary<int, callback_t>(); Dictionary<int, wire> protocol_obj = new Dictionary<int, wire>(); void register(int cmd, wire obj, callback_t cb) { protocol_obj[cmd] = cb; protocol_cb[cmd] = cb; } void process() { ... //int cmd; //byte[] data; //假设cmd和data已经读取完毕,准备进行反序列化 //假设所有的通讯协议结构均采用类protobuf之类的方式定义 //然后把switch语句换成下面代码 if (protocol_obj.ContainsKey(cmd)) { wire obj = protocol_obj[cmd]; obj.Parse(data) protocol[cmd](cmd, obj) } ... } //Module1.cs 增加代码 void process_cmd1(int cmd, wire dat) { cmd1_packet ack = (cmd1_packet) dat; //do some for request } ... void Start() { cmd1_packet ack = new cmd1_packet(); NetProtocol.Instance.register(CMD1, ack, process_cmd1); }
其实这么做只是省了一行代码而已,似乎并不值得如此大费周张。但是,它的意义在于,我们可以借用这种方式,打破在process函数中不可以处理协议反序列化的困境。
在此基础上,我们还可以更近一步,将CMD1和cmd1_packet进行关联,这样在上层我们就可以完全弱化掉cmd的存在,来降低上层应用的使用负担。
当然,这需要使用的类protobuf工具做一些支持,比如可以从cmd1_packet对象反查出与其对应的协议ID。刚好我自己实现的zproto是支持这种功能的。