Session
请大家关注我的微博:@NormanLin_BadPixel坏像素
3.0已经更新了。大家可以在学习完这篇之后去看Session3.0的差异
大致浏览了一下代码,我决定还是先去学习一下.Net异步编程和网络RPC模式是啥。
稍微学习了一下后,我们大致对RPC模式和异步编程有了一定了解。
private static uint RpcId { get; set; }
这个就是RPC模式中消息传递时候的CallID吗?用来标识调用远端的哪个方法?如果让我写我可能会这样,但是,大佬肯定想的比我更多,而且,我们大概能猜到用来标识调用的应该是我们之前接触到的Opcode,所以,对这个RPCID我们还不能太早下定论。而且,它是私有的,很可能是对自身的一些数据进行标识的。
private readonly NetworkComponent network;
创建这个Session的网络组件,应该是在创建这个Session的时候赋值的。方便这个Session对上级进行访问。就像我们Unity里面,gameObject.transform跟transform.gameObject一样方便。不过这个是只能自己访问的,毕竟私有只读的属性在那嘛。
private readonly Dictionary<uint, Action<object>> requestCallback = new Dictionary<uint, Action<object>>();
这个应该是接收完数据后的回调函数。变量名说明一切。
private readonly AChannel channel;
private readonly List<byte[]> byteses = new List<byte[]>() {new byte[0], new byte[0]};
channel我们之前讲过了,应该是专属于这个Session的对话通道。而byteses应该就是通道中传输的数据了。
public Session(NetworkComponent network, AChannel channel)
{
this.network = network;
this.channel = channel;
this.StartRecv();
}
构造函数,最后调用了StartRecv方法,可以预见这是启动数据传输的方法。
private async void StartRecv()
{
while (true)
{
if (this.Id == 0)
{
return;
}
byte[] messageBytes;
try
{
messageBytes = await channel.Recv();
if (this.Id == 0)
{
return;
}
}
catch (Exception e)
{
Log.Error(e.ToString());
continue;
}
if (messageBytes.Length < 3)
{
continue;
}
ushort opcode = BitConverter.ToUInt16(messageBytes, 0);
try
{
this.Run(opcode, messageBytes);
}
catch (Exception e)
{
Log.Error(e.ToString());
}
}
}
这段代码前面很好理解,就是在异步接收对话通道里传递的消息。大家需要理解的是if (messageBytes.Length < 3) 之后的代码。
我们先来看
ushort opcode = BitConverter.ToUInt16(messageBytes, 0);
BitConverter.ToUInt16 这个方法是将字节数组指定位置起的两个字节转换为无符号整数。所以我们得先保证messageBytes的长度是大于等于3的。
而且,我们也不难猜出这里消息传递的结构:2个字节的操作类型+具体消息。
我们继续看Run 方法。
private void Run(ushort opcode, byte[] messageBytes)
{
int offset = 0;
// opcode最高位表示是否压缩
bool isCompressed = (opcode & 0x8000) > 0;
if (isCompressed) // 最高位为1,表示有压缩,需要解压缩
{
messageBytes = ZipHelper.Decompress(messageBytes, 2, messageBytes.Length - 2);
offset = 0;
}
else
{
offset = 2;
}
opcode &= 0x7fff;
this.RunDecompressedBytes(opcode, messageBytes, offset);
}
呵呵,可能大家跟我一样,看到0x8000,0x7fff头都大了,不过,如果大家没记错的话,opcode是ushort类型的数据,而ushort类型的数据是占2个字节的,表示无符号整型,其范围为[0,65535]。而short型表示的范围是[-32768,32767]。而0x8000是16进制表示的32767,有没有发现,刚好是short型的边缘。而它的二进制表示是1000 0000 0000 0000用一个ushort类型的去跟0x8000进行逻辑与运算,便能很方便的判断最高位是否为1。为什么要这么判断呢?首先有的时候按位搜索或是按位运算都会相对快一些,最重要的是这样写装逼。不过这里作者很明显赋予了这个ushort以特别的使命,用来判断数据是否进行过压缩。这里大家要保持有疑问:
1. 什么样的数据需要压缩?
2. 数据是怎样进行压缩的?
3. 数据是在什么时候进行压缩的?
4. 又是在什么时候解压缩的?
很方便就能知道,数据的解压缩是在此时进行的。第1跟第3个问题我们等看到的时候再聊,现在我们能解答的是第2个问题,数据是怎样进行压缩的?
ZipHelper
public static byte[] Compress(byte[] content)
{
//return content;
Deflater compressor = new Deflater();
compressor.SetLevel(Deflater.BEST_COMPRESSION);
compressor.SetInput(content);
compressor.Finish();
using (MemoryStream bos = new MemoryStream(content.Length))
{
var buf = new byte[1024];
while (!compressor.IsFinished)
{
int n = compressor.Deflate(buf);
bos.Write(buf, 0, n);
}
return bos.ToArray();
}
}
Deflater这个类是在ICSharpCode.SharpZipLib.Zip.Compression,
Git/icsharpcode/SharpZipLib这是一个开源的项目,有兴趣的大家可以自己去看看。其实我们只要知道,这个家伙能帮我们进行数据的压缩,减少我们网络传输的带宽就可以了。
而从这段我们可以看出,这个Demo中数据是以1KB为单位进行压缩的,为什么这么压缩呢,不知道。如果有大佬知道的话请解惑。而后面的解压缩我就不讲了。
Session
messageBytes = ZipHelper.Decompress(messageBytes, 2, messageBytes.Length - 2);
我们从这里可以看出,网络传输中的数据只有具体的数据被压缩了,而前面两个字节的操作符(Opcode)并不会被压缩。而offset这个变量则是表示整段数据的偏差量,如果是操作压缩数据,我们经过解压缩后获得的是不含操作符的具体数据,所以是没有偏差量的。而如果操作的是没有压缩的数据,则数据是包含前两个字节的操作符的,所以要记录这个偏差值,为2。
opcode &= 0x7fff;
这段代码,在我看来,应该就是把最高位变为0。于是我猜测,作者应该是对每一段数据这样处理的:首先获取具体数据data1,然后获取数据的操作符opcode1,然后,判断data1是否需要进行压缩,如果需要进行压缩,则把opcode1的最高位设为1(因为ushort本身最高位不会为1,这也是我猜的),变成opcode2。如果不需要进行压缩,则opcode不变,opcode2 = opcode1,然后对data1进行数据压缩变为data2,最后,opcode2 + data2就是最后得到的传输数据。
所以,这段代码其实可以理解为把opcode2转换为opcode1。(如果我猜的没错的话嘿嘿)
接下来我们看看RunDecompressedBytes
private void RunDecompressedBytes(ushort opcode, byte[] messageBytes, int offset)
{
Type messageType = this.network.Entity.GetComponent<OpcodeTypeComponent>().GetType(opcode);
object message = this.network.MessagePacker.DeserializeFrom(messageType, messageBytes, offset, messageBytes.Length - offset);
//Log.Debug($"recv: {MongoHelper.ToJson(message)}");
AResponse response = message as AResponse;
if (response != null)
{
// rpcFlag>0 表示这是一个rpc响应消息
// Rpc回调有找不着的可能,因为client可能取消Rpc调用
Action<object> action;
if (!this.requestCallback.TryGetValue(response.RpcId, out action))
{
return;
}
this.requestCallback.Remove(response.RpcId);
action(message);
return;
}
this.network.MessageDispatcher.Dispatch(this, opcode, offset, messageBytes, (AMessage)message);
}
根据我们之前学过的OpcodeTypeComponent,我们可以通过一个opcode(ushort)来获取到对应的操作类。
我们来瞅瞅this.network.MessagePacker是什么东西。
public interface IMessagePacker
{
byte[] SerializeToByteArray(object obj);
string SerializeToText(object obj);
object DeserializeFrom(Type type, byte[] bytes);
object DeserializeFrom(Type type, byte[] bytes, int index, int count);
T DeserializeFrom<T>(byte[] bytes);
T DeserializeFrom<T>(byte[] bytes, int index, int count);
T DeserializeFrom<T>(string str);
object DeserializeFrom(Type type, string str);
}
啊哈,终于给我们找着了,不知道大家还记不记得,我们之前在学习MessageDispatherComponent的时候说的
这个时候,消息调度器的第一个作用就出来了,它会把这些信息给统一打包,然后通过自定义的数据类型,把这些信息序列化成json或者protobuff或其他的一些数据,方便网络传输。
我本来以为这一步是会在MessageDispather里面实现的,不过作者把这一步放在了MessagePacker里面,不过这样职责更单一,更优雅。
我们知道,我们发送的数据其实是从一个个对象变化而来的,比如我传输一个移动的操作,那么我们就可能需要传输一个Vecter2或Vecter3的数据或者其他我们自定义的数据类型。但是,在网络的传输过程当中,我们是通过字节流进行传输的,所以,我们需要把这些对象序列化。而序列化的方法有很多种,比如json,比如protobuf等。而这个接口就是让我们自定义序列化和反序列化方式的地方了。不管我们选用了哪种方式,只要实现这些接口就好。
所以,我们便是从这段代码获取到反序列化后的消息对象。最后进行一次强转即可。一般都是自定义的数据类型。
object message = this.network.MessagePacker.DeserializeFrom(messageType, messageBytes, offset, messageBytes.Length - offset);
这里需要注意的是,作者在这里给网络传输的信息分了两个类型,一种是AResponse,一种是AMessage。这里作者很好心的给了注释,不然我独自理解得花好多时间。AResponse是服务端回的RPC消息,而AMessage应该是普通的消息。对于从服务端传回的RPC消息,因为这个消息是回应从客户端传出去的RPC请求的,所以在客户端发送请求的时候肯定会有回调函数的,所以,收到传回的消息后就可以直接调用回调函数了,而从代码中,我们就可以看出requestCallback这里就是存放回调函数的,这在我们开头的时候也说了。
如果是普通的消息,则是通过消息调度器对消息进行调度。
上面的是接收消息与处理的内容,接下来我们看一下发送消息的。
我们先来看一下SendMessage
private void SendMessage(object message)
{
//Log.Debug($"send: {MongoHelper.ToJson(message)}");
//获得到消息的操作符
ushort opcode = this.network.Entity.GetComponent<OpcodeTypeComponent>().GetOpcode(message.GetType());
//讲消息从对象序列化成字节数组
byte[] messageBytes = this.network.MessagePacker.SerializeToByteArray(message);
//判断并对数据进行压缩
if (messageBytes.Length > 100)
{
byte[] newMessageBytes = ZipHelper.Compress(messageBytes);
if (newMessageBytes.Length < messageBytes.Length)
{
messageBytes = newMessageBytes;
//如果需要压缩,则把操作符的最高位设为1
opcode |= 0x8000;
}
}
//将ushort类型的opcode转成字节数组
byte[] opcodeBytes = BitConverter.GetBytes(opcode);
this.byteses[0] = opcodeBytes;
this.byteses[1] = messageBytes;
channel.Send(this.byteses);
}
看到这里,大家会惊喜的发现,这不就是我们之前[对发送数据处理方式的猜测](#caice
)吗?(我会对代码进行注释)
最后,我们通过消息通道传输的数据是List
/// <summary>
/// Rpc调用
/// </summary>
public Task<Response> Call<Response>(ARequest request, CancellationToken cancellationToken)
where Response : AResponse
{
request.RpcId = ++RpcId;
this.SendMessage(request);
var tcs = new TaskCompletionSource<Response>();
this.requestCallback[RpcId] = (message) =>
{
try
{
Response response = (Response)message;
if (response.Error > 100)
{
tcs.SetException(new RpcException(response.Error, response.Message));
return;
}
//Log.Debug($"recv: {MongoHelper.ToJson(response)}");
tcs.SetResult(response);
}
catch (Exception e)
{
tcs.SetException(new Exception($"Rpc Error: {typeof(Response).FullName}", e));
}
};
cancellationToken.Register(() => { this.requestCallback.Remove(RpcId); });
return tcs.Task;
}
看到这段代码里对RpcId的调用,大家大概也能猜到this.RpcId的作用了。没错,就是用来标识由这个Session发送出去的Rpc请求的。也就是说,每个Session都管理着一套自己的Id标识,这个Rpc只会在自己发送请求的时候进行自增,也只会在接收到返回消息的时候判断。而每个Session独立的AChannel通信通道也保证了自己发送的Rpc请求的返回只会被自己接收到而不会被其他Session误收。
赋予了RpcId之后,我们通过SendMessage把消息发送出去了。但是,大家注意了,我们这个方法的返回值是一个Task,我们必定会在接收到消息的回复后把回复内容返回,接下来便是这部分的代码。
这里我们又遇到了TaskCompletionSource这个在我们之前对TimerComponent的学习时候就遇到过的东西,那个时候我对它的描述不是很多,这里给大家详细说一下。
当我们实例化一个TaskCompletionSource对象tcs的时候,它会帮我们创建一个Task,也就是tcs.Task。我们可以对这个tcs进行一些操作,这些操作会相应的对tcs.Task造成影响,比如我们最常用的tcs.SetResult(TResult) 便能把tcs的基础 Task 转换为 RanToCompletion 状态。 并把TResult作为任务的返回结果。
而这段代码同时起到了对异常的捕获。并且,把接收到回复信息的回调函数根据RpcId存进了requestCallback字典里面,供之后调用。
具体在哪里调用,应该是在请求失败的时候。我们在后面的ActorProxy学习笔记内会解释。(2018/4/16写)
并且,注册一个将在取消此 CancellationToken 时调用的委托,根据RpcId移除相应的回调函数。
而后面还有一个Call
/// <summary>
/// Rpc调用,发送一个消息,等待返回一个消息
/// </summary>
public Task<Response> Call<Response>(ARequest request) where Response : AResponse
跟之前那个相比,只是少了CancellationToken。也就是说,如果用第一种方法,带CancellationToken参数的,那么它可以在对方没有回复之前通过CancellationToken来取消掉这次Call,而用第二种方法,则一定会等待对方答复,就算得到的回复是异常。
而后面的Send跟Reply则都是容易理解的。
细心的同学可能已经发现,这里很多的共用方法都没有被其它地方引用,是他们没用吗?那作者为什么要写他们?这里我突然想到了ILRuntime,之前因为这个跟热更新有关所以一直没有提,(毕竟刚开是还不想接触热更,那又是一个很大的课题)但是既然这个Demo用到了热更,那肯定有很多逻辑是写在ILRuntime里面的,而如果我猜的没错的话,ILRuntime跟Lua一样,是不被VS编辑器编译的,我们也找不到其中对其他脚本公有方法的调用,这样我们之前累积的很多问题,如果看不到具体在哪里调用的话,我们会很难理解其中的作用。所以,我想还是得去学习一下ILRuntime。不过,我遵循的是,不到万不得已的时候,我们还是继续我们现阶段的学习吧。
结束语
我们要学习的东西还很多 ————– Norman林