美文网首页@IT·互联网软件开发与创新
一次Golang单体架构中的复杂长函数的重构实践和思考

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

作者: liuwill | 来源:发表于2024-07-20 16:21 被阅读0次

    在现代应用程序开发中,信息流(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的实现类,将这些方法和数据拼装的核心逻辑做了桥接,实现了解耦。

    1. 实现单个实体的拼装工厂类,工厂对象接收一个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方法。

    1. 实现支持共享数据的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单体架构中的复杂长函数的重构实践和思考

        本文链接:https://www.haomeiwen.com/subject/wawjhjtx.html