关于对golang泛型的一点研究

201 阅读8分钟

最近笔者偶然发现 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)
        ...
    }
}

UserApirpcclient.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 函数的参数中的具体实现

  1. rpc 函数

    rpc 是一个函数类型的参数,它接受一个 C 类型的客户端、一个上下文、一个 A 类型的请求参数和一些 gRPC 调用选项,并返回一个 B 类型的响应和一个错误。这使得 Call 函数可以接受任意类型的 RPC 调用函数。

  2. client

    client 是一个 C 类型的客户端,它用于实际的 RPC 调用。

  3. c

    c 是一个 *gin.Context 类型的对象,它包含了来自 HTTP 请求的信息。

  4. 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 的要求。

尽管 UserRegisterrpc 参数看起来不完全一致,但它们在关键部分是兼容的:

  • 都接受一个 context.Context 类型的参数。
  • 都接受可变数量的 grpc.CallOption 类型的参数。
  • 都返回一个指针类型的响应和一个 error 类型。

这里的关键在于 UserRegister 方法没有显式地包含 client 参数,而是通过 UserClient 接口隐式地包含了它。在 a2r.Call 调用中,client 参数会被显式地提供,而 UserRegister 方法将从其接收者那里获取客户端。

所以,即使 UserRegisterrpc 参数的签名不完全相同,它们仍然可以互相匹配,因为 UserRegister 可以被适配到 Call 函数期望的签名中。在这种情况下,a2r.Call 负责处理 client 参数和其他可能需要的逻辑。

总感觉说服力不太够的样子。。。

这种情况属于特殊性还是具有普遍性还不得而知,纸上得来终觉浅,绝知此事要躬行,让我们写个例子实验下。


实验

我们就拿上面 Validator 的例子稍加修改,代码如下:

1. 定义接口 ValidateNumValidateStr

两个接口的方法签名都只接收一个参数

type ValidateNum interface {
    IntInRange(num int) error
}

type ValidateStr interface {
    StringNotEmpty(content string) error
}

2. 定义具体实现类型 NumStr

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 泛型的特性之一。

参考:

Go 1.21: Generic Functions Comprehensive Revisit