前言
在疫情的背景下,网游很有市场,这是我初出茅庐尝试搭建网络同步框架(SGNet),本文描述该框架下的编解码设计,编解码目标是兼容大部分数据格式。
开发环境
- Uinty 2020.3.18f1c1
- VS2022
- protobuf-net、System.Text.Json dll
- 编码格式使用长度信息法
一、了解编数据格式
1.Json
Json是目前web最流行的数据格式,我最喜欢它的优点是方便阅读。
值得注意的是:
Unity虽然说可支持.net 4.x但是经测试,没有json库,需要导入,方法不多做介绍(参考)。
根据msdn文档一般用到下面两个api:
// 形参省略了库名和参数名
// 编码api
JsonSerializer.Serialize(object, Type, JsonSerializerContext);
// 解码api
JsonSerializer.Deserialize(ReadOnlySpan<Byte>, Type, JsonSerializerContext);
注意到这里,需要获取类类型。
2.protobuf
protobuf是谷歌的一种数据格式,Unity里我使用了protobuf-net,因为没找到文档,下面的api是我摸索出来的,可能有更好的方法。
// 形参省略了库名和参数名
// 编码api
Serialize(stream, T);
// 解码api
Deserialize(Type, stream);
同样需要知道类类型。
3.待了解其他数据格式
二、消息协议的设计
1.第一版本
首先,每个消息都应该继承于MessageProtocol,便于归类管理,可存放在同一类连表里,然后有个问题,protobuf是自动生成代码的,每次修改proto文件后生成代码,会把上一次添加的继承给覆盖,因此需要套一层数据结构去包含它,添加必要的信息,此处名为消息协议,另外虽然继承与一个基类global::ProtoBuf.IExtensible,但是在运行时的父类时object,所以还是套一层数据结构较佳。“所有问题都可以通过增加一层来解决”。
消息封装
public abstract class MessageProtocol
{
public string protoName = "";
// 有两个减少反射使用的方法
// 1.可以添加属性给子类重写,获取对象类型,方便编码时获取类型。
// 2.可以添加字典,每新增一个子类就在子类的静态构造函数里注册子类类型,方便解码时获取类型。
// 网络传输只有消息是有效信息,因此还可以把在基类设置消息属性,给子类重写,在编解码的时候获取消息(应该叫拆包吧)即可。
}
public class SystemRoomCancelReady : MessageProtocol
{
public RoomCancelReadyMsg roomCancelReady;
public SystemRoomCancelReady()
{
protoName = "SystemRoomCancelReady";
roomCancelReady = new RoomCancelReadyMsg();
}
}
//... ...
解码器
public interface ICoder
{
byte[] Encode(MessageProtocol message);
MessageProtocol Decode(byte[] messageBuffer, string protocolName, UInt16 start, UInt16 length);
}
public class JsonCoder : ICoder
{
static JsonSerializerOptions options = new JsonSerializerOptions
{
IncludeFields = true, // 序列化所有公共字段,或者字段加{get;set;}
};
public byte[] Encode(MessageProtocol message)
{
return System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, message.GetType(), options));
}
public MessageProtocol Decode(byte[] messageBuffer, string protocolName, UInt16 start, UInt16 length)
{
ArraySegment<byte> buffer = new ArraySegment<byte>(messageBuffer, start, length);
return (MessageProtocol)JsonSerializer.Deserialize(buffer , Type.GetType(protocolName), options);
}
}
public class ProtobufCoder: ICoder
{
public byte[] Encode(MessageProtocol message)
{
MemoryStream ms;
using (ms = new MemoryStream())
{
ProtoBuf.Serializer.Serialize(ms, message.Message);
}
return ms.ToArray();
}
public MessageProtocol Decode(byte[] messageBuffer, string protocolName, UInt16 start, UInt16 length)
{
using (var ms = new MemoryStream(messageBuffer, start, length))
{
obj = ProtoBuf.Serializer.Deserialize(msg.MsgType, ms);
}
return obj;
}
}
总结: 这种方法有三个缺点,一是频繁的调用反射Gettype(解决方法见消息协议基类),二是消息协议类会随消息类一起增长,编写过程机械,可以用代码生成器解决(但是我不会)。可是这种方法相对第二种方法,方便测试,可以直接遍历基类的字典,便可全部消息收发一遍,第三是应该传输消息而非消息协议,节省流量(解决方法见消息协议基类)。
2.第二种方法
基于第一种方法的总结,再观察消息协议类的构成,不难发现,每个类仅仅只是包含的消息类不同,这个时候就应该想到泛型,减少没有意义的重复代码。并结合总结中的改进方法修改。
消息协议基类
public abstract class BaseMsg
{
public abstract Type MsgType { get; }
public abstract string MsgName { get; }
public abstract object Message { get; set; }
public static Dictionary<string, BaseMsg> MessageDict = new Dictionary<string, BaseMsg>();
public abstract BaseMsg Clone();
}
因为仅传输消息,解码后使用字典找到对应的消息协议,把消息赋值给消息协议的一份克隆,然后返回。
public class NetworkMsg<T> : BaseMsg where T : new()
{
static Type msgType = typeof(T);
public T message;
public override Type MsgType => msgType;
public override string MsgName => msgType.Name;
public override object Message
{
get { return message; }
set { message = (T)value; }
}
static NetworkMsg()
{
MessageDict.Add(msgType.Name, new NetworkMsg<T>());
}
public NetworkMsg()
{
message = new T();
}
public NetworkMsg(T msg)
{
message = msg;
}
public override BaseMsg Clone()
{
return new NetworkMsg<T>(default);
}
}
可惜该方法并非十全十美,有两个缺点,一是上面提到的无法靠反射生成所有消息协议类测试,二是没有有效的手段约束传进泛型里的消息,可能传错类型,导致编解码异常。
总结
新手确实不应该一上来就做框架性的东西,浪费了我很多时间设计,而且不怎么符合心意,但是为了实现游戏同步又不得不搭建一个C/S框架,或许我应该从单机做起吧。
参考资料:
罗培羽大佬的《unity游戏网络实战》《百万在线》
TSRPC + Cocos 多人实时对战 Demo