Dubbo-go 服务代理模型

简介:HSF 是阿里集团 RPC/服务治理 领域的标杆,Go 语言又因为其高并发,云原生的特性,拥有广阔的发展前景和实践场景,服务代理模型只是一种落地场景,除此之外,还有更多的应用场景值得我们在研发的过程中去探索和总结。
作者 | 李志信
背景
Dubbo-go 生态包括 Dubbo-go v3.0 、v1.5、pixiu 等子项目,在可扩展性上提供了灵活的定制化方式。
众所周知,HSF 是阿里集团 RPC/服务治理 领域的标杆框架。HSF-go 是 go 语言实现的 HSF 框架,由中间件团队维护,由于 Go 语言的特性,在跨语言调用场景,云原生组件集成服务代理场景扮演重要角色,目前拥有 Dapr Binding实现,并且在函数计算(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 客户端存根类 type UserProvider struct { // dubbo标签,用于适配go侧客户端大写方法名 -> java侧小写方法名,只有 dubbo 协议客户端才需要使用 GetUserfunc(ctx context.Context, req int32) (*User, error) `dubbo:"getUser"` }func init(){ // 注册客户端存根类到框架,实例化客户端接口指针 userProvider config.SetConsumerService(userProvider) }// 字段需要与 Java 侧对应,首字母大写 type User struct { UserIDstring UserFullName string`hessian:"user_full_name"` UserAgeint32 // default convert to "userAge" Time time.Time }func (u *User) JavaClassName() string { return "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: "laurence"}, &GoUser{Age: 22}} )

框架接收到这三个参数后,会构造出泛化请求,发送至服务端。
【Dubbo-go 服务代理模型】服务端在接收到泛化请求时,会在一层 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、Dapr Binding 模型 Dapr 标准化 API 理念是非常新颖和实用的,其中 Bindings 构造块, 是我们服务调用解决方案的基础。
Bindings 最直观的理解,是介于用户应用运行时和网络之间的一层流量中间件。
Dubbo-go 服务代理模型
文章图片

上图可以解释基于 Binding 的整条调用链路,由用户应用运行时调用 Dapr 标准化接口从而发起调用。由 Dapr 运行时将流量交给可扩展的 Binding 构造块,Dapr 可以这种统一化接口和可扩展能力,很方便地支持多种协议的切换,按需激活。如图中伸展出来的 HSF、Dubbo 支持。
被激活的例如 HSF-go 构造块将接管这一请求,将来自应用的标准化的请求头和请求体解析出来,生成 HSF 协议请求,Dapr 边车一般不会拥有下游服务二方包,因此这一请求一定是泛化调用请求。
当然,在请求发出之前,早已完成了服务发现过程,这是用户以及应用运行时无感的,由 Dapr 来接管和封装。上面提到的泛化请求在完成服务发现之后,即可被发送至目的机器 ip,被下游的 Inbound Binding 的 HSF-go 实现所接收和处理,这个下游的组件对应上面提到的“服务端泛化调用”,他接受任何 HSF 请求。下游将 HSF 协议解析出来,参数从泛化调用的三个参数标准化为正常请求参数后,通过 Dapr 提供的 Callback 机制传递至应用运行时。
在这一过程中,泛化调用扮演了极其重要的角色,在客户端负责出流量的 HSF 协议泛化调用发起,在服务端负责入流量的泛化调用解析和传递。
我认为,Dapr 绑定的网络协议模型,是 RPC 协议进一步抽象的体现。将所有的 RPC 协议抽象为 metadata(元数据)和 body 两部分,用户应用/SDK 侧只需要关心这两部分的内容。一旦将这个抽象的请求结构交给 Dapr,具体协议的生成,就由具体激活的构造块来做了,这是我认为 Dapr 提供的一种很精巧的服务调用抽象设计。
Dubbo-go 服务代理模型
文章图片

2、序列化数组透传的设计 上面提到的入流量与出流量组件都是泛化调用的实现,但如果细究,并不是第一节我们提到的传统泛化调用。
传统泛化调用的入参是结构,调用过程涉及到序列化过程。在 Dapr 这种边车场景下,一次完整的 RPC 调用将会引入至少六次序列化/反序列化过程,这成本是巨大的。
因此在设计中,并没有使用标准泛化调用过程,而是将序列化过程省略掉了,只保留了应用侧的一次序列化,Dapr 边车针对参数部分只进行透传处理。这样来,大大减少了无谓的消耗。
这样一来,在客户端 Outbound 的实现,就成了针对如下泛化调用接口的使用:
//args 参数为序列化后的byte数组 ProxyInvokeWithBytes(ctx context.Context, methodName string, argsTypes []string, args [][]byte) ([]byte, error)在服务端Inbound 的实现,也成了针对byte数组类型参数的泛化调用// inbound 入参 type RawDataServiceRequest struct { RequestContext *core.RequestContext Methodstring ArgsTypes[]string Args[][]byte // args 参数为序列化后的byte数组 Attachmentmap[string]interface{} RequestProps[]byte }

相当于在泛化调用的基础上,删除了序列化操作,将请求参数透传。
HSF-go 服务代理的设计
钉钉团队拥有很多 Go 语言落地场景,在 Dubbo-go 生态项目的发展过程中提供了诸多帮助与实践。
在跨集群通信解决方案中,代理网关是必不可少的,大多数网关需要运维人员手动进行流量配置。部分网关对网络协议存在要求,例如 envoy,因此中间件团队推出基于 Http2 的 Dubbo3(Triple) 协议的原因之一,就是为了适配网关。
在跨集群 RPC 场景下,理想情况是在网关层不需要进行协议转换,并且不需要进行序列化/反序列化过程,并且将服务治理能力融合在网关内部,从而减少资源消耗和运维成本。
这也提出了一种诉求,在集团内跨云场景下,我们需要建立一个支持原生 HSF 协议的代理网关,从而允许集群外部的客户端在无感的情况下,将请求切流量至集群内部,由网关接受来自外界的 HSF 请求,并动态进行服务发现流程,将请求流量转发至集群内对应服务提供者。可以想到,泛化调用在这个过程中将扮演重要角色。
Dubbo-go 服务代理模型
文章图片

我们沿着之前 Dapr 的思路,如上图所示,将视角从整个调用链路转移到单个实例上,可以看到一个实例可以接受泛化请求,并也可以发起泛化请求,在泛化过程中不涉及序列化过程。这个我们所关注的实例,就是一个网关的抽象表现。
拥有了这样的网关,我们可以实现客户端无感的跨集群调用。在必要的情况下,可以在客户端所在环境进行代理注册。
Dubbo-go 服务代理模型
文章图片

这样的网关是单向的,可以处理从外部进入内部的流量,如果希望双向打通,跨集群的统一化注册中心将是必要的。在这种情况下,网关需要根据流量查询多个注册中心的信息,从而保证链路正确。
Dubbo-go 服务代理模型
文章图片

总结
HSF 是阿里集团 RPC/服务治理 领域的标杆,Go 语言又因为其高并发,云原生的特性,拥有广阔的发展前景和实践场景,服务代理模型只是一种落地场景,除此之外,还有更多的应用场景值得我们在研发的过程中去探索和总结。
Dubbo/HSF 生态、Dubbo-go 技术体系将携手用户一同打磨与实践。
原文链接
本文为阿里云原创内容,未经允许不得转载。

    推荐阅读