美文网首页
大话客户端网络编程

大话客户端网络编程

作者: 歌德与巴赫 | 来源:发表于2016-09-18 22:38 被阅读0次

    大话客户端网络编程

    • Slicol

    前言

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

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

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


    1.网络模块

    1.1. 重要

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

    1.2. 神秘

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

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

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

    1.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++则可以用其它方法来实现映射)。并且定义了协议格式。

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

    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种工作方式:

    • 同步方式。无论是UDP还是TCP,在用Socket进行连接、发送、接收的时候,在未完成工作前代码不再继续往下执行,处于等待状态,直到该语句完成对应个工作后才继续执行下一条语句。值得注意的是,UDP和TCP对于一件工作是否完成的定义不同,以Send为例,如下图。
      • 对于TCP来说,未完成工作就是:(1)缓冲区满了,数据无法写入,(2)或者数据写入了但是还没轮到它发送,(3)或者数据发送了,但是未收到ACK确认。
      • 对于UDP来说,未完成工作就是:缓冲区满了,数据无法写入。
    图3 Socket同步方式时序图
    • 异步方式。即不论对应工作是否完成,都会继续往下执行。当工作完成后,是通过一个回调来通知调用者。参照上图,不需要单独用时序图来说明了。

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

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

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

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

    2.2. 关于多线程

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

    • 线程函数。如果说主线程是从Main函数开始的(在Unity+C#里,你是看不到Main函数的。),那么子线程也是从一个函数开始。为了防止主线程与子线程的代码逻辑搞混,建议将线程函数定义在一个单独的类里。由这个函数所调用的所有被调用函数都在这个类里。

    • 前台线程和后台线程。切记,系统默认创建的子线程是前台线程,它将带来一个问题,就是当主线程已经结束时,程序还会运行。如果将它设置为后台线程,则当主线程结束时,所有后台线程都会无异常中止。

    • 线程同步。当主线程和子线程存在共用数据时,为了避免多线程同时操作同一数据,需要使用“”。C#有多种锁定方式,比较常用的是lock语句。建议不要直接lock需要操作的数据,而是为这个数据定义一个对应的object,lock这个object。因为有些类型的数据,比如int,是无法直接lock的。

    • 异常处理。使用Try/Catch进行异常处理时,不要在线程的创建处TryCatch。一旦线程创建成功,线程执行过程中的异常,是无法在其它线程中被捕获的。正确的做法是在线程函数里TryCatch。

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

    2.3. 关于连接器

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

    • 如果使用Apollo的通讯组件,可以封装成ApolloConnection。
    • 如果使用Socket,则封装成TCPConnection/UDPConnection。
    • 如果使用Bluetooth,则封装一个BluetoothConnection。
    • 如果使用RS232串口通讯,则封装一个RS232Connection。

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

    2.4. 关于数据包/数据流

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

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

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

    2.5. 关于轮询

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

    • 可扩展性。既然轮询不可避免,为了扩展性,就统一使用轮询机制来在ConnectManager和Connection之间通讯。
    • 异常隔离。防止业务层模块异常导致整个Connection的异常。因为Connection作为基础功能,还有其它模块在使用。

    3.协议层

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

    3.1. 协议格式

    最基本的协议格式如下:

    • 协议头
      • PID: 协议ID
      • Index: 协议发送序列号
      • DataBuffSize: 协议体的数据长度
      • CheckSum: 校验和
    • 协议体
      • DataBuff: 协议数据Buffer。

    以上协议格式定义了一个协议数据包。其中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. 协议分类

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

    • 只发送,不需要监听回包。用于向服务器上报数值。
    • 无发送,只需要监听回包。用于服务器Push数值,或者触发逻辑。
    • 一处发送,多处监听回包。用于基础功能协议。
    • 一处发送,一处监听回包。用于具体功能协议。

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

    3.4. 协议ID规则

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

    • 同一条协议,发包和回包时,PID相同。因为发包和回包时,虽然协议体内容不同,但却是一回一答,是为同一个功能服务的。
    • 同一条协议,发包和回包时,PID不同。因为发包和回包时,协议体的内容不同。目前比较流行这种方式。为了使编程更方便,以及代码容易理解,一般将回包的PID定义为发包的PID+1。

    4.调试

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

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

    4.1. 网络日志系统

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

    • 单独输出网络模块的日志。
    • 以16进制显示每一个Connection的Send和Recv缓冲区数据。这是你能够接触到的最底层接口的数据,后台会经常和你Check这些数据。
    • 列出每一条被注册的协议。
    • 记录发送和接收到的每一条协议的内容。如果该协议是注册的,则可以反序列化为结构性信息,如果未注册,则提示未注册,并且显示16进制数据。
    • 统计断线重连次数,断线时长,网络延时等。
    4.2. 网络状况模拟

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

    4.3. 抓包工具

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

    相关文章

      网友评论

          本文标题:大话客户端网络编程

          本文链接:https://www.haomeiwen.com/subject/rxmgettx.html