Go 泛型在实际业务场景中的应用

2022年7月20日 227点热度 0人点赞 0条评论

距离Go 1.18正式发布已有四个月的时间,泛型作为该版本的一大特性,网上已经有不少文章对其做过介绍了。而本文的目的主要是想说说,如果在我们的实际业务当中使用泛型,具体有哪些落地的应用场景,以及能解决之前编码上的哪些问题或痛点。

关于泛型的基本用法,本文就不再赘述,你可以从任何常用的搜索引擎找到有关这些基本用法的文章。或者可以直接参看这篇文章:GO编程模式:泛型编程[1]

接下来,我将通过以下三个场景(均来自于我们真实业务中的案例),来介绍泛型是如何和实践相结合的。

场景一

在业务开发中,通常会将一些通用的小函数放到一个类似的common库中,让各业务项目依赖这个common库,按需调用这些函数,以达到代码复用的目的。那现在我们有这样一个函数,需要把一个int类型的切片按照分隔符转换成一个string,具体代码如下:

func IntSliceToString(intSlice []int, sep string) string {
 stringSlice := make([]stringlen(intSlice))
 for i, v := range intSlice {
  stringSlice[i] = strconv.Itoa(v)
 }

 return strings.Join(stringSlice, sep)
}

我们会这么使用这个函数:

func main() {
 intSlice := []int{12345}
 fmt.Println(fmt.Sprintf("%s", IntSliceToString(intSlice, ",")))
}

现在业务上有另外一个类似的需求:需要把一个int64类型的切片按照分隔符转换成一个string,我们会怎么来实现呢?按照直接的方式,会是如下这样:

func Int64SliceToString(int64Slice []int64, sep string) string {
 stringSlice := make([]stringlen(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) stringstring {
 stringSlice := make([]stringlen(arr))
 for i, v := range arr {
  stringSlice[i] = f(v)
 }

 return strings.Join(stringSlice, sep)
}

该函数声明了一个类型为T的切片,而T是用any这个类型来进行约束的(在Go 1.18中,anyinterface{}的别名),也就意味着,该函数可以接收任意类型的切片。另外,第三个参数是一个func,该函数同样接收一个类型为T的参数,返回一个string。

有了这个泛型函数,我们的IntSliceToStringInt64SliceToString函数就可以改写为:

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库对外提供的IntSliceToStringInt64SliceToString函数不变,既达到了代码复用性的目的,同时又为今后的类似需求提供了扩展点。

当然,更进一步来讲,为了缩小约束范围,我们也可以自定义一个类型约束的interface,将numericSliceToString函数中的参数类型由any替换成这个自定义的interface。

type numeric interface {
 int | int64
}

func numericSliceToString[T numeric](arr []T, sep string, f func(T) stringstring {
 ...
}

用numeric这个interface来约束T,表示只允许接收int或int64类型的参数。

场景二

写过Java的同学都知道,MyBatis是一个非常好用的半自动ORM框架,业务的repository通过继承BaseMapper这个类,来达到自动拥有一些BaseMapper定义的通用方法这一目的。BaseMapper的代码如下:

public interface BaseMapper<Textends 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("")
}

当然,对于泛型的应用场景,还有其他的许多,只不过对于目前我们的业务来讲,我能想到的只有以上的这三个业务场景,本文的目的也是想起到一个抛砖引玉的作用,也欢迎各位在评论区留言补充,我们一起交流讨论。

参考资料

[1]

GO编程模式:泛型编程: https://coolshell.cn/articles/21615.html

85570Go 泛型在实际业务场景中的应用

这个人很懒,什么都没留下

文章评论