最近业余时间在写一个小游戏。在为客户端封装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 protocol = new Dictionary();
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 protocol_cb = new Dictionary();
Dictionary protocol_obj = new Dictionary();
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是支持这种功能的。
原文链接:http://blog.gotocoding.com/archives/808