
前言
网络是游戏开发的重要一环,许多Unity网络相关的项目都是基于Unet实现的,可以在引擎内通过组件的方式较轻松的实现网络功能,但现已被弃用,替代产品有:
Mirror Networking:https://github.com/vis2k/Mirror
Phton:https://www.photonengine.com/
近期腾讯也和Unity合作推出了游戏联机服务的C# SDK: https://cloud.tencent.com/document/product/1038
框架也有:
服务端ComBlock:https://github.com/kbengine/kbengine
服务端Pomelo:https://github.com/NetEase/pomelo
双端ET:https://github.com/egametang/ET
等。要想学习这部分的引擎,首先基础要打好,所以想到先从自己实现一个简单的客户端/服务端来了解多人游戏基本的工作流。项目服务端使用c#编写,数据库选用Mysql,客户端使用Unity引擎。
参考书籍:
《Unity3D网络游戏实战》-罗培羽https://luopeiyu.github.io/unity_net_book/
项目地址
客户端:https://github.com/TLS18/Block-Client
服务端:https://github.com/TLS18/Block-Server
整体架构

实现了一个较为基础的服务端,市面上成熟的服务端架构要比这个复杂许多,例如增加分区分服,增加数据库访问代理,处理数据缓存等大量优化。下面分客户端和服务端两方面来记录学习历程
服务端部分
DataMgr类
该类用于连接数据库,封装了访问数据库的所有方法,通过Mysql提供的connector能在c#中访问修改数据库,在此之前需要先连接上数据库
1 | class DataMgr |
其中一个判断角色是否能注册的方法(查询数据库内是否有某个id对应的记录)
1 | private bool CanRegister(string id) |
sql注入
需要注意的一点是要防止sql注入,简单来说就是服务端需要向数据库进行sql查询,一些场景下需要结合用户输入动态构建sql语句,例如上面这个方法要使用用户id来查询,此时用户就可以在id中加入非法语句来达到查询修改数据库的目的,所以在id上使用了一个简单的IsSafeStr()来判断输入是否符号要求,代码如下
1 | public bool IsSafeStr(string str) |
更多关于数据库及网络安全的知识还有待学习,记录一个用于渗透测试的平台留待研究,里面就有关于sql注入的部分
ServMgr类
该类负责管理所有客户端的连接,包括消息接收/分发、消息发送、维护连接状态等功能。在初始化时会建立一个Socket对象并开始异步监听是否有客户端连接,客户端连接成功后使用socket.BeginReceive()异步接收该连接的消息
1 | public void Start(string host,int post) |
当服务端接收到来自客户端的消息时,调用定义好的回调函数ReceiveCb,将消息根据种类分发给不同的类去执行,关于如何辨别消息的种类后面会有记录
TCP粘包分包
这里需要注意的是,由于TCP协议的机制,如果遇到较小的数据包,会等待其他数据包到来一同发送,或者缓冲区有数据未取出,下次就会一口气取出多个包,数据接收端就无法判断这个包是否一同处理;相反如果遇到较大的数据包,会将其拆分发送。故以上两种情况会造成粘包分包问题,一种解决方法是在每个包的前面增加一个包长度,当接收端处理数据时,先根据缓冲区里数据量和给定包的长度进行判断,这样就能保证每次都能正确的取出数据
1 | private void ProcessData(Conn conn) |
心跳机制
当一个客户端出现问题,比如网络断线,客户端闪退等没有正常下线的场景,与客户端的连接就会变为死连接,即无法接收到客户端的消息且没有主动关闭。可以加入心跳机制解决问题,客户端定时给服务端发送心跳协议,一般5s左右发送一次,更新服务端的心跳时间,当心跳时间超过一定阀值时,判断连接失效,主动去断开与客户端的连接
消息分发
对消息进行处理时,先根据其消息类型进行分发,然后由不同的类去执行,例如服务端收到了创建角色的消息,在HandlePlayerMsg中有这样一个方法
1 | public void MsgCreatePlayer(Player player,ProtocolBase protoBase) |
在ServMgr中可以这样访问,通过C#的反射,可以在程序运行期获取类型信息,这里使用MethodInfo可以获取到方法的名称参数返回值等等,接着构建一个数组保存要传入方法的参数,当类型不确定的时候可以使用Object类,接着就是使用MethodInfo.Invoke调用给定对象的方法
1 | public HandlePlayerMsg handlePlayerMsg = new HandlePlayerMsg(); |
Player类
该类作为连接Conn类和管理ServMgr类的中间层,可以看作是服务器里每一个玩家的抽象,其包含成员
1 | //玩家id |
Scene类
该类用于管理服务器场景,在玩MMORPG的时候,经常会有地图或者副本的概念,一个副本就是一个场景,用户加载资料的时候,只需要专注于场景里的玩家,所以需要一个场景管理类来管理该场景里所有玩家的信息。本项目结构简单只有一个场景,如果需要多个场景可以建立Scene类的列表来实现。当有玩家进入场景时,该类需要做的就是创建ScenePlayer对象来表示其在场景中的状态,同时将同场景下的所有玩家的信息发送给该玩家,队伍或者房间也同理
1 | public class ScenePlayer |
服务端与客户端的协议
字节流协议
在运行时服务端与客户端之间会发送不同类型的消息,要想方便的对不同消息类型进行处理,需要创建一个协议来规定两端之间通信的内容,网络通信的参与方必须遵守协议才能互相传输信息
例如可以通过字符串协议,客户端向服务端发送一个字符串表示注册账号
| 消息长度(粘包分包处理) | 消息内容 |
|---|---|
| Int(xxx) | Register,userId,password |
字符串用逗号隔开,第一个表示方法名,后面的表示参数,这样传输到服务端后就会交由MsgRegister来对参数userId和password进行处理,但这样有一个漏洞,如果客户端发送了一段带有逗号的字符串会引发错误,所以书籍中采用了字节流协议的方法,将方法名和参数全部放进一个字节数组中,客户端和服务端依照约定好的顺序依次从数组中读取值。这种协议可以表示下面几种数据类型
| 数据类型 | 占用字节 |
|---|---|
| Int | 4 |
| Float | 8 |
| String | ↓ |
字符串较特殊,为了保证其完整性,我们需要在其前面加上表示字符串字节长度的int型数据,例如
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 8 | R | e | g | i | s | t | e | r |
再将其放入字节数组中
Protocol类
基类
除了字节流协议,服务端还要能支持多种协议,定义一个基类,包含协议必须要支持的编码,解码,获取协议名称等功能(名称指的是登录协议,注册协议等)
1 | public class ProtocolBase |
字节流类
需要注意的是添加和获取字符串的方法,获取字符串里的参数end加上了ref关键字,表示该参数在方法内的改变会影响到原本的变量
1 | public void AddString(string str) |
构建字节流协议
发送端
1 | ProtocolBytes protocol = new ProtocolBytes(); |
使用字节流协议
接收端,由于ref关键字,每次读取完后start的位置会往后移动
1 | public void MsgRegister(Conn conn, ProtocolBase protoBase) |