大话客户端网络编程

2016-09-18  本文已影响0人  歌德与巴赫

大话客户端网络编程


前言

其实我和网络编程是有缘分的。我来公司做的第一件事情,就是重构一个网络模块。作为初生牛犊,那时我还不知道网络模块和其它模块有什么不同。不就是一个模块嘛。结果后来就掉了网络模块深深的坑里了。

其实我对网络编程是又爱又恨的。自从做了人生中第一个网络模块后,就像一个魔咒一样,后面经历的每一个项目的网络模块几乎都是我做的。而且无论是项目研发阶段、运营阶段、还是非常稳定的运营阶段,网络模块永远都有做不完的事情。

网络编程,是游戏开发逃脱不了的宿命!


1.网络模块

1.1. 重要

网络模块的重要性,无须多言。

1.2. 神秘

一个项目的模块很多,但是网络模块只有一个。再结合网络模块的重要性,所以,大部分新来的同学,都没有机会做网络模块。

两个项目的背包模块几乎无法相同,但是网络模块却几乎通用。继续结合网络模块的重要性,所以一个项目的网络模块大概只会在——曾经做过网络模块的同学——手里不断重构和完善,最后几乎可以与业务无关。于是,大部分已经工作一段时间的同学,也没有机会做网络模块。

我遇到很多新老同学过来打听网络模块的情况。大家觉得它很神秘,跃跃欲试,却不知从何入手。

1.3. 简单

其实网络模块没有什么神秘。它的一般性框架是这样的(火影手游的PVP网络模块是专用的网络模块,详见我之前的文章):

图1 网络模块的一般性框架

看起来好像很简单,大致就分为两大部分:连接管理器协议管理器。而且这两个管理器的实现也相当简单。大致如下:

图2 连接管理器与协议管理器的实现

到此为止,一个几乎通用的网络模块框架基本上搭完了。是不是很简单?

1.4. 模块糖

模块糖,这是我杜撰的一个词。就像语法糖一样。对于不同的项目,可以给ConnectManager和ProtocolManager加一些糖,让它用起来更甜。比如将SendProtocol(pid, PTLObj, connId)包装成SendDirProtocol(pid,PTLObj)和SendZoneProtocol(pid, PTLObj)等;将CreateConnection(connId,type,ip,port)包装成CreateDirConnection(ip,port)和CreateZoneConnection(ip,port)等。

等等等等。


2.连接层

上面聊了一下网络模块的一般性框架。下面从具体实现来聊聊相关技术。掌握了这些技术点,便可以轻松实现一个网络模块的连接层。

2.1. 关于Socket

Socket就是常说的套接字。说实话我对这个翻译是很懵逼的。Socket就是我们正常网络编程中能够接触到的最底层的通讯接口。对于它的原理,在这篇文章中,我们只意会,不言传。

对于Socket,我们最需要关注的是它的工作方式。在客户端Socket主要有2种工作方式:

图3 Socket同步方式时序图

在同步方式中,可以理解为有一个Loop在不停地轮询是否完成工作,直到工作完成才结束Loop。为了避免UI以及主逻辑被卡住,一般需要将以同步方式工作的操作都放在子线程中。

这个时候,你可能会问,在实际应用中,我们应该选择“多线程同步方式”还是“异步方式”呢?我们先看看下表的对比:

对比 多线程同步方式 异步方式
性能
复杂度
灵活性

所以,如果对网络连接没有特别要求的情况下,优先考虑异步方式,省心高效。但是,如果我们想在UDP基础上实现一个自定义的协议栈,那么可以使用多线程同步方式,把对自定义协议栈的实现逻辑放在子线程中,避免对主线程产生性能影响。

2.2. 关于多线程

当我们不得不使用多线程同步方式时,就要面对一个令很多新同学都感到陌生神秘的东西:线程。由于使用多线程的情况并不多,所以主要掌握以下几点大概便可以在网络编程中使用多线程了。

当然关于多线程的其它知识,有很多专门的文章介绍。

2.3. 关于连接器

Connection是对底层或者基础通讯接口以及可能使用的线程相关逻辑进行封装。一般情况下,按照所使用的通讯接口类型进行封装。

这个世界上有很多种通讯方式,你都可以封装成对应的Connection,以便统一它的通讯接口。除此之外,它主要还将提供Send和Recv的数据缓存。

2.4. 关于数据包/数据流

从图1中,我们看到,一个“协议实例”将转换为一个“协议数据包”,然后“协议数据包”将以“数据流/数据包”的形式发送出去。

在不同的传输协议中,数据的发送形式是不同的。在TCP传输中,数据是以流的形式发送。而在UDP传输中,数据是以包的形式发送。

它们的区别在于,一个数据包里包含一个协议的完整数据。而一段数据流里可能包含的是多个协议的数据,或者一个不完整的协议数据。

2.5. 关于轮询

如果采用Socket的异步方式进行工作,Socket可以直接回调给Connection,然后Connection在回调函数里向ConnectManager发事件。那么,为什么还要使用轮询?因为,如果有些通讯接口不支持异步工作方式(很多自制的硬件其驱动只支持同步操作的),那么就要进行线程同步操作。而这种情况下,一般强烈建议采用主线程向子线程轮询的方式进行数据同步。


3.协议层

协议层相对连接层简单得多。它的主要相关技术如下。

3.1. 协议格式

最基本的协议格式如下:

以上协议格式定义了一个协议数据包。其中DataBuff来自对协议实例的序列化。

为了实现对协议实例的序列化,我们可以自定义一个IProtocolBase接口,让具体协议来实现这个接口。

但是,在实际应用中,我们都是直接使用Google的ProtoBuf作为协议的基类。它已经提供了非常高效的序列化和反序列化功能。

3.2. 协议流

在章节2.4中得知,有些情况下,协议层收到来自连接层的数据,并不一定是一个恰好完整的协议数据包,而有可能一段数据流。于是,为了统一逻辑,不管收到的是数据包,还是数据流,我都将它们统一为协议流

在ProtocolManager中,需要对协议流进行合并或分割处理。其实很简单,它的逻辑流程如下所示。(需要注意的是,如果系统中同时存在多个Connection,需要为每一个Connection定义一个协议流。)

st=>start: OnRecvData
e=>end
e2=>end: 对协议反序列化
op_WriteInPtlStream=>operation: 将数据写入协议流
cond_CanReadPtlHead=>condition: 协议流长度是否够读一个协议头?
op_ReadPtlHead=>operation: 读出协议头数据
op_ReadDataSize=>operation: 得到协议体长度
cond_CanReadPtlBody=>condition: 协议流长度是否够读一个协议体?
op_ReadPtlData=>operation: 取出协议体数据
cond_CheckPtlData=>condition: 协议体数据CheckSum通过?

st->op_WriteInPtlStream->cond_CanReadPtlHead
cond_CanReadPtlHead(yes)->op_ReadPtlHead->op_ReadDataSize->cond_CanReadPtlBody
cond_CanReadPtlHead(no)->e
cond_CanReadPtlBody(yes)->op_ReadPtlData->cond_CheckPtlData
cond_CanReadPtlBody(no)->e
cond_CheckPtlData(yes)->e2->cond_CanReadPtlHead
cond_CheckPtlData(no)->cond_CanReadPtlHead

<center><small>图4 协议流处理逻辑</small></center>

3.3. 协议分类

一般情况下,协议可以分为这几类:

ProtocolManager应该对上面4种协议都能提供支持。

3.4. 协议ID规则

后台喜欢把协议ID叫CMD,或者CmdID。我一般直译为PID。PID的规则一般有两种:


4.调试

无论做什么模块开发,都离不开调试。而网络模块对于调试的要求更高。可以这么说,你编写一个网络模块可能需要2天,但是将来花在调试它的时间可能是直到项目结束。

所以,在你完成网络模块的代码编写之后,一定不要忘记,为了能够高效地调试,做好一切准备。

4.1. 网络日志系统

我相信,你的项目中一定已经有了现成的日志系统。但是那远远不够。建议在其基础上封装一个网络日志系统,并且为它提供一个专用面板。它会比在总日志文本里看网络日志要高效得多,性能也可控得多。它应该提供如下功能:

4.2. 网络状况模拟

在研发阶段,这个功能是非常有用的。可以帮助你高效测试网络模块在各种网络情况下,是否正常工作。也可以为业务模块提供网络相关的测试手段。比如,测试在线模块,断线重连逻辑(再也不需要拨网线了)等。

4.3. 抓包工具

一般使用Wireshark和Fiddler。网络编程必备工具。

上一篇下一篇

猜你喜欢

热点阅读