这是一个 简单易用的 网络库。
这是一个网络模块的通用解决方案。设计目的为应用程序网络模块提供统一的HighLevel接口。
整个类库被拆分为多个dll。简单来说:NetRemoteStandard.dll是标准,里面只有接口定义;Megumin.Remote.dll是一种实现。类比于dotnetStandard和dotnetCore的关系。
为什么要拆分为多个dll? 具体实现可能需要依赖很多其他dll,而接口定义并不需要这些依赖。对于只想使用接口,自定义实现的用户来说,引入额外的依赖是不必要的。例如MessageStandard,用户仅引用自己选择的序列化库即可,而不必引用多个序列化库。
是的,使用Nuget获取Megumin.Remote。但是注意,需要搭配序列化库,不同的序列化库可能有额外的要求。
由于使用了C# 7.3语法,在unity中如果使用源码至少需要2018.3。
目标框架netstandard2.1,在unity中建议unity版本2021.2以上。过小的版本可以使用源码,但需要自行解决依赖关系。
or add "com.megumin.net": "https://github.com/KumoKyaku/Megumin.Net.git?path=UnityPackage/Packages/Net"
to Packages/manifest.json
.
If you want to set a target version, uses the
*.*.*
release tag so you can specify a version like#2.1.0
. For examplehttps://github.com/KumoKyaku/Megumin.Net.git?path=UnityPackage/Packages/Net#2.1.0
.
Span<T>
。使用System.IO.Pipelines
作为高性能IO缓冲区。MIT许可证
设计原则:最常用的代码最简化,复杂的地方都封装起来。
发送一个消息,并等待一个消息返回
是类库的全部内容。
从结果值返回异常是有意义的:
///实际使用中的例子
IRemote remote = new TCPRemote(); ///省略连接代码……
public async void TestSend()
{
Login login = new Login() { Account = "LiLei", Password = "HanMeiMei" };
/// 泛型类型为期待返回的类型
var (result, exception) = await remote.SendAsync<LoginResult>(login);
///如果没有遇到异常,那么我们可以得到远端发回的返回值
if (exception == null)
{
Console.WriteLine(result.IsSuccess);
}
}
方法签名:
ValueTask<Result> SendAsyncSafeAwait<Result>(object message, object options = null, Action<Exception> onException = null);
结果值是保证有值的,如果结果值为空或其他异常,触发异常回调函数,不会抛出异常,所以不用try catch。异步方法的后续部分不会触发
,所以后续部分可以省去空检查。
(注意:这不是语言特性,也不是异步编程特性,这依赖于具体Remote的实现,这是类库的特性。如果你使用了这个接口的其他实现,要确认实现遵守了这个约定。
)
IRemote remote = new TCPRemote(); ///省略连接代码……
public async void TestSend()
{
Login login = new Login() { Account = "LiLei", Password = "HanMeiMei" };
/// 泛型类型为期待返回的类型
LoginResult result = await remote.SendAsyncSafeAwait<LoginResult>(login, (ex)=>{});
///后续代码 不用任何判断,也不用担心异常。
Console.WriteLine(result.IsSuccess);
}
虽然不推荐一个请求对应多个回复类型,但是某些业务设计仍然有此需求。比如将所有errorcode作为一个独立类型回复,那么一个请求就有可能有对应回复和errorcode两个回复类型。
protobuf协议中可以使用 IMessage接口
作为等待返回的类型。
class ErrorCode{}
class Resp{}
class Req{}
async void Test(IRemote remote){
Req req = new Req();
///泛型中填写所有期待返回类型的基类,然后根据类型分别处理。
///如果泛型处仅使用一种类型,那么服务器回复另一种类型时,底层会转换为 InvalidCastException 进如异常处理逻辑。
var ret = await remote.SendAsyncSafeAwait<object>(req);
if(ret is ErrorCode ec)
{
}
else if(ret is Resp resp)
{
}
}
ValueTask<object> OnReceive(short cmd, int messageID, object message);
接收端回调函数
protected virtual async ValueTask<object> OnReceive(short cmd, int messageID, object message)
{
switch (message)
{
case TestPacket1 packet1:
Console.WriteLine($"接收消息{nameof(TestPacket1)}--{packet1.Value}");
return null;
case Login login:
Console.WriteLine($"接收消息{nameof(Login)}--{login.Account}");
return new LoginResult { IsSuccess = true };
case TestPacket2 packet2:
return new TestPacket1 { Value = packet2.Value };
default:
break;
}
return null;
}
具体响应方式参考PreReceive
函数源码,参考IPreReceiveable,ICmdOption,SendOption.Echo等。
Heartbeat,RTT,Timestamp Synchronization等功能都由此机制实现。
PreReceive
函数中处理此类消息,并调用GetResponse返回结果到发送端。
public interface IAutoResponseable : IPreReceiveable
{
ValueTask<object> GetResponse(object request);
}
线程调度
Remote 使用bool UseThreadSchedule(int rpcID, short cmd, int messageID, object message)
函数决定消息回调函数在哪个线程执行,true时所有消息被汇总到Megumin.ThreadScheduler.Update。
你需要轮询此函数来处理接收回调,它保证了按接收消息顺序触发回调(如果出现乱序,请提交一个BUG)。Unity中通常应该使用FixedUpdate。
如果你的消息在分布式服务器之间传递,你可能希望消息在中转进程中尽快传递,那么
false时接收消息回调使用Task执行,不必在轮询中等待,但无法保证有序,鱼和熊掌不可兼得。
///建立主线程 或指定的任何线程 轮询。(确保在unity中使用主线程轮询)
///ThreadScheduler保证网络底层的各种回调函数切换到主线程执行以保证执行顺序。
ThreadPool.QueueUserWorkItem((A) =>
{
while (true)
{
ThreadScheduler.Update(0);
Thread.Yield();
}
});
Message.dll
(AOT/IL2CPP)当序列化类以dll的形式导入unity时(因为有时会将消息类库设计成unity外的共享工程),必须加入link文件,防止序列化类属性的get,set方法被il2cpp剪裁。重中之重,因为缺失get,set函数不会报错,错误通常会被定位到序列化库的多个不同位置(我在这里花费了16个小时)。
<linker>
<assembly fullname="Message" preserve="all"/>
</linker>
TotalLength(value including total length 4 byte) | RpcID | CMD | MSGID | Body |
---|---|---|---|---|
总长度(值包含总长度自身的4个字节) | 消息ID | 消息正文 | ||
Int32(int) | Int32(int) | Int16(short) | Int32(int) | byte[] |
4byte | 4byte | 2byte | 4byte | byte[].Lenght |
当服务器不使用本库,或者不是C#语言时。满足报头格式,即可支持本库所有特性。
MessagePipeline 是 Megumin.Remote 分离出来的一部分功能。
它也可以理解为一个协议栈。
它决定了消息收发具体经过了哪些步骤,可以自定义MessagePipeline并注入到Remote,用来满足一些特殊需求。
例如:
你可以为每个Remote指定一个MessagePipeline实例,如果没有指定,默认使用MessagePipeline.Default。
2.0 版本删除MessagePipeline,改为多个Remote实现中可重写的函数,在工程实践中发现,将消息管线与Remote拆离没有意义,是过度设计。如果需要同时定制3个协议Remote的管线,可以由用户自行拆分,框架不做处理。
人生就是反反复复。
3.0版本决定改回最开始设计,第一版本的设计思路更好。
经过工程实践发现,2.0的设计并不方便重写,用户相同的重写代码在针对不同的协议时需要重写多份,分别从TcpRemote,UdpRemote,Kcpremote继承,每次修改时也要同时修改多份,十分笨重。
用户主要重写接收消息部分和断线部分,断线重连部分针对不同协议处理方式也不同。
所以将Transport和IDisconnectHandler从Remote拆分出来。
本质上说,3.0的Remote等于1.0的MessagePipeline。3.0的Transport等于1.0的Remote。
MessageLUT(Message Serialize Deserialize callback look-up table)是MessageStandard的核心类。MessagePipeline 通过查找MessageLUT中注册的函数进行序列化。因此在程序最开始你需要进行函数注册
。
通用注册函数:
void RegistIMeguminFormatter<T>(KeyAlreadyHave key = KeyAlreadyHave.Skip) where T : class, IMeguminFormatter, new()
序列化类库的中间件基于MessageLUT提供多个简单易用的API,自动生成序列化和反序列化函数。需要为协议类添加一个MSGIDAttribute来提供查找表使用的ID。因为一个ID只能对应一组序列化函数,因此每一个协议类同时只能使用一个序列化库。
namespace Message
{
[MSGID(1001)] //MSGID 是框架定义的一个特性,注册函数通过反射它取得ID
[ProtoContract] //ProtoContract 是protobuf-net 序列化库的标志
[MessagePackObject] //MessagePackObject 是MessagePack 序列化库的标志
public class Login //同时使用多个序列化类库的特性标记,但程序中每个消息同时只能使用一个序列化库
{
[ProtoMember(1)] //protobuf-net 从 1 开始
[Key(0)] //MessagePack 从 0 开始
public string Account { get; set; }
[ProtoMember(2)]
[Key(1)]
public string Password { get; set; }
}
[MSGID(1002)]
[ProtoContract]
[MessagePackObject]
public class LoginResult
{
[ProtoMember(1)]
[Key(0)]
public bool IsSuccess { get; set; }
}
}
JIT环境下可以直接注册一个程序集
private static async void InitServer()
{
//MessagePackLUT.Regist(typeof(Login).Assembly);
Protobuf_netLUT.Regist(typeof(Login).Assembly);
ThreadPool.QueueUserWorkItem((A) =>
{
while (true)
{
ThreadScheduler.Update(0);
Thread.Yield();
}
});
}
AOT/IL2CPP
环境下需要显示
通过泛型函数注册每一个协议类,以确保在AOT/IL2CPP编译器
在静态分析时生成对应的泛型函数。
public void TestDefine()
{
Protobuf_netLUT.Regist<Login>();
Protobuf_netLUT.Regist<LoginResult>();
}
注意:
序列化库
使用代码生成器生成代码
,是生成类型实际的序列化函数。
而这里是为了静态分析时生成序列化类库通用API的泛型函数。
例如:
ProtoBuf.Serializer.Serialize<T>()
生成为ProtoBuf.Serializer.Serialize<Login>()
两者不相同。
每个库有各自的限制,对IL2CPP支持也不同。框架会为每个支持的库写一个继承于MessageStandard/MessageLUT的新的MessageLUT.
由于各个序列化库对Span<byte>
的支持不同,所以中间层可能会有轻微的性能损失.
对于序列化函数有三种形式:
RPC功能
:保证了请求和返回消息一对一匹配。发送时RPCID为负数,返回时RPCID*-1 为正数,用正负区分上下行。
内存分配
:通过使用内存池
,减少alloc。内存池
:标准库内存池,ArrayPool<byte>.Shared
。序列化
:使用type做Key查找函数。反序列化
:使用MSGID(int)做Key查找函数。MessageLUT.Regist<T>
函数手动添加其他类型。消息类型
:尽量不要使用大的自定义的struct,整个序列化过程有可能
导致多次装箱拆箱。在参数传递过程中还会多次复制,性能比class低。<TargetFrameworks>netstandard2.0;netstandard2.1;net5;net6</TargetFrameworks>
。时间和空间上的折衷
序列化之前无法确定消息大小,因此需要传递一个足够大的buffer到序列化层。如果不进行拷贝,直接将整个大buffer传递到发送层,由于异步特性,无法准确得知发送过程的生命周期,可能在发送层积累大量的大buffer,严重消耗内存,因此类库在序列化层和发送层之间做了一次拷贝。
2.0版本 使用IBufferWriter<byte>
和ReadOnlySequence<byte>
解决了这个问题,效率更高。
这是写类库途中总结到的知识或者猜测:
Megumin.Remote是以MMORPG为目标实现的。对于非MMORPG游戏可能不是最佳选择。 在遥远的未来也许会针对不同游戏类型写出NetRemoteStandard的不同实现。
Where the data stores before we invoke 'socket.read(buffer, offset, count)'?
Doubt regarding Winsock Kernel Buffer and Nagle algorithm