一次Golang单体架构中的复杂长函数的重构实践和思考

在现代应用程序开发中,信息流(Feed)是许多平台核心功能的一部分。信息流往往会聚合大量的数据,构建这样一个信息流列表是一个复杂的任务。需要从多个微服务和数据库中获取大量数据,包括用户、频道、标签、等级、用户状态和互动等,并进行过滤、转换和计算,最终拼装成目标数据结构。在这个过程中,性能和代码设计的合理性尤为重要。

难题:一个超过1000行的长函数

在互联网服务端代码中,分层架构是一种常见的业务逻辑组织方式。我们通常根据实体,将每一层分为以实体名称命名的文件和无状态对象,每个对象包含数据获取、业务逻辑处理和数据库读写的方法。

通常情况下,大多数方法都是内聚的,只与单个实体相关,代码行数较少。但类似于微服务中的聚合根,无论数据结构如何设计组织,服务端接口最终需要根据产品和业务的需求,在前后端的交界处,为了满足UI展示的需求,将数据聚合起来。无论架构层面如何治理和拆分,为满足业务需求,接口层必然需要将数据按照使用需求进行组合。

信息流产品层面正是这样的场景。信息流列表中会聚合各种不同类型的信息和广告,以及信息作者的状态、互动数据和统计信息。为了满足这些需求,需要从各种数据源或服务中获取数据,并根据不同领域逻辑进行转换和组合,最终拼装成目标数据结构。在考虑性能的情况下,这些拼装数据的函数往往变得非常庞大。

在我参与的一个项目中,就遇到过一个信息流列表对象拼装函数。由于处理复杂数据,该函数长度超过1000行,甚至接近2000行,维护起来非常痛苦。

抽象和组合:拆分复杂函数

大多数小型业务探索驱动的项目,由于开发时间短、变化快,往往不会在代码设计上花费太多时间。开发人员也不愿意在调用业务逻辑函数时进行复杂的组装,因此很容易出现Service中包含一个长函数,并通过包的公开方法直接访问的情况。

为了降低代码复杂度并解耦核心逻辑和具体实现,在重构复杂长函数的过程中,我采用了以下方法,使代码能够适应微服务架构。无论外部数据获取代码如何变化,核心的信息流拼装逻辑都不会受到影响。具体方法非常类似六边形架构的思想。

代码重构的核心思想可以总结为:采用六边形架构,将核心业务逻辑与数据获取和转换方法分离,通过工厂模式和依赖倒置实现灵活的架构设计。

具体实现思路

我把这种模式称之为组装工厂,像流水线一样,初始化一个空对象,然后逐步把各种字段拼到对应位置,最后交付一个完整的对象。

1. 定义数据Provider接口,拼装工厂获取数据,通过自己定义的Provider接口函数,数据的提供者,或者控制反转的容器,负责实现Provider接口。为工厂提供数据。

type DataProvider interface {
     GetUser(ctx context.Context, userID int) (*User, error)
     GetChannel(ctx context.Context, channelID int) (*Channel, error)
     GetMessageBody(ctx context.Context, msgId string) (*Message, error)
     // 其他数据获取方法
 }

在最初的长函数中,依赖service层和dao层的大量方法调用,获取数据,Provider的实现类,将这些方法和数据拼装的核心逻辑做了桥接,实现了解耦。

2. 实现单个实体的拼装工厂类,工厂对象接收一个Provider,作为数据源,接收一个信息流的基础数据结构或者id。

拼装工厂类将复杂的拼装过程,按照逻辑拆分成一个一个小函数,通过协程,并行拼装目标实体对象的不同字段。

因为目标对象和各种中间数据,可以作为工厂的私有字段,因此可以减少函数调用时的参数传递。


type PostFactory struct {
    provider DataProvider
    post *BasePost
    target *TargetPost
    err error
}

func NewDataFactory(provider DataProvider, post *BasePost) *PostFactory {
    return &PostFactory{provider: provider, post: pose}
}

func(df *PostFactory) prepare(ctx context.Context) (*PostFactory) {
    df.target = &TargetPost{}
    return df
}

func(df *PostFactory) throw(err error) (*PostFactory) {
    df.err = err
    return df
}

func(df *PostFactory) composeUser(ctx context.Context) (*PostFactory) {
    if df.err != nil {
        return df
    }
    user, err := df.provider.GetUser(userID)
    if err != nil {
        return df.throw(err)
    }
    df.target.SetUser(user)
    // 其他数据获取和拼装逻辑
    return df
}

// 其他各种composeXXXX函数,负责拼装各种其他数据

func(df *PostFactory) Build(ctx context.Context) (*TargetPost, error) {
    err := df.prepare(ctx)
      .composeUser(ctx)
      // 其他数据获取和拼装逻辑
    if err != nil {
      return nil, err
    }
    return df.target, nil
}

在实际实现的时候,调用composeXXX的链式调用,可以结合mapreduce的多线程编程范式,将调用放在子协程中并行处理,需要注意的是,如果存在对相同字段的并发写入,要注意加锁,并且注意执行的先后顺序。

如果有必要可以由Factory或者TargetPost自己实现一系列并发安全的SetXXX方法。

3. 实现支持共享数据的Provider

Provider最基础的能力,就是通过接口,隔离拼装工厂对各种数据源的物理依赖,这样就可以达到依赖抽象而不依赖具体实现的目标,最基本的Provider实现类,就是将工厂对数据获取方法的调用,转发给各自领域的Service方法。

但是因为我们信息流的场景,往往是需要在一个列表中,拼装一个数组所包含的实体对象,因此,我们可以通过在基础Provider之上,再包装一层,对外也实现了Provider接口,自己维护了一层缓存,在缓存找不到数据的时候,调用基础Provider,从数据源重新获取数据。

type BaseProvider struct {
    us UserService
}

func (bp *BaseProvider) GetUser(ctx context.Context, userID int) (*User, error) {
  return bp.us.GetUserById(ctx, userID)
}

type CachedProvider struct {
    um sync.Map

    bp DataProvider
}

func (cp *CachedProvider) GetUser(ctx context.Context, userID int) (*User, error) {
  if u, ok := cp.um.Load(userID); ok {
    return u.(*User), nil
  }
  u, err := cp.bp.GetUserById(ctx, userID)
  if err != nil {
    return u, err
  }
  cp.Store(userID, u)
  return u, nil
}

除了使用并发安全的sync.Map之外,其实也可以用map,加上读写锁的方式,控制并发,但是在实际实现的时候,通过benchmark测试,我们发现加锁会验证的影响并发执行速度,所以采用无锁化,进程安全的sync.Map。

如果一定不想使用sync.Map的话,一定要避免一个全局锁,而是针对不同的共享数据结构,使用各自的锁,将锁分开,尽可能的避免互斥。

缓存的时候,可以采用进程内存,也可以结合redis,实现成二级缓存,需要注意的是,如果采用二级缓存的话,要使用LFU或者LRU,控制内存中缓存占用的空间大小,防止溢出。

4. 链式调用和批处理

无论是拼装工厂,还是在Provider的组合实现过程中,我个人都比较偏好采用链式调用的风格,通过内部状态,控制某一个中间处理异常之后,后面的调用就不再继续执行,只是函数的空调用。

另外在对象上,采用WithXXXX命名的一系列函数,我们可以给对象更多的能力,每个With函数,都是返回对象本身,这样所有调用都是链式的。

在我们的应用场景中,主要是结合批处理,兼顾数据查询的性能,和拼装工厂代码逻辑的简单通用。为此,我们可以对CacheProvider进行扩展,在调用之前,可以提前注入数据,也就是预加载缓存。这样,拼装数据之前,就可以利用微服务或者数据库提供的批量查询接口,提前加载一批数据,获得更好的查询性能。

func(cp *CachedProvider) WithUserList(users []*User) (*CachedProvider) {
    for _, u := range users {
        cp.Store(u.UserId, u)
    }
    return cp
}

链式调用的好处是写出来的代码更加简洁。

5.并发控制和性能

经过一系列的代码重构之后,我们成功的将原来几千行的函数,拆分成了结构更清晰,每一个函数都不大的小函数调用,而且通过接口,实现了抽象和实现的分离,极大的强化了代码的灵活性。

这种实现方式,需要大量的设计和分析,实现成本很高,所以一定不是系统中代码的常态,而是只有在非常重要,而且复杂的时候,才需要通过设计精细的结构,简化复杂度。

另外还要考虑性能的因素,毫无疑问,无论是针对service层对象上的一个ToMessage这样的简单长函数,还是Provider加Factory的组合,都是通过协程并发的加载,我们很直观就可以想到,简单的静态函数调用,比起对象的内存分配和回收,成本更低。

经过Benchmark测试,我们发现,在没有任何优化的情况下,使用Provider加Factory的方式,对象分配和回收的开销,的确是远远高于静态函数调用。

但是,我们可以通过使用Golang的sync.Pool,存储和复用Provider对象,结合并发数的控制,可以极大的改善Provider加Factory模式的内容分配和回收开销,经过实际验证,在负载越高的情况下,拥有局部缓存和内部并发机制的Provider加Factory组合,性能相比长函数,也更有优势。

这也体现出了长函数的一个缺点,就是难以阅读、维护和优化,长函数本身调用虽然简单,但是几千行的代码,调用过程中也免不了会有各种对象的分配和回收,而且因为是一大块集中的代码,很难针对某一段进行优化。

总结和回顾

总的来说,通过这样的一次重构,我们验证了通过定义抽象接口,可以解耦了代码,从而让复杂的业务逻辑的核心代码,可以达到微服务就绪,不再受限于分层的代码。

没有度量就没有优化,采用Benchmark度量性能,根据度量的指标进行优化,是性能优化的正确方式。更好地掌握了锁和并发控制还有线程安全的数据结构。

精心设计的代码结构,不但有助于理解和维护,而且通过降低局部复杂性,也有助于性能的优化和问题排查。

一次Golang单体架构中的复杂长函数的重构实践和思考icon-default.png?t=N7T8https://mp.weixin.qq.com/s/cpRZnLFJkM-LVVBTKPtfLQ一次Golang单体架构中的复杂长函数的重构实践和思考

  • 19
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值