最近笔者偶然发现 GitHub 上面一个 star 很高的开源IM项目,名为 open-im-server。 在研究源代码的过程中发现了一些之前没有注意过的 golang 泛型技术细节,经过一番琢磨,所获颇丰,特意写篇文章来记录下这个探索过程。
简单介绍
为了让大家更好的理解技术细节,先在下面附上项目的源代码片段,并做简单的业务介绍。
这是 api 文件夹中的文件 router.go, 采用 gin 框架写的一些平平无奇的路由。下面我把大部分代码都省略,只保留枝干方便解读。
func newGinRouter(disCov discovery.SvcDiscoveryRegistry, config *Config) *gin.Engine {
...
r := gin.New()
...
u := NewUserApi(*userRpc)
...
{
userRouterGroup.POST("/user_register", u.UserRegister)
userRouterGroup.POST("/update_user_info", u.UpdateUserInfo)
userRouterGroup.POST("/update_user_info_ex", u.UpdateUserInfoEx)
...
}
}
UserApi 和 rpcclient.User 是等价的,以 UserApi 为对象写了一堆方法。这里的方法不直接处理业务逻辑,而是通过 RPC 调用微服务模块,从结构上来看真是赏心悦目,颇具工程美学。
type UserApi rpcclient.User
func NewUserApi(client rpcclient.User) UserApi {
return UserApi(client)
}
func (u *UserApi) UserRegister(c *gin.Context) {
a2r.Call(user.UserClient.UserRegister, u.Client, c)
}
func (u *UserApi) UpdateUserInfo(c *gin.Context) {
a2r.Call(user.UserClient.UpdateUserInfo, u.Client, c)
}
func (u *UserApi) UpdateUserInfoEx(c *gin.Context) {
a2r.Call(user.UserClient.UpdateUserInfoEx, u.Client, c)
}
...
通过以上代码片段我们可以得知,这个项目是按照微服务的架构进行设计各个功能模块。功能模块包括认证、用户、信息、朋友、组群、会话、第三方,各个功能模块之间采用 RPC 交互。
1. 初始化 Gin 路由器:
newGinRouter函数创建了一个新的 Gin 引擎实例r。- 初始化了一些必要的组件,如服务发现注册表
disCov和配置对象config。 - 创建了一个
UserApi实例u,该实例包装了一个rpcclient.User客户端。
2. 设置路由组:
- 使用 Gin 的路由组
userRouterGroup来组织相关的用户 API 路由。 - 设置了三个 POST 请求的路由处理器:
/user_register对应于UserApi的UserRegister方法。/update_user_info对应于UserApi的UpdateUserInfo方法。/update_user_info_ex对应于UserApi的UpdateUserInfoEx方法。
3. 处理 HTTP 请求:
- 当客户端发送请求到这些路由时,Gin 路由器会根据 URL 路径匹配相应的处理器函数。
- 例如,当发送一个 POST 请求到
/user_register时,Gin 会调用UserApi的UserRegister方法。
4. 调用 RPC 方法:
UserApi中的方法,如UserRegister,实际上只是简单地转发请求到 RPC 客户端。- 具体来说,
a2r.Call方法负责调用远程服务端点(这里假设a2r是一个封装了 RPC 调用的工具函数或结构体)。 - 这个方法接收四个参数:RPC 方法名、RPC 客户端实例、Gin 上下文
c(可能用于获取请求数据)。
具体分析
让我们来看一下 UserRegister,这是通过 protoc-gen-go 工具自动生成的文件 user.pb.go 中的代码,UserRegister 是接口 UserClient 的方法签名。
这里注意一下,这个方法签名可以接受三个参数,其中一个是可变参数,从具体的实现来看也没啥问题。
type UserClient interface {
...
UserRegister(ctx context.Context, in *UserRegisterReq, opts ...grpc.CallOption) (*UserRegisterResp, error)
...
}
...
func (c *userClient) UserRegister(ctx context.Context, in *UserRegisterReq, opts ...grpc.CallOption) (*UserRegisterResp, error) {
out := new(UserRegisterResp)
err := c.cc.Invoke(ctx, "/openim.user.user/userRegister", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
接着我们再来研究一下 a2r.Call(),去掉大部分不相关的代码,只留下最关键的一行。
type Option[A, B any] struct {
// BindAfter is called after the req is bind from ctx.
BindAfter func(*A) error
// RespAfter is called after the resp is return from rpc.
RespAfter func(*B) error
}
func Call[A, B, C any](rpc func(client C, ctx context.Context, req *A, options ...grpc.CallOption) (*B, error), client C, c *gin.Context, opts ...*Option[A, B]) {
...
resp, err := rpc(client, c, req)
...
}
在这个定义中,A, B, C 是类型参数,它们分别代表:
A:请求参数的类型。B:响应参数的类型。C:RPC 客户端的类型。
泛型在Call 函数的参数中的具体实现
-
rpc函数:rpc是一个函数类型的参数,它接受一个C类型的客户端、一个上下文、一个A类型的请求参数和一些 gRPC 调用选项,并返回一个B类型的响应和一个错误。这使得Call函数可以接受任意类型的 RPC 调用函数。 -
client:client是一个C类型的客户端,它用于实际的 RPC 调用。 -
c:c是一个*gin.Context类型的对象,它包含了来自 HTTP 请求的信息。 -
opts:opts是一个可变参数列表,每个元素都是*Option[A, B]类型的指针,其中A是请求参数类型,B是响应参数类型。
小结
到这里我们就清楚了,a2r.Call 是一个泛型函数,它接受一个满足特定签名的函数作为参数,并使用这个函数来进行特定操作。
比如,将 UserClient 接口的 UserRegister 方法作为参数传入,那么此处的参数 rpc 其实就是 UserRegister,处理的就是 UserRegister 的业务逻辑。
通过泛型将 rpc 泛化为不同接口的不同方法,无需为每种类型编写单独的函数,直接省略掉大量函数的具体实现,减少了代码重复,主打一个灵活性。
似乎一切正常,好像没什么可以大惊小怪的。
等等, rpc 是几个参数来自?四个。
UserRegister 是几个参数?三个。。。这是什么情况?还有这种操作?
问题探索
我们常见的泛型有这样的:
package main
func First[T any](items []T) T {
return items[0]
}
func main() {
intSlice := []int{1, 2, 3, 4, 5}
firstInt := First[int](intSlice) // returns 1
println(firstInt)
stringSlice := []string{"apple", "banana", "cherry"}
firstString := First[string](stringSlice) // returns "apple"
println(firstString)
}
这样的:
package main
func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
return a + b
}
func main() {
sumInt := SumGenerics[int](2, 3) // returns 5
sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
sumInt64 := SumGenerics[int64](10, 20) // returns 30
}
还有这样的:
package main
import (
"fmt"
"strings"
)
// 这是一个泛型函数类型,它表示一个验证器函数。`Validator` 接受一个类型为 `T` 的参数,并
// 返回一个 `error`。这里的 `T` 表示任何类型,使得 `Validator` 可以用于验证任何类型的数据。
type Validator[T any] func(T) error
// 这是一个泛型函数,用于执行多个验证器。它接受一个类型为 `T` 的数据 `data` 和一个
// 可变参数列表 `validators`,每个验证器都是一个 `Validator[T]` 类型的函数。
func Validate[T any](data T, validators ...Validator[T]) error {
for _, validator := range validators {
err := validator(data)
if err != nil {
return err
}
}
return nil
}
// 两个函数是具体的验证器实现:
// `StringNotEmpty`:验证字符串 `s` 是否为空。
// `IntInRange`:验证整数 `num` 是否在 `[min, max]` 范围内。
func StringNotEmpty(s string) error {
if len(strings.TrimSpace(s)) == 0 {
return fmt.Errorf("string cannot be empty")
}
return nil
}
func IntInRange(num int, min, max int) error {
if num < min || num > max {
return fmt.Errorf("number must be between %d and %d", min, max)
}
return nil
}
type LoginForm struct {
Username string
Password string
Age int
}
func (f *LoginForm) Validate() error {
return Validate(f,
func(l *LoginForm) error {
return StringNotEmpty(l.Username)
},
func(l *LoginForm) error {
return StringNotEmpty(l.Password)
},
func(l *LoginForm) error {
return IntInRange(l.Age, 0, 100)
},
)
}
func main() {
loginForm := LoginForm{
Username: "John",
Password: "123",
Age: 50,
}
err := loginForm.Validate()
if err != nil {
println(err.Error())
panic(err)
}
println("Login form is valid")
}
对于传参个数不一致也能匹配上这个问题,笔者在网上逛了一圈,没发现非常有说服力的解释,无奈之下转而投入作为新兴生产力 AI 的怀抱。比如通义千问的回答是这样的。
在 Go 语言中,当我们将
user.UserClient.UserRegister传递给a2r.Call时,我们实际上是在传递一个函数字面量。由于UserRegister方法的签名与Call函数期望的rpc参数的签名相似(除了client参数外),Go 允许这种传递,因为UserRegister方法可以被适配以满足rpc的要求。尽管
UserRegister和rpc参数看起来不完全一致,但它们在关键部分是兼容的:
- 都接受一个
context.Context类型的参数。- 都接受可变数量的
grpc.CallOption类型的参数。- 都返回一个指针类型的响应和一个
error类型。这里的关键在于
UserRegister方法没有显式地包含client参数,而是通过UserClient接口隐式地包含了它。在a2r.Call调用中,client参数会被显式地提供,而UserRegister方法将从其接收者那里获取客户端。所以,即使
UserRegister和rpc参数的签名不完全相同,它们仍然可以互相匹配,因为UserRegister可以被适配到Call函数期望的签名中。在这种情况下,a2r.Call负责处理client参数和其他可能需要的逻辑。
总感觉说服力不太够的样子。。。
这种情况属于特殊性还是具有普遍性还不得而知,纸上得来终觉浅,绝知此事要躬行,让我们写个例子实验下。
实验
我们就拿上面 Validator 的例子稍加修改,代码如下:
1. 定义接口 ValidateNum 和 ValidateStr
两个接口的方法签名都只接收一个参数
type ValidateNum interface {
IntInRange(num int) error
}
type ValidateStr interface {
StringNotEmpty(content string) error
}
2. 定义具体实现类型 Num 和 Str
type Num struct {}
func (n *Num) IntInRange(num int) error {
if num < 0 || num > 100 {
return fmt.Errorf("number must be between %d and %d", 0, 100)
}
fmt.Printf("number %d is between %d and %d\n", num, 0, 100)
return nil
}
type Str struct {}
func (s *Str) StringNotEmpty(content string) error {
if len(strings.TrimSpace(content)) == 0 {
return fmt.Errorf("content cannot be empty")
}
fmt.Printf("content %s is valid\n", content)
return nil
}
3. 修改泛型验证函数 Validate
这里的 Validate 接受两个参数
func Validate[A, B any](validator func(client A, data B) error, client A, data B) error {
err := validator(client, data)
if err != nil {
return err
}
return nil
}
4. 在 main 函数中验证数据
func main() {
var NumClient ValidateNum = &Num{}
err := Validate(NumClient.IntInRange, NumClient, 100)
if err != nil {
println(err.Error())
}
var StrClient ValidateStr = &Str{}
err = Validate(StrClient.StringNotEmpty, StrClient, "hello world")
if err != nil {
println(err.Error())
}
}
5.输出
number 100 is between 0 and 100 content
hello is valid
真相大白,这并不是专属于 rpc 的独特性,而是基于 golang 泛型的普遍性功能。
6.结论
尽管 NumClient.IntInRange 和 StrClient.StringNotEmpty 方法只有一个参数,但通过将它们作为 validator 函数的第一个参数传递,成功地将这些方法与 Validate 函数适配起来。
这是因为 validator 函数的第一个参数实际上是指向方法的指针,而不是方法本身。因此可以将接口方法作为参数传递给 Validate 函数。 由此可见,这确实是 golang 泛型的特性之一。
参考: