在现代应用程序开发中,信息流(Feed)是许多平台核心功能的一部分。信息流往往会聚合大量的数据,构建这样一个信息流列表是一个复杂的任务。需要从多个微服务和数据库中获取大量数据,包括用户、频道、标签、等级、用户状态和互动等,并进行过滤、转换和计算,最终拼装成目标数据结构。在这个过程中,性能和代码设计的合理性尤为重要。
难题:一个超过1000行的长函数
在互联网服务端代码中,分层架构是一种常见的业务逻辑组织方式。我们通常根据实体,将每一层分为以实体名称命名的文件和无状态对象,每个对象包含数据获取、业务逻辑处理和数据库读写的方法。
通常情况下,大多数方法都是内聚的,只与单个实体相关,代码行数较少。但类似于微服务中的聚合根,无论数据结构如何设计组织,服务端接口最终需要根据产品和业务的需求,在前后端的交界处,为了满足UI展示的需求,将数据聚合起来。无论架构层面如何治理和拆分,为满足业务需求,接口层必然需要将数据按照使用需求进行组合。
信息流产品层面正是这样的场景。信息流列表中会聚合各种不同类型的信息和广告,以及信息作者的状态、互动数据和统计信息。为了满足这些需求,需要从各种数据源或服务中获取数据,并根据不同领域逻辑进行转换和组合,最终拼装成目标数据结构。在考虑性能的情况下,这些拼装数据的函数往往变得非常庞大。
在我参与的一个项目中,就遇到过一个信息流列表对象拼装函数。由于处理复杂数据,该函数长度超过1000行,甚至接近2000行,维护起来非常痛苦。
抽象和组合:拆分复杂函数
大多数小型业务探索驱动的项目,由于开发时间短、变化快,往往不会在代码设计上花费太多时间。开发人员也不愿意在调用业务逻辑函数时进行复杂的组装,因此很容易出现Service中包含一个长函数,并通过包的公开方法直接访问的情况。
为了降低代码复杂度并解耦核心逻辑和具体实现,在重构复杂长函数的过程中,我采用了以下方法,使代码能够适应微服务架构。无论外部数据获取代码如何变化,核心的信息流拼装逻辑都不会受到影响。具体方法非常类似六边形架构的思想。
代码重构的核心思想可以总结为:采用六边形架构,将核心业务逻辑与数据获取和转换方法分离,通过工厂模式和依赖倒置实现灵活的架构设计。
具体实现思路
我把这种模式称之为组装工厂,像流水线一样,初始化一个空对象,然后逐步把各种字段拼到对应位置,最后交付一个完整的对象。
- 定义数据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的实现类,将这些方法和数据拼装的核心逻辑做了桥接,实现了解耦。
- 实现单个实体的拼装工厂类,工厂对象接收一个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方法。
- 实现支持共享数据的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度量性能,根据度量的指标进行优化,是性能优化的正确方式。更好地掌握了锁和并发控制还有线程安全的数据结构。
精心设计的代码结构,不但有助于理解和维护,而且通过降低局部复杂性,也有助于性能的优化和问题排查。
网友评论