这是一个 简单易用的 网络库。
这是一个网络模块的通用解决方案。设计目的为应用程序网络模块提供统一的HighLevel接口。
整个类库被拆分为多个dll。简单来说:NetRemoteStandard.dll是标准,里面只有接口定义;Megumin.Remote.dll是一种实现。类比于dotnetStandard和dotnetCore的关系。
是的,使用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
.
- 支持Tcp,Udp,Kcp。
- 使用内存池和多线程处理收发,可配置线程调度,无需担心网络模块性能问题。
- 内置Rpc。
- 可以搭配不同的序列化类库,甚至不用序列化库。
- AOT/IL2CPP可用。
- 可重写的消息管线,专业程序员可以针对具体功能进一步优化。
- 接口分离。[Dependency injection] 应用程序可以仅使用NetRemoteStandard.dll编码,然后使用Megumin.Remote.dll的具体实现类注入,当需要切换协议或者序列化类库时,应用程序逻辑无需改动。
- IOCP开销和消息调度转发延迟之间有很好的平衡。
- 自定义MiniTask池,针对网络功能对Task重新实现,性能更高,仅初始化时alloc。
- 支持
Span<T>
。使用System.IO.Pipelines
作为高性能IO缓冲区。 - 纯C#实现,这是学习网络功能一个好的起点。
- 2.0 版本 API设计经过真实业务需求改良。
MIT许可证
目前为止类库还很年青,没有经过足够的商业项目测试。- 不持支 WebGL Networking
- 对于非程序人员仍然需要一些学习成本。独立游戏作者用起来还是有一定难度的。
- 类库没有解决操作系统的时间精度问题。这个问题非常复杂,需要专人定制。
设计原则:最常用的代码最简化,复杂的地方都封装起来。
发送一个消息,并等待一个消息返回
是类库的全部内容。
从结果值返回异常是有意义的:1.省去了try catch ,写法更简单(注意,没有提高处理异常的性能)2.用来支持异常在分布式服务器中传递,避免try catch 控制流。
///实际使用中的例子
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)
{
}
}
接收端回调函数
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;
}
注意: 异步发送方法等待的返回值虽然也是接收到的消息,但是会被分发到异步函数回调中,不会触发本函数。即使异步发送方法没有使用await关键字而导致异步后续没有注册,返回消息也不会触发本函数,返回消息将被忽略。 (事实上,很难实现找不到异步后续时将消息分发到此函数中。因为不持有返回的Task引用时,想要将消息转送到本回调函数,需要对Task增加额外的标记,生命周期难以控制,控制流会变得更难以理解。详细情况参阅源码RpcCallbackPool.CreateCheckTimeout)
具体响应方式参考PreReceive
函数源码,参考IPreReceiveable,ICmdOption,SendOption.Echo等。
Heartbeat,RTT,Timestamp Synchronization等功能都由此机制实现。
- 在某些时候,比如测试消息是否正常收发,发送端可能希望远端做出特定方式的响应,比如echo,将消息原样返回。
这种需求不是针对某一个特定消息类型的,也不是对于某个消息类型永远做出这样的响应,可能仅仅是针对某个时刻的某条消息。
对于这样的需求,在OnReceive函数中实现并不合适,没有办法根据消息类型进行抽象。- 通过CmdID == 1 << 0来实现。发送时指定option参数,option实现ICmdOption,将CmdID传递到底层报头中。
- 通过IPreReceiveable.PreReceiveType == 1来实现,发送的消息协议实现IPreReceiveable接口,并且PreReceiveType的值等于1。
- 接收端在PreReceive函数中处理,并决定此消息是否继续传递到OnReceive函数中。
- 在另一些时候,更通用的是,发送端发出一个消息,但是处于一些特殊原因,不希望将响应函数写在OnReceive函数中。
可以通过消息协议继承IAutoResponseable接口实现,并且PreReceiveType == 2。
接收端PreReceive
函数中处理此类消息,并调用GetResponse返回结果到发送端。public interface IAutoResponseable : IPreReceiveable { ValueTask<object> GetResponse(object request); }
- 比如消息协议是跨项目的,但是OnReceive函数不是。
- 比如一些简单并且通用的基础函数调用,不想污染OnReceive函数。GetTime,GetSystemInfo等。但又不想将这些功能内置到网络模块中。
-
线程调度
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>
- Udp,Kcp 不用处理粘包,所以报头不含有TotalLength,TotalLength改为1字节的消息种类识别码,具体参照源码。
- 使用小端字节序写入报头。BinaryPrimitives.WriteInt32LittleEndian。
- TotalLength = 4 + 4 + 2 + 4 + bodyLength。
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,用来满足一些特殊需求。
例如:
消息反序列化前转发。使用返回消息池来实现接收过程构造返回消息实例无Alloc(这需要序列化类库的支持和明确的生命周期管理)。
你可以为每个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 RegistIMeguminFormater<T>(KeyAlreadyHave key = KeyAlreadyHave.Skip) where T : class, IMeguminFormater, 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>
的支持不同,所以中间层可能会有轻微的性能损失.
对于序列化函数有三种形式:
- 代码生成器生成代码
{ protobuf ,MessagePack mpc.exe } - 通过反射每个字段组合
{ protobuf-net .NET Standard 1.0 } - JIT 生成
{ protobuf-net , MessagePack }
- IL2CPP 请使用.NET Standard 1.0,其他运行时可能无法构建。虽然是反射模式,但是对于客户端来说并没有性能问题,于此同时服务器可以使用 .NET Standard 2.0。
unity无头模式服务器应该考虑其他库。
RPC功能
:保证了请求和返回消息一对一匹配。发送时RPCID为负数,返回时RPCID*-1 为正数,用正负区分上下行。- 0和int.minValue为无效RPCID值。
- 0是普通消息。int.minValue是广播消息。
内存分配
:通过使用内存池
,减少alloc。发送过程数据拷贝了2次,接收过程数据无拷贝(各个序列化类库不同)。2.0版本中做了调整。内存池
:标准库内存池,ArrayPool<byte>.Shared
。序列化
:使用type做Key查找函数。反序列化
:使用MSGID(int)做Key查找函数。- 内置了string,int,long,float,double,DateTimeOffset等类型序列化支持,即使不使用序列化类库,也可以直接发送它们。
MessageLUT.Regist<T>
函数手动添加其他类型。
如果不想用序列化库,也可以使用Json通过string发送。消息类型
:尽量不要使用大的自定义的struct,整个序列化过程有可能
导致多次装箱拆箱。在参数传递过程中还会多次复制,性能比class低。- 多目标框架支持
<TargetFrameworks>netstandard2.0;netstandard2.1;net5;net6</TargetFrameworks>
。
- Remote:负责序列化和Rpc,消息接收。
- Transport:负责传输层数据收发。
- IDisconnectHandler: 负责断线处理。
序列化之前无法确定消息大小,因此需要传递一个足够大的buffer到序列化层。如果不进行拷贝,直接将整个大buffer传递到发送层,由于异步特性,无法准确得知发送过程的生命周期,可能在发送层积累大量的大buffer,严重消耗内存,因此类库在序列化层和发送层之间做了一次拷贝。
2.0版本 使用IBufferWriter<byte>
和ReadOnlySequence<byte>
解决了这个问题,效率更高。
- 0.2版本。没有精确测试,Task的使用确实影响了一部分性能,但是是值得的。经过简单本机测试单进程维持了15000+ Tcp连接。
- 2.0版本性能可能会比之前版本低一些,还没有实际测试。
- 3.0版本性能得到极大优化,超过历史版本。性能上可以满足99%的项目。
本机测试Tcp峰值达到15000+连接,收发26000 0000 字节每秒,网络模块性能已经不在是问题。
Kcp只能3000~5000连接,再多就会爆内存,具体要能维持多少连接,要看数据流量。
这是写类库途中总结到的知识或者猜测:
- public virtual MethodInfo MakeGenericMethod(params Type[] typeArguments);
在IL2CPP下可用,但是不能创造新方法。如果这个泛型方法在编译期间确定,那么此方法可用。否则找不到方法。 - IL2CPP不能使用dynamic关键字。
- 异步编程两个核心关键字理解:
- await == UnsafeOnCompleted == 将下文代码包装成回调函数注册到Task中。
记住 await 时才调用 UnsafeOnCompleted即可。 - async == AsyncTaskMethodBuilder.Create().Task,并在方法末尾SetResult。
async是隐藏的生成一个Task/ValueTask。
- await == UnsafeOnCompleted == 将下文代码包装成回调函数注册到Task中。
Megumin.Remote是以MMORPG为目标实现的。对于非MMORPG游戏可能不是最佳选择。 在遥远的未来也许会针对不同游戏类型写出NetRemoteStandard的不同实现。
- 同时监听IPV4,IPV6。
- 同时监听Tcp,Udp。
- 同时监听多个端口,用于实现负载均衡。
- Megumin.Explosion Megumin系列类库的最底层基础库,Megumin的其他库都有可能需要引用它。
- Megumin.GameFramework Megumin系列的业务逻辑类库。
- Kcp 类库所依赖的Kcp实现。
- 介绍 KCP:新的低延迟、安全网络堆栈 SpatialOS 的官网有几个协议的详细评测
- MessagePack for C# v2, new era of .NET Core(Unity) I/O Pipelines 优化中的一些技巧和问题.
- IOCP(I/O Completion Ports)
- IOCP threads - Clarification?
- Understanding Worker Thread And I/O Completion Port (IOCP)
- clumsy 能在 Windows 平台下人工造成不稳定的网络状况,方便你调试应用程序在极端网络状况下的表现。