您的位置:首页 > 健康 > 养生 > 一次Golang单体架构中的复杂长函数的重构实践和思考

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

2024/12/23 10:54:38 来源:https://blog.csdn.net/liuwill/article/details/140589865  浏览:    关键词:一次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 DataProviderpost *BasePosttarget *TargetPosterr 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 = errreturn 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.Mapbp 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单体架构中的复杂长函数的重构实践和思考

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com