距离Go 1.18正式发布已有四个月的时间,泛型作为该版本的一大特性,网上已经有不少文章对其做过介绍了。而本文的目的主要是想说说,如果在我们的实际业务当中使用泛型,具体有哪些落地的应用场景,以及能解决之前编码上的哪些问题或痛点。
关于泛型的基本用法,本文就不再赘述,你可以从任何常用的搜索引擎找到有关这些基本用法的文章。或者可以直接参看这篇文章:GO编程模式:泛型编程[1]
接下来,我将通过以下三个场景(均来自于我们真实业务中的案例),来介绍泛型是如何和实践相结合的。
场景一
在业务开发中,通常会将一些通用的小函数放到一个类似的common库中,让各业务项目依赖这个common库,按需调用这些函数,以达到代码复用的目的。那现在我们有这样一个函数,需要把一个int类型的切片按照分隔符转换成一个string,具体代码如下:
func IntSliceToString(intSlice []int, sep string) string {
stringSlice := make([]string, len(intSlice))
for i, v := range intSlice {
stringSlice[i] = strconv.Itoa(v)
}
return strings.Join(stringSlice, sep)
}
我们会这么使用这个函数:
func main() {
intSlice := []int{1, 2, 3, 4, 5}
fmt.Println(fmt.Sprintf("%s", IntSliceToString(intSlice, ",")))
}
现在业务上有另外一个类似的需求:需要把一个int64类型的切片按照分隔符转换成一个string,我们会怎么来实现呢?按照直接的方式,会是如下这样:
func Int64SliceToString(int64Slice []int64, sep string) string {
stringSlice := make([]string, len(int64Slice))
for i, v := range int64Slice {
stringSlice[i] = strconv.FormatInt(v, 10)
}
return strings.Join(stringSlice, sep)
}
以上代码虽然能满足这个需求,但是其实逻辑和IntSliceToString
函数差不多,唯一有差别的地方就是第4行。那么问题就来了,如果我要再支持以下的这些需求,该怎么办呢?
-
把float32类型的切片,按照分隔符转换成一个string -
把一个自定义的struct的某个属性,按照分隔符转换成一个string -
…
解决办法可能就是简单的复制粘贴,再稍微改一改。在编码中的痛点就是实现的不够优雅,存在大量的类似重复逻辑。当我们想要对逻辑做一些改动,比如对返回的字符串前后分别加上"["、"]"两个字符,那么就要把这些函数全部都改一遍。
针对这个痛点,我们如何用泛型来解决逻辑重复性的问题呢?实际上我们可以定义一个泛型函数,例如下面这样:
func numericSliceToString[T any](arr []T, sep string, f func(T) string) string {
stringSlice := make([]string, len(arr))
for i, v := range arr {
stringSlice[i] = f(v)
}
return strings.Join(stringSlice, sep)
}
该函数声明了一个类型为T
的切片,而T
是用any
这个类型来进行约束的(在Go 1.18中,any
是interface{}
的别名),也就意味着,该函数可以接收任意类型的切片。另外,第三个参数是一个func
,该函数同样接收一个类型为T
的参数,返回一个string。
有了这个泛型函数,我们的IntSliceToString
和Int64SliceToString
函数就可以改写为:
func IntSliceToString(intSlice []int, sep string) string {
return numericSliceToString(intSlice, sep, strconv.Itoa)
}
func Int64SliceToString(int64Slice []int64, sep string) string {
return numericSliceToString(int64Slice, sep, func(elem int64) string {
return strconv.FormatInt(elem, 10)
})
}
是不是优雅了一些?我们利用泛型,把相同的逻辑下沉到了numericSliceToString
函数中,而common库对外提供的IntSliceToString
和Int64SliceToString
函数不变,既达到了代码复用性的目的,同时又为今后的类似需求提供了扩展点。
当然,更进一步来讲,为了缩小约束范围,我们也可以自定义一个类型约束的interface,将numericSliceToString
函数中的参数类型由any
替换成这个自定义的interface。
type numeric interface {
int | int64
}
func numericSliceToString[T numeric](arr []T, sep string, f func(T) string) string {
...
}
用numeric这个interface来约束
T
,表示只允许接收int或int64类型的参数。
场景二
写过Java的同学都知道,MyBatis是一个非常好用的半自动ORM框架,业务的repository通过继承BaseMapper这个类,来达到自动拥有一些BaseMapper定义的通用方法这一目的。BaseMapper的代码如下:
public interface BaseMapper<T> extends Mapper<T> {
/**
* 插入一条记录
*
* @param entity 实体对象
*/
int insert(T entity);
/**
* 根据 ID 删除
*
* @param id 主键ID
*/
int deleteById(Serializable id);
/**
* 根据 ID 修改
*
* @param entity 实体对象
*/
int updateById(@Param(Constants.ENTITY) T entity);
...
}
对于业务的repository,无需重新声明这些方法,只需要在对应的mapper.xml中编写具体实现这些方法的xml代码即可。
我们这些使用Go语言的同学,是很羡慕写Java的同学能够直接使用MyBatis这种半自动框架来提升开发效率,同时也能使业务的repository变得很简洁。而对于之前没有泛型支持的我们来说,写业务repository的流程是这样的:
比如我要开发一个用户模块,需要有基本的增删改查方法,用户模块的repository的定义如下:
// User domain层的领域对象
type User struct {
Id string
Name string
Sex int
Avatar string
}
// UserRepository domain层的repo接口定义
type UserRepository interface {
Insert(ctx context.Context, u *User) (string, error)
DeleteById(ctx context.Context, id string) error
UpdateById(ctx context.Context, id string, u *User) error
FindById(ctx context.Context, id string) (*User, error)
}
接下来,我们又被要求开发一个评论模块,这个模块的业务repository也会有基本的增删改查方法,相关的代码实现如下:
type Comment struct {
Id string
Content string
UserId string
}
type CommentRepository interface {
Insert(ctx context.Context, c *Comment) (string, error)
DeleteById(ctx context.Context, id string) error
UpdateById(ctx context.Context, id string, c *Comment) error
FindById(ctx context.Context, id string) (*Comment, error)
}
有没有发现,Insert、DeleteById、UpdateById、FindById这些接口方法的定义,除了在入参和返回值上的类型不一样,其他基本上是一样的。这就导致了我们每开发一个新的业务模块,都需要为业务对应的repo定义一套这些方法,做法就是拷贝现成业务repo的方法定义,改一改入参和返回值(很熟悉的味道,是不是?)。
那有了泛型,我们如何更优雅的实现这类代码呢?我们可以编写一个类似BaseMapper的interface,我们就叫它BaseRepository,代码如下:
type BaseDO interface {
GetId() string
}
type BaseRepository[T BaseDO] interface {
Insert(ctx context.Context, t T) (string, error)
DeleteById(ctx context.Context, id string) error
UpdateById(ctx context.Context, id string, t T) error
FindById(ctx context.Context, id string) (T, error)
}
在BaseRepository中,入参和返回值的类型被限定在了T
这一类型上,所有实现了BaseDO的领域对象,其对应的Repository通过持有一个BaseRepository匿名属性,来自动拥有BaseRepository中定义的这些通用方法,而无需再像之前那样拷贝粘贴一份。我们来看看具体怎么使用:
对于用户模块,我们可以写成下面这样:
type User struct {
...
}
func (u *User) GetId() string {
return u.Id
}
type UserRepository interface {
BaseRepository[*User]
}
type userRepository struct {}
func NewUserRepository() UserRepository {
return &userRepository{}
}
func (repo *userRepository) Insert(ctx context.Context, u *User) (string, error) {
panic("insert user")
}
func (repo *userRepository) DeleteById(ctx context.Context, id string) error {
panic("delete user")
}
func (repo *userRepository) UpdateById(ctx context.Context, id string, u *User) error {
panic("update user")
}
func (repo *userRepository) FindById(ctx context.Context, id string) (*User, error) {
panic("find user")
}
对于调用方来讲,和之前的调用方式没有区别:
userRepository := NewUserRepository()
u, err := userRepository.FindById(context.Background(), "1")
if err != nil {
panic(err)
}
fmt.Println(fmt.Sprintf("user id: %s, user name: %s", u.Id, u.Name))
这样,我们就利用泛型实现了类似MyBatis的BaseMapper的功能,让我们的业务代码变得更简洁和优雅。
场景三
对于业务中比较常见的基于关注关系的内容流这一场景(比如微博的关注流、微信的朋友圈等),以微博为例,服务端会提供关注feed流接口,实现逻辑如下:
func (f *FeedFlowService) GetUserFeedFlow(ctx context.Context, userId string) ([]*Feed, error) {
// 1、查找关注用户列表
followingUserIds, _ := f.userRelationRepository.FindFollowingUserIds(context.Background(), userId)
// 2、查找关注用户发布的feed,按feed发布时间倒序
feedIds, _ := f.userFeedRepository.FindFollowingUsersFeedIds(context.Background(), followingUserIds)
// 3、查找feed实体,返回的切片无序(因为SQL使用的是in查找)
feeds, _ := f.feedRepository.FindFeedsById(context.Background(), feedIds)
// 4、构造返回的feed列表,切片有序(按照feed发布时间倒序)
feedsMap := make(map[string]*Feed)
for _, feed := range feeds {
feedsMap[feed.Id] = feed
}
result := make([]*Feed, len(feeds))
for i, v := range feedIds {
result[i] = feedsMap[v]
}
return result, nil
}
对于第4步,我们需要根据有序的feedIds构造出有序的feeds返回值,使用一个额外的map来提高遍历切片的效率。对于这种先查关系拿到id,再根据id查找实体的情形,在一个App里会出现在很多模块中,比如获取用户发布的feed、获取feed下的评论、获取评论下的回复等等。这些接口除了查找关系、查找实体的Repository方法不一样,第4步构造返回数据的逻辑基本一样。如果不使用泛型,依旧是老套路,拷贝粘贴,改一改。那使用泛型呢,还是可以定义一个泛型函数,如下:
func sortEntitiesByOrderedIds[E BaseDO](ids []string, entities []E) []E {
entityMap := make(map[string]E)
for _, entity := range entities {
entityMap[entity.GetId()] = entity
}
result := make([]E, len(entities))
for i, v := range ids {
result[i] = entityMap[v]
}
return result
}
这样,对于GetUserFeedFlow
函数第4步来讲,只需要一行代码就搞定了:
func (f *FeedFlowService) GetUserFeedFlow(ctx context.Context, userId string) ([]*Feed, error) {
...
// 4、构造返回的feed列表,切片有序(按照feed发布时间倒序)
return sortEntitiesByOrderedIds(feedIds, feeds), nil
}
另外,对于其他类似模块的接口实现,也可以直接调用sortEntitiesByOrderedIds
函数,只要保证函数第2个参数的实体实现了BaseBO
接口即可。
上述完整的代码如下:
type FeedFlowService struct {
userRelationRepository UserRelationRepository
userFeedRepository UserFeedRepository
feedRepository FeedRepository
}
func (f *FeedFlowService) GetUserFeedFlow(ctx context.Context, userId string) ([]*Feed, error) {
// 1、查找关注用户列表
followingUserIds, _ := f.userRelationRepository.FindFollowingUserIds(ctx, userId)
// 2、查找关注用户发布的feed,按feed发布时间倒序
feedIds, _ := f.userFeedRepository.FindFollowingUsersFeedIds(ctx, followingUserIds)
// 3、查找feed详情,返回的切片无序(因为SQL使用的是in查找)
feeds, _ := f.feedRepository.FindFeedsById(ctx, feedIds)
// 4、构造返回的feed列表,切片有序(按照feed发布时间倒序)
return sortEntitiesByOrderedIds(feedIds, feeds), nil
}
func sortEntitiesByOrderedIds[E BaseDO](ids []string, entities []E) []E {
entityMap := make(map[string]E)
for _, entity := range entities {
entityMap[entity.GetId()] = entity
}
result := make([]E, len(entities))
for i, v := range ids {
result[i] = entityMap[v]
}
return result
}
type BaseDO interface {
GetId() string
}
type Feed struct {
Id string
Content string
Type int
ImageUrls []string
VideoUrls []string
}
func (f *Feed) GetId() string {
return f.Id
}
type UserRelationRepository interface {
FindFollowingUserIds(ctx context.Context, userId string) ([]string, error)
}
type userRelationRepository struct {
}
func NewUserRelationRepository() UserRelationRepository {
return &userRelationRepository{}
}
func (u *userRelationRepository) FindFollowingUserIds(ctx context.Context, userId string) ([]string, error) {
panic("")
}
type UserFeedRepository interface {
FindFollowingUsersFeedIds(ctx context.Context, userIds []string) ([]string, error)
}
type userFeedRepository struct {
}
func NewUserFeedRepository() UserFeedRepository {
return &userFeedRepository{}
}
func (u *userFeedRepository) FindFollowingUsersFeedIds(ctx context.Context, userIds []string) ([]string, error) {
panic("")
}
type FeedRepository interface {
FindFeedsById(ctx context.Context, feedIds []string) ([]*Feed, error)
}
type feedRepository struct {
}
func NewFeedRepository() FeedRepository {
return &feedRepository{}
}
func (f *feedRepository) FindFeedsById(ctx context.Context, feedIds []string) ([]*Feed, error) {
panic("")
}
当然,对于泛型的应用场景,还有其他的许多,只不过对于目前我们的业务来讲,我能想到的只有以上的这三个业务场景,本文的目的也是想起到一个抛砖引玉的作用,也欢迎各位在评论区留言补充,我们一起交流讨论。
参考资料
GO编程模式:泛型编程: https://coolshell.cn/articles/21615.html
文章评论