背景
Dubbo-go生态包括Dubbo-gov3.0、v1.5、pixiu等子项目,在可扩展性上提供了灵活的定制化方式。
众所周知,HSF是阿里集团RPC/服务治理领域的标杆框架。HSF-go是go语言实现的HSF框架,由中间件团队维护,由于Go语言的特性,在跨语言调用场景,云原生组件集成服务代理场景扮演重要角色,目前拥有DaprBinding实现,并且在函数计算(FC)场景,跨云场景,脱云独立部署场景产生价值,并在钉钉、Lazada、高德等技术团队拥有落地场景。HSF-go属于Dubbo-go生态体系内的一环,是开源项目Dubbo-go的定制化实现。
纵观HSF-go的一系列和服务代理相关的场景,我希望在这里分享一下其作为服务代理的实践与原理,欢迎和大家一起交流。
HSF-go泛化调用模型
1、泛化调用
首先了解一下Dubbo的泛化调用,就是不依赖二方包的情况下,通过传入方法名,方法签名和参数值,就可以调用到下游服务。
而Golang的泛化调用和Java角度略有不同,这与语言特性有关。Go不支持类继承和方法重载,并且没有二方包的概念。Java的二方包可以抽象为一套由客户端和服务端约定好的接口信息,包含接口名、方法名、参数列表、具体参数定义,这些基础概念在任何RPC场景都是必须的,只是表现形式不同:对Java来说就是二方包,对gRPC来说就是proto文件以及编译产物,对兼容Dubbo协议的Dubbo-go来说,就是使用兼容Java版本的Hessian序列化接口。当然使用Go编写Hessian接口这种适配方式带来了一些困扰,就是让Go开发者写起来比较头疼的,对应Java版本的POJO结构和接口存根。
下面是Dubbo-go生态习惯写法中,一个使用Hessian序列化,兼容Java的Go客户端例子。
//UserProvider客户端存根类typeUserProviderstruct{//dubbo标签,用于适配go侧客户端大写方法名-java侧小写方法名,只有dubbo协议客户端才需要使用GetUserfunc(ctxcontext.Context,qint32)(*User,error)`dubbo:"getUser"`}funcinit(){//注册客户端存根类到框架,实例化客户端接口指针userProviderconfig.SetConsumerService(userProvider)}//字段需要与Java侧对应,首字母大写typeUserstruct{UserIDstringUserFullNamestring`hessian:"user_full_name"`UserAgeint32//defaultconvertto"userAge"Timetime.Time}func(u*User)JavaClassName()string{turn"org.apache.dubbo.User"//需要与Java侧User类名对应}
Go相比于支持方法重载的Java,对接口的元数据信息依赖较弱,可以更轻松地定位目的方法从而发起调用。但本质上,还是需要上面所提到的“约定好”的接口信息,从而保证能正确命中下游方法,以及保证参数解析正确。
在泛化调用的情景下,在代码上不需要引入“二方包”,在增大了自由度的同时,失去了“二方包”接口的限制,因此客户端需要在泛化调用传递参数时尽可能小心,保证传递的参数完全和服务端提供的接口对应,从而正确调用。
泛化调用包含服务端泛化和客户端泛化调用。如果客户端泛化是把中间代理当做consumer端的反向代理,那么服务端泛化就是把中间代理当做服务provider端的正向代理,把请求转发到后端真正的服务提供方。服务端泛化,开发者在编写服务时,不需要声明具体的参数,框架将请求解析成通用的方法名和参数列表数组并传递至用户层,开发者编写的代码需要直接操作这些动态的数据,可参考文末的例子。而用的相对较多的是客户端泛化,即上面聊的,客户端在代码层面并没有拿到服务端提供的接口依赖,而是通过传入方法名和参数,由框架生成泛化调用请求,从而达到和通过真实接口调用一样的效果。
泛化调用请求往往方法名为$invoke,包含三个参数,分别是:
真实方法名;参数名组成的数组;参数具体值组成的数组。
以一个HSF-go泛化调用请求为例:
//一个HSF-go的客户端泛化调用genericService.Invoke(context.TODO(),"getUser",[]string{(GoUser{}).JavaClassName(),(GoUser{}).JavaClassName()},[]interface{}{GoUser{Name:"launce"},GoUser{Age:22}})
框架接收到这三个参数后,会构造出泛化请求,发送至服务端。
服务端在接收到泛化请求时,会在一层filter中过滤出以$invoke为方法名的请求,并构造出真实请求结构,向上层传递,从而完成调用并返回。
以上是Dubbo体系泛化调用的通用实现,但如果单纯站在Go语言的角度来设计,并不需要传递参数列表类型,服务端可以单纯通过方法名定位到方法,再将参数数组反序列化,获得真实参数。
2、泛化调用与服务运维能力
泛化调用的应用场景很广泛,集团的开发人员接触最多的泛化调用,可能就是MSE/HSF-ops平台提供的服务测试能力。
集团内使用的MSE运维平台是一个强大的、用于HSF服务治理的平台,可以在平台上配置运维、服务治理能力、进行服务测试,以及商业化版本MSE的压测、流量回放等操作。而其提供的服务测试能力,依赖的就是HSF泛化调用。当开发人员在平台上针对一个接口方法发起测试时,会传入一个json参数列表,平台会将json参数列表转化为hessian对象并序列化,构造出上面提到的三参数,并向目的机器发起调用,拿到测试返回值。HSF服务会默认支持泛化调用。
除了服务测试,还可以使用泛化调用来开发服务网关、服务探活、cli服务测试工具等。
3、泛化调用与序列化协议的关系
常见的序列化协议很多,例如Dubbo/HSF默认的hessian2序列化;还有使用广泛的JSON序列化;以及gRPC原生支持的protobuf(PB)序列化等等。
提到的这三种典型的序列化方案作用类似,但在实现和开发中略有不同。PB不可由序列化后的字节流直接生成内存对象,而Hessian和JSON都是可以的。后两者反序列化的过程不依赖“二方包”,也可以说是存根。一个更好理解的方法是,PB可以理解为一种类似于对称加密协议,在客户端和服务端必须有存根的情况下,才能解析出对象,而hessian和json不依赖存根,这决定了pb的压缩效果更好。
这也可以解释为什么,使用PB序列化的Triple(Dubbo3)协议并没有被我们常用的服务运维平台的测试功能所支持。因为上述泛化调用模型只能构造可凭空解析的序列化类型。
如果实在要泛化调用PB序列化服务,解决方案还是有的,还是用对称加密举例,只要我拿到和服务端一致的“密钥“,我就可以构造出对方可解析的结构,从而发起泛化调用。这就是gRPC反射服务的原理,反射服务可以让客户端在发起调用之前,拿到这份proto接口定义文件,从而获得对称加密的“密钥”,在这份密钥的基础上,填写好参数字段,就能像正常客户端一样发起调用了。
HSF-go在Dapr场景的实践
上面主要聊了Dubbo体系的泛化调用模型,上面也提到了,泛化调用的应用场景非常多,也成为了Dapr落地的基础之一。Dapr是阿里云合作的,微软开源的CNCF孵化项目,融合了标准化API、组件可扩展SPI机制、边车架构、Serverless等诸多先进理念,在阿里集团有FC,跨云等许多生产落地场景。
1、DaprBinding模型
Dapr标准化API理念是非常新颖和实用的,其中Bindings构造块,是我们服务调用解决方案的基础。
Bindings最直观的理解,是介于用户应用运行时和网络之间的一层流量中间件。
上图可以解释基于Binding的整条调用链路,由用户应用运行时调用Dapr标准化接口从而发起调用。由Dapr运行时将流量交给可扩展的Binding构造块,Dapr可以这种统一化接口和可扩展能力,很方便地支持多种协议的切换,按需激活。如图中伸展出来的HSF、Dubbo支持。
被激活的例如HSF-go构造块将接管这一请求,将来自应用的标准化的请求头和请求体解析出来,生成HSF协议请求,Dapr边车一般不会拥有下游服务二方包,因此这一请求一定是泛化调用请求。
当然,在请求发出之前,早已完成了服务发现过程,这是用户以及应用运行时无感的,由Dapr来接管和封装。上面提到的泛化请求在完成服务发现之后,即可被发送至目的机器ip,被下游的InboundBinding的HSF-go实现所接收和处理,这个下游的组件对应上面提到的“服务端泛化调用”,他接受任何HSF请求。下游将HSF协议解析出来,参数从泛化调用的三个参数标准化为正常请求参数后,通过Dapr提供的Callback机制传递至应用运行时。
在这一过程中,泛化调用扮演了极其重要的角色,在客户端负责出流量的HSF协议泛化调用发起,在服务端负责入流量的泛化调用解析和传递。
我认为,Dapr绑定的网络协议模型,是RPC协议进一步抽象的体现。将所有的RPC协议抽象为metadata(元数据)和body两部分,用户应用/SDK侧只需要关心这两部分的内容。一旦将这个抽象的请求结构交给Dapr,具体协议的生成,就由具体激活的构造块来做了,这是我认为Dapr提供的一种很精巧的服务调用抽象设计。
2、序列化数组透传的设计
上面提到的入流量与出流量组件都是泛化调用的实现,但如果细究,并不是第一节我们提到的传统泛化调用。
传统泛化调用的入参是结构,调用过程涉及到序列化过程。在Dapr这种边车场景下,一次完整的RPC调用将会引入至少六次序列化/反序列化过程,这成本是巨大的。
因此在设计中,并没有使用标准泛化调用过程,而是将序列化过程省略掉了,只保留了应用侧的一次序列化,Dapr边车针对参数部分只进行透传处理。这样来,大大减少了无谓的消耗。
这样一来,在客户端Outbound的实现,就成了针对如下泛化调用接口的使用:
//args参数为序列化后的byte数组ProxyInvokeWithBytes(ctxcontext.Context,methodNamestring,argsTypes[]string,args[][]byte)([]byte,error)在服务端Inbound的实现,也成了针对byte数组类型参数的泛化调用//inbound入参typeRawDataServiceRequeststruct{RequestContext*co.RequestContextMethodstringArgsTypes[]stringArgs[][]byte//args参数为序列化后的byte数组Attachmentmap[string]interface{}RequestProps[]byte}
相当于在泛化调用的基础上,删除了序列化操作,将请求参数透传。
HSF-go服务代理的设计
钉钉团队拥有很多Go语言落地场景,在Dubbo-go生态项目的发展过程中提供了诸多帮助与实践。
在跨集群通信解决方案中,代理网关是必不可少的,大多数网关需要运维人员手动进行流量配置。部分网关对网络协议存在要求,例如envoy,因此中间件团队推出基于Http2的Dubbo3(Triple)协议的原因之一,就是为了适配网关。
在跨集群RPC场景下,理想情况是在网关层不需要进行协议转换,并且不需要进行序列化/反序列化过程,并且将服务治理能力融合在网关内部,从而减少资源消耗和运维成本。
这也提出了一种诉求,在集团内跨云场景下,我们需要建立一个支持原生HSF协议的代理网关,从而允许集群外部的客户端在无感的情况下,将请求切流量至集群内部,由网关接受来自外界的HSF请求,并动态进行服务发现流程,将请求流量转发至集群内对应服务提供者。可以想到,泛化调用在这个过程中将扮演重要角色。
我们沿着之前Dapr的思路,如上图所示,将视角从整个调用链路转移到单个实例上,可以看到一个实例可以接受泛化请求,并也可以发起泛化请求,在泛化过程中不涉及序列化过程。这个我们所