简介: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 协议客户端才需要使用
GetUser func(ctx context.Context, req int32) (*User, error) `dubbo:"getUser"`
}
func init(){
// 注册客户端存根类到框架,实例化客户端接口指针 userProvider
config.SetConsumerService(userProvider)
}
// 字段需要与 Java 侧对应,首字母大写
type User struct {
UserID string
UserFullName string `hessian:"user_full_name"`
UserAge int32 // 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}}
)
框架接收到这三个参数后,会构造出泛化请求,发送至服务端。
服务端在接收到泛化请求时,会在一层 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 最直观的理解,是介于用户应用运行时和网络之间的一层流量中间件。
上图可以解释基于 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 提供的一种很精巧的服务调用抽象设计。
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
Method string
ArgsTypes []string
Args [][]byte // args 参数为序列化后的byte数组
Attachment map[string]interface{}
RequestProps []byte
}
相当于在泛化调用的基础上,删除了序列化操作,将请求参数透传。
HSF-go 服务代理的设计
钉钉团队拥有很多 Go 语言落地场景,在 Dubbo-go 生态项目的发展过程中提供了诸多帮助与实践。
在跨集群通信解决方案中,代理网关是必不可少的,大多数网关需要运维人员手动进行流量配置。部分网关对网络协议存在要求,例如 envoy,因此中间件团队推出基于 Http2 的 Dubbo3(Triple) 协议的原因之一,就是为了适配网关。
在跨集群 RPC 场景下,理想情况是在网关层不需要进行协议转换,并且不需要进行序列化/反序列化过程,并且将服务治理能力融合在网关内部,从而减少资源消耗和运维成本。
这也提出了一种诉求,在集团内跨云场景下,我们需要建立一个支持原生 HSF 协议的代理网关,从而允许集群外部的客户端在无感的情况下,将请求切流量至集群内部,由网关接受来自外界的 HSF 请求,并动态进行服务发现流程,将请求流量转发至集群内对应服务提供者。可以想到,泛化调用在这个过程中将扮演重要角色。
我们沿着之前 Dapr 的思路,如上图所示,将视角从整个调用链路转移到单个实例上,可以看到一个实例可以接受泛化请求,并也可以发起泛化请求,在泛化过程中不涉及序列化过程。这个我们所关注的实例,就是一个网关的抽象表现。
拥有了这样的网关,我们可以实现客户端无感的跨集群调用。在必要的情况下,可以在客户端所在环境进行代理注册。
这样的网关是单向的,可以处理从外部进入内部的流量,如果希望双向打通,跨集群的统一化注册中心将是必要的。在这种情况下,网关需要根据流量查询多个注册中心的信息,从而保证链路正确。
总结
HSF 是阿里集团 RPC/服务治理 领域的标杆,Go 语言又因为其高并发,云原生的特性,拥有广阔的发展前景和实践场景,服务代理模型只是一种落地场景,除此之外,还有更多的应用场景值得我们在研发的过程中去探索和总结。
Dubbo/HSF 生态、Dubbo-go 技术体系将携手用户一同打磨与实践。
原文链接
本文为阿里云原创内容,未经允许不得转载。