构建 SatelliteRpc:基于 Kestrel 的 RPC 框架(整体设计篇)
背景
之前在.NET 性能优化群内交流时,我们发现很多朋友对于高性能网络框架有需求,需要创建自己的消息服务器、游戏服务器或者物联网网关。但是大多数小伙伴只知道 DotNetty,虽然 DotNetty 是一个非常优秀的网络框架,广泛应用于各种网络服务器中,不过因为各种原因它已经不再有新的特性支持和更新,很多小伙伴都在寻找替代品。
这一切都不用担心,在.NET Core 以后的时代,我们有了更快、更强、更好的 Kestrel 网络框架,正如其名,Kestrel 中文翻译为红隼(hóng sǔn)封面就是红隼的样子,是一种飞行速度极快的猛禽。Kestrel 是 ASPNET Core 成为.NET 平台性能最强 Web 服务框架的原因之一,但是很多人还觉得 Kestrel 只是用于 ASPNET Core 的网络框架,但是其实它是一个高性能的通用网络框架。
我和拥有多个.NET 千星开源项目作者九哥一拍即合,为了让更多的人了解 Kestrel,计划写一系列的文章来介绍它,九哥已经写了一系列的文章来介绍如何使用 Kestrel 来创建网络服务,我觉得他写的已经很深入和详细了,于是没有编写的计划。
不过最近发现还是有很多朋友在群里面问这样的问题,还有群友提到如何使用 Kestrel 来实现一个 RPC 框架,刚好笔者在前面一段时间研究了一下这个,所以这一篇文章也作为 Kestrel 的应用篇写给大家,目前来说想分为几篇文章来发布,大体的脉络如下所示,后续看自己的时间和读者们感兴趣的点再调整内容。
整体设计
Kestrel 服务端实现请求、响应序列化及反序列化单链接多路复用实现性能优化
Client 实现代码生成技术
待定……
项目
本文对应的项目源码已经开源在 Github 上,由于时间仓促,笔者只花了几天时间设计和实现这个 RPC 框架,所以里面肯定有一些设计不合理或者存在 BUG 的地方,还需要大家帮忙查缺补漏。
SatelliteRpc: https://github.com/InCerryGit/SatelliteRpc
如果对您有帮助,欢迎点个 star~再次提醒注意:该项目只作为学习、演示使用,没有经过生产环境的检验。
项目信息
编译环境
要求 .NET 7.0 SDK 版本,Visual Studio 和 Rider 对应版本都可以。
目录结构
演示
安装好 SDK 和下载项目以后,samples
目录是对应的演示项目,这个项目就是通过我们的 RPC 框架调用 Server 端创建的一些服务,先启动 Server 然后再启动 Client 就可以得到如下的运行结果:
设计方案
下面简单的介绍一下总体的设计方案:
传输协议设计
传输协议的主要代码在SatelliteRpc.Protocol
项目中,协议的定义在Protocol
目录下。针对 RPC 的请求和响应创建了两个类,一个是AppRequest
另一个是AppResponse
。
在代码注释中,描述了协议的具体内容,这里简单的介绍一下,请求协议定义如下:
响应协议定义如下:
其中主要的参数和数据在各自请求响应体中,请求体和响应体的序列化类型是通过PayloadConverters
中的序列化器进行序列化和反序列化的。
在响应时使用了请求 Id,这个请求 Id 是 ulong 类型,是一个链接唯一的自增的值,每次请求都会自增,这样就可以保证每次请求的 Id 都是唯一的,这样就可以在客户端和服务端进行匹配,从而找到对应的请求,从而实现多路复用的请求和响应匹配功能。
当 ulong 类型的值超过最大值时,会从 0 开始重新计数,由于 ulong 类型的值是 64 位的,值域非常大,所以在正常的情况下,同一连接下不可能出现请求 Id 重复的情况。
客户端设计
客户端的层次结构如下所示,最底层是传输层的中间件,它由RpcConnection
生成,它用于 TCP 网络连接和最终的发送接受请求,中间件构建器保证了它是整个中间件序列的最后的中间件,然后上层就是用户自定义的中间件。
默认的客户端实现DefaultSatelliteRpcClient
,目前只提供了几个 Invoke 方法,用于不同传参和返参的服务,在这里会执行中间件序列,最后就是具体的LoginClient
实现,这里方法定义和ILoginClient
一致,也和服务端定义一致。
最后就是调用的代码,现在有一个DemoHostedService
的后台服务,会调用一下方法,输出日志信息。
下面是一个层次结构图:
所以整个 RCP Client 的关键实体的转换如下图所示:
多路复用
上文提到,多路复用主要是使用 ulong 类型的 Id 来匹配 Request 和 Response,主要代码在RpcConnection
,它不仅提供了一个最终用于发送请求的方法,在里面声明了一个TaskCompletionSource
的字典,用于存储请求 Id 和TaskCompletionSource
的对应关系,这样就可以在收到响应时,通过请求 Id 找到对应的TaskCompletionSource
,从而完成请求和响应的匹配。
由于请求可能是并发的,所以在RpcConnection
中声明了Channel<AppRequest>
,将并发的请求放入到 Channel 中,然后在RpcConnection
中有一个后台线程,用于从 Channel 单线程的中取出请求,然后发送请求,避免并发调用远程接口时,底层字节流的混乱。
扩展性
客户端不仅仅支持ILoginClient
这一个契约,用户可以自行添加其他契约,只要保障服务端有相同的接口实现即可。也支持增加其它 proto 文件,Protobuf.Tools 会自动生成对应的实体类。
中间件
该项目的扩展性类似 ASP.NET Core 的中间件,可以自行加入中间件处理请求和响应,中间件支持 Delegate 形式,也支持自定义中间件类的形式,如下代码所示:
在客户端中间件中,可以通过CallContext
获取到请求和响应的数据,然后可以对数据进行处理,然后调用next
方法,这样就可以实现中间件的链式调用。
同样也可以进行阻断操作,比如在某个中间件中,直接返回响应,这样就不会继续调用后面的中间件;或者记录请求响应日志,或者进行一些其他的操作,类似于 ASP.NET Core 中间件都可以实现。
序列化
序列化的扩展性主要是通过PayloadConverters
来实现的,内部实现了抽象了一个接口IPayloadConverter
,只要实现对应 PayloadType 的序列化和反序列化方法即可,然后注册到 DI 容器中,便可以使用。
由于时间关系,只列出了 Protobuf 和 Json 两种序列化器,实际上可以支持用户自定义序列化器,只需要在请求响应协议中添加标识,然后由用户注入到 DI 容器即可。
其它
其它一些类的实现基本都是通过接口和依赖注入的方式实现,用户可以很方便的进行扩展,在 DI 容器中替换默认实现即可。如:IRpcClientMiddlewareBuilder
、IRpcConnection
、ISatelliteRpcClient
等。
另外也可以自行添加其他的服务,因为代码生成器会自动扫描接口,然后生成对应的调用代码,所以只需要在接口上添加SatelliteRpcAttribute
,声明好方法契约,就能实现。
服务端设计
服务端的设计总体和客户端设计差不多,中间唯一有一点区别的地方就是服务端的中间件有两种:
一种是针对连接层的
RpcConnectionApplicationHandler
中间件,设计它的目的主要是为了灵活处理链接请求,由于可以直接访问原始数据,还没有做路由和参数绑定,后续可观测性指标和一些性能优化在这里做会比较方便。比如为了应对 RPC 调用,定义了一个名为RpcServiceHandler
的RpcConnectionApplicationHandler
中间件,放在整个连接层中间件的最后,这样可以保证最后执行的是 RPC Service 层的逻辑。另外一种是针对业务逻辑层的
RpcServiceMiddleware
,这里就是类似 ASP.NET Core 的中间件,此时上下文中已经有了路由信息和参数绑定,可以在这做一些 AOP 编程,也能直接调用对应的服务方法。在 RPC 层,我们需要完成路由,参数绑定,执行目标方法等功能,这里就是定义了一个名为EndpointInvokeMiddleware
的中间件,放在整个 RPC Service 层中间件的最后,这样可以保证最后执行的是 RPC Service 层的逻辑。
下面是一个层次结构图:
整个 RPC Server 的关键实体的转换如下图所示:
多路复用
服务端对于多路复用的支持就简单的很多,这里是在读取到一个完整的请求以后,直接使用 Task.Run 执行后续的逻辑,所以能做到同一链接多个请求并发执行,对于响应为了避免混乱,使用了Channel<HttpRawContext>
,将响应放入到 Channel 中,然后在后台线程中单线程的从 Channel 中取出响应,然后返回响应。
终结点
在服务端中有一个终结点的概念,这个概念和 ASP.NET Core 中的概念类似,它具体的实现类是RpcServiceEndpoint
;在程序开始启动以后;便会扫描入口程序集(当然这块可以优化),然后找到所有的RpcServiceEndpoint
,然后注册到 DI 容器中,然后由RpcServiceEndpointDataSource
统一管理,最后在进行路由时有IEndpointResolver
根据路径进行路由,这只提供了默认实现,用户也可以自定义实现,只需要实现IEndpointResolver
接口,然后替换 DI 容器中的默认实现即可。
扩展性
服务端的扩展性也是在中间件、序列化、其它接口上,可以通过 DI 容器很方便的替换默认实现,增加 AOP 切面等功能,也可以直接添加新的 Service 服务,因为会默认去扫描入口程序集中的RpcServiceEndpoint
,然后注册到 DI 容器中。
优化
现阶段做的性能优化主要是以下几个方面:
Pipelines 在客户端的请求和服务端处理(Kestrel 底层使用)中都使用了 Pipelines,这样不仅可以降低编程的复杂性,而且由于直接读写 Buffer,可以减少内存拷贝,提高性能。
表达式树在动态调用目标服务的方法时,使用了表达式树,这样可以减少反射的性能损耗,在实际场景中可以设置一个快慢阈值,当方法调用次数超过阈值时,就可以使用表达式树来调用方法,这样可以提高性能。
代码生成在客户端中,使用了代码生成技术,这个可以让用户使用起来更加简单,无需理解 RPC 的底层实现,只需要定义好接口,然后使用代码生成器生成对应的调用代码即可;另外实现了客户端自动注入,避免运行时反射注入的性能损耗。
内存复用对于 RPC 框架来说,最大的内存开销基本就在请求和响应体上,创建了 PooledArray 和 PooledList,两个池化的底层都是使用的 ArrayPool,请求和响应的 Payload 都是使用的池化的空间。
减少内存拷贝 RPC 框架消耗 CPU 的地方是内存拷贝,上文提到了客户端和服务端均使用 Pipelines,在读取响应和请求的时候直接使用
ReadOnlySequence<byte>
读取网络层数据,避免拷贝。客户端请求和服务端响应创建了 PayloadWriter 类,通过IBufferWriter<byte>
直接将序列化的结果写入网络 Buffer 中,减少内存拷贝,虽然会引入闭包开销,但是相对于内存拷贝来说,几乎可以忽略。对于这个优化实际应该设置一个阈值,当序列化的数据超过阈值时,才使用 PayloadWriter,否则使用内存拷贝的方式,需要 Benchmark 测试支撑阈值设置。
其它更多的性能优化需要 Benchmark 的数据支持,由于时间比较紧,没有做更多的优化。
待办
计划做,但是没有时间去实现的:
服务端代码生成现阶段服务端的路由是通过字典匹配实现,方法调用使用的表达式树,实际上这一块可以使用代码生成来实现,这样可以提高性能。另外一个地方就是 Endpoint 注册是通过反射扫描入口程序集实现的,实际上这一步可以放在编译阶段处理,在编译时就可以读取到所有的服务,然后生成代码,这样可以减少运行时的反射。
客户端取消请求目前客户端的请求取消只是在客户端本身,取消并不会传递到服务端,这一块可以通过协议来实现,在请求协议中添加一个标识,传递 Cancel 请求,然后在服务端进行判断,如果是取消请求,则服务端也根据 ID 取消对应的请求。
Context 和 AppRequest\AppResponse 池化目前的 Context 和 AppRequest\AppResponse 都是每次请求都会创建,对于这些小对象可以使用池化的方式来实现复用,其中 AppRequest、AppResponse 已经实现了复用的功能,但是没有时间去实现池化,Context 也可以实现池化,但是目前没有实现。
堆外内存、FOH 管理目前的内存管理都是使用的堆内存,对于那些有明显作用域的对象和缓存空间可以使用堆外内存或 FOH 来实现,这样可以减少 GC 在扫描时的压力。
AsyncTask 的内存优化目前是有一些地方使用的 ValueTask,对于这些地方也是内存分配的优化方向,可以使用
PoolingAsyncValueTaskMethodBuilder
来池化 ValueTask,这样可以减少内存分配。TaskCompletionSource 也是可以优化的,后续可以使用AwaitableCompletionSource
来降低分配。客户端连接池化目前客户端的连接还是单链接,实际上可以使用连接池来实现,这样可以减少 TCP 链接的创建和销毁,提高性能。
异常场景处理目前对于服务端和客户端来说,没有详细的测试,针对 TCP 链接断开,数据包错误,服务器异常等场景的重试,熔断等策略都没有实现。
文章转载自:InCerry
原文链接:https://www.cnblogs.com/InCerry/p/18033494/satelliterpc-1
评论