惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
GbyAI
GbyAI
阮一峰的网络日志
阮一峰的网络日志
Cloudbric
Cloudbric
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
I
Intezer
Simon Willison's Weblog
Simon Willison's Weblog
博客园_首页
The Cloudflare Blog
C
Cisco Blogs
AWS News Blog
AWS News Blog
IT之家
IT之家
Cyberwarzone
Cyberwarzone
罗磊的独立博客
美团技术团队
V
V2EX
Project Zero
Project Zero
A
Arctic Wolf
C
Cyber Attacks, Cyber Crime and Cyber Security
大猫的无限游戏
大猫的无限游戏
博客园 - 叶小钗
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 聂微东
有赞技术团队
有赞技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
雷峰网
雷峰网
S
Schneier on Security
P
Privacy International News Feed
V
Visual Studio Blog
量子位
T
Tor Project blog
S
Securelist
腾讯CDC
A
About on SuperTechFans
T
Threat Research - Cisco Blogs
G
GRAHAM CLULEY
B
Blog RSS Feed
D
DataBreaches.Net
博客园 - 三生石上(FineUI控件)
B
Blog
NISL@THU
NISL@THU
L
Lohrmann on Cybersecurity
V
Vulnerabilities – Threatpost
人人都是产品经理
人人都是产品经理
博客园 - 【当耐特】
L
LINUX DO - 热门话题
Recorded Future
Recorded Future

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构
关于网络协议封装的一些新想法
重归混沌 · 2017-05-28 · via 重归混沌的BLOG

最近业余时间在写一个小游戏。在为客户端封装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是支持这种功能的。