写作背景
写背景之前先放一张网图,侵删。
有一个活跃应用包含了2个相似业务场景,所以共用了底层模型。
前期在开发过程中,强行将两波研发组正在研发的产品底层模型和能力统一。底层能力统一遇到了挺多问题,比如数据库字段适配、转换、冗余;repo 层 SQL 条件拼接用了大量 if else,导致建索引困难...等等。
一些历史原因,该应用经手了十多个研发,代码是垒了又垒,出现一个很有规律的现象,大家都是只增代码不减代码。
代码性能随着数据规模增加不断降低,靠着优化补丁缝缝补补支撑着,业务高峰期经常被运维同学拿着 SQL 光顾。
重构该项目想法不止10次,想逮着机会拉着各方大佬商讨重构事项,因为重构对业务是没有收益的,并且重构难度相当大,所以迟迟没有下定决心。
最近刚好产品需要打磨下一个版本,需要挺长时间,几个后端研发商讨要不重构吧。嗯,我想可以,于是我找上前端负责人沟通拉他入伙,找上前端之前测试已经同意了。
于是一场重构拉开序幕了。
A 同学负责梳理和收敛模型、数据订正、向上提供能力。
B 同学负责梳理前端接口,编排底层能力,提供原子接口给前端(最复杂,直接面向Web端业务,接口有很多特殊逻辑)。
C 同学负责引擎层,和一些计算类逻辑,另外就是打打杂。
重构
大型重构耗时不说还费人力,搞不好重构完你拿不到业务结果,所以重构前你要明确收益是啥?无非就是下面几种
性能提升产品体验更好;
简化架构并提升架构扩展性(后面迭代基于重构后架构能快速开发上线);
历史债清理,历史代码可读性差维护费劲(大部分程序员看别人代码都是这样吧)。
我们是三种情况都中,下面简单总结重构的思路吧。
模型梳理和能力收敛
底层模型我认为最重要,要可靠、稳定且变动少,如果在迭代中你的模型变来变去,上层业务根本开发不了或者边开发边改,到项目收尾就是另一坨屎山。
上层业务是根据底层模型长出来的,所以一定要跟产品讨论确定最终模型,若有好的竞品参考更好了,你的设计可能会看的更远,以防过度设计,架构设计满足未来1-2年迭代即可。
模型设计需要预估数据规模,数据规模决定是否采用分库分表/分区表。如果不好预估采用简单原则先上线看看业务效果,但基础框架这些能力一定要预留好,上量后能快速开发上线。
底层模型和能力收敛了,上层业务编排对能力的复用性更高。ps:这次模型梳理我们干掉了 2 张千万级表。
数据订正
模型梳理和收敛一般会涉及数据订正,特指线上模型对应的数据割接在新模型。一般会有下面几种方式
写脚本从数据库捞数据订正数据,一般会先 select 查询到内存中,重新组装数据再 insert 新模型。数据量小场景完全可行,但数据量大是跑不动(已踩过坑,线上数据几天没跑完最后发布失败)。
写 SQL 直接操作数据表,简单的数据处理场景、数据量小场景可行,数据量大场景不可靠,容易超时并且会有数据库稳定性风险,另外订正逻辑复杂是搞不定的(已踩过坑,线上跑数据失败导致发布失败)。
oplog、binlog.... 日志采集同步到消息队列(kafka、pulsar 等),启消费组消费订正数据(我最常用也是最可靠的),数据量大的场景特别爽,处理存量数据的同时还能保证增量数据同步处理。
数据订正是清理过期数据最佳时期。假若平台过期数据体量大,这部分数据不迁移新表,留在历史表中当备份就行,亦可快速恢复。ps:本次重构过期数据预估是千万级别。
API 接口
接口是你对外的门面,应该提前规划明确,不能新增需求就干一个接口,需求迭代到后期,大大小小接口加起来几十上百个维护成本是很高的。
我们一般会按照下面几个原则:
按操作分类比如:增、删、改、查是一类,只会定义4个接口上游业务方调用需传入 source 区分调用源。
接口保持简洁,不耦合非当前业务的复杂数据。比如业务上需要回显组织架构数据(员工名称、部门、员工上级等),这类数据需要业务方自行编排组织架构 byids 接口。
接口具备降级能力,不能因为接口内部编排的非重要接口、逻辑报错导致整个接口不可用。 降级指将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。
这次重构 B 同学和前端面临了巨大压力,接口多、混乱、逻辑不清晰,决定梳理业务逻辑按照上面 3 个原则重写接口。
代码重构技巧
代码重构也是本次重构重点,经过长时间迭代已经闻到了坏代码味道。怎么重构早就心中有数,很早就盘点和推演了,下面是我常用的一些重构技巧,这些技巧都是非常经典的,如果看过「重构改善既有代码设计」应该都不陌生。
内联临时变量
项目里有一些临时变量,只被简单赋值了一次。将这些临时变量的赋值语句直接嵌入到使用它们的地方,而不是创建一个新的变量来存储这个临时值。
go
func Publish() error {
// ... 省略一部分代码
err = Producer(context.TODO()).ProducerOne(&obj)
if err != nil {
return err
}
return nil
}
临时变量内联改造后👇👇👇
scss
func Publish() error {
// ... 省略一部分代码
return Producer(context.TODO()).ProducerOne(&obj)
}
魔幻数字"(Magic Number)
指代码中使用未经解释或定义的常数值,这些值通常没有命名并且没有给出其含义或用途。这样的数字使代码难理解和维护,项目里面很多魔幻数字使用。
go
func Update(ids []string, nodeID string) {
// ... 省略一部分代码
Report(context.TODO(), nodeID, "6", ids)
}
要解决魔幻数字比较简单,只需你把业务逻辑理解定义成枚举就可以了,这个数字6表示朋友圈类型,魔幻数字改造后👇👇👇
go
type TargetType int
const (
QWMoment TargetType = 6
)
func Update(ids []string, nodeID string) {
// ... 省略一部分代码
Report(context.TODO(), nodeID, QWMoment, ids)
}
删除注释、未引用代码「俗称死代码」
根据我 review 代码的经验,不少研发同学会把已注释、未引用代码保留,这部分代码是非常影响后面维护者思路的,我们在重构过程中遇到不少这类代码,来来回回找测试和研发确认为什么会保留,哪些业务常用在用?带来了不小负担。(尤其是越上层的死代码引用了一堆下层代码,比如controller 引用 service,service 再引用 repo ,若重写 repo 非常上头)
所以我强烈建议,一旦代码不用了,应该立刻删除。若删除这部分代码后面迭代可能会使用,我建议重新开发。我列一些删除代码后的收益。
清晰度和简洁性;
减少维护成本;
减少冗余和混乱;
避免误导。
卫语句取代条件表达式
卫语是用来提前结束方法执行的结构。通常情况下,卫语句用来检查某些前置条件是否满足,如果条件不满足,则立即退出方法执行,以避免进入后续的代码块。有助于减少代码嵌套深度,增加代码的可读性和可维护性。
按照我的经验,卫语句应该有下面 2 种情况:
两个条件分支都属于正常行为;
有一个条件分支是正常行为,另一个分支则是异常的情况。
我们review过的代码一般是第二种情况比较严重。
go
func Recall(exclusion constant.ExclusionType)error {
if exclusion == constant.OnlyOneExec {
if detail.TargetID == "" {
return nil
}
_, err := repo.Update(ctx,....)
if err != nil {
return xerrors.Wrapf(err, "Update")
}
}
return nil
}
调整后代码👇👇👇
go
func Recall(exclusion constant.ExclusionType)error {
if exclusion != constant.OnlyOneExec {
return nil
}
if detail.TargetID == "" {
return nil
}
_, err := repo.Update(ctx,....)
return err
}
变量改名
好的命名能让读者一目了然,变量名可以很好的解释一段代码干了什么。我发现项目里面很多字段名、类名、包名很模糊,很难理解具体的业务(包括我自己也经常命名错)。
下段代码是我整理的坏的命名
TaskCommand 是 Kafka 消费者依赖的实体,收到消息后根据 Type 和 Status 撤回数据。但你看 struct 名 跟撤回没有任何关系。
go
// TaskCommand 任务相关命令
type TaskCommand struct {
Type int8 `json:"type"` // 执行类型
Status int8 `json:"status"`
}
所以我选择把 TaskCommand 替换成跟业务更贴切的名称👇👇👇
go
type RecallDataParam struct {
Type int8 `json:"type"` // 执行类型
Status int8 `json:"status"`
}
引入参数对象
以一个对象取代一些参数,可以改善代码的可读性和维护性,尤其是在函数参数列表较长或者参数之间存在复杂关系的情况下。将一组相关的参数封装到一个对象中,将该对象作为函数的参数传递,简化函数签名并提高代码的清晰度。
在一些历史比较久的代码里过长参数真的很常见,从 controller 透传到 service 再透传到 repo 层,代码复用性也非常低。
go
type AppImpl struct {
}
func (app *AppImpl) List(tp []int, status, page, pageSize int, keyword string, domain string) ([]interface{}, error) {
// .... 省略业务逻辑
return nil, nil
}
上段代码我一般会在 controller 和 service 中间抽一个 dto 实体。👇👇👇
go
type AppImpl struct {
}
func (app *AppImpl) List(listDTO *ListDTO) ([]interface{}, error) {
// .... 省略业务逻辑
return nil, nil
}
type ListDTO struct {
tp []int
status, page, pageSize int
keyword, domain string
}
提炼类
一个类应该是一个明确的抽象,它的职责是单一的,只处理一些明确的职责。
提炼类一般是下面两种情况
需求是在不停变化和累加,你会这儿加一个函数,那儿加一个方法。导致某些文件或者类非常臃肿。
相似的能力,散落在不同的业务板块,涉及的开发都在重复建设,有一个需求建一个烟囱。
典型案例是项目中事件上报能力,本应该是一个通用能力集中收敛上报代码,据我梳理代码散落在多处,上报触点有 10 来个,每个触点都在写同样的上报代码,假设某一天上报逻辑变化必须在这 10 多处做出许多小修改。
所以我决定把上报能力收敛在一个类,将复杂逻辑封装到该类,定义有限参数露出给使用方。
上报通用能力封装在 EventTracking。👇👇👇
swift
// Tracker 埋点上报接口
type Tracker[T any] interface {
EventTracking(in T) error
}
type CMSReachDTO struct {
}
type CMSReachTracking[T any] struct {
ctx context.Context
}
func NewCMSReachTracking[T any](ctx context.Context) Tracker[*CMSReachDTO] {
return &CMSReachTracking[T]{ctx: ctx}
}
func (t *CMSReachTracking[T]) EventTracking(in *CMSReachDTO) error {
// ....逻辑省略
return nil
}
提炼超类
如果两个类在做相似的事,可以利用基本的继承/组合(GO 只有组合)机制把它们的相似之处提炼到超类。一般会把字段、方法都搬移过去。
我遇到的 case 在 entity 上会多一些,比如下面这两个 struct。
go
type Task struct {
ID string `gorm:"column:id"`
Tenant string `gorm:"column:tenant"`
SubDomain string `gorm:"column:sub_domain"`
IsDel int8 `gorm:"column:is_del;default:1" `
CreateAt int64 `gorm:"column:create_at;autoCreateTime:milli"`
UpdateAt int64 `gorm:"column:update_at;autoUpdateTime:milli"`
CreateUserID string `gorm:"column:create_uid"`
UpdateUserID string `gorm:"column:update_uid"`
// ... 省略其他字段
}
type TaskDetail struct {
ID string `gorm:"column:id"`
TargetID string `gorm:"column:target_id"`
Tenant string `gorm:"column:tenant"`
SubDomain string `gorm:"column:sub_domain"`
IsDel int8 `gorm:"column:is_del;default:1" `
CreateAt int64 `gorm:"column:create_at;autoCreateTime:milli"`
UpdateAt int64 `gorm:"column:update_at;autoUpdateTime:milli"`
CreateUserID string `gorm:"column:create_uid"`
UpdateUserID string `gorm:"column:update_uid"`
// ... 省略其他字段
}
上面这段代码他们都有共性的代码,并且我非常熟悉业务是不可能更改的,所以我会提炼一个超类。👇👇👇
go
type SuperParty struct {
Tenant string `gorm:"column:tenant"`
SubDomain string `gorm:"column:sub_domain"`
IsDel int8 `gorm:"column:is_del;default:1" `
CreateAt int64 `gorm:"column:create_at;autoCreateTime:milli"`
UpdateAt int64 `gorm:"column:update_at;autoUpdateTime:milli"`
CreateUserID string `gorm:"column:create_uid"`
UpdateUserID string `gorm:"column:update_uid"`
}
type Task struct {
ID string `gorm:"column:id"`
SuperParty
// ... 省略其他字段
}
type TaskDetail struct {
ID string `gorm:"column:id"`
TargetID string `gorm:"column:target_id"`
SuperParty
// ... 省略其他字段
}
当然某些场景还有一些配套的方法,也可以一并搬迁到 SuperParty 里面。
提炼方法/函数
提炼函数/方法是我常用的一种手段,我不喜欢长函数/方法。我看过一个说法,一个函数/方法应该能在一屏中显示,我一直奉为经典语录(我写的代码函数/方法基本不会超过一百行);另外只要有一段代码不止被用一次,我就会把他们单独放进一个函数。
有这样一个场景,调用外部 byids 查询员工信息获取 externalId 执行业务逻辑,封装外部接口调用。
go
type Client struct {
ctx context.Context
}
func (c *Client) GetByIDs(id []string) ([]*User, error) {
// ....省略业务逻辑
return []*User{}, nil
}
type User struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
}
下面是业务方使用 GetByIDs() 方法
go
func TestGetUserByIDs(t *testing.T) {
ids := []string{"1", "2"}
client := &Client{}
users, err := client.GetByIDs(ids)
if err != nil {
panic(err)
}
l := make([]string, 0, len(users))
for _, v := range users {
l = append(l, v.ExternalID)
}
// ...执行业务逻辑
}
业务方调用 GetByIDs() 方法,遍历 users 获取 ExternalID 执行业务逻辑。在业务上这种操作还真不少,所以决定优化复用一部分代码。👇👇👇
定义 Users 切片。
GetByIDs() 方法返回 Users。
Users 提供 GetExternalIDs 方法。
go
type Client struct {
ctx context.Context
}
func (c *Client) GetByIDs(id []string) (Users, error) {
// .... 省略业务逻辑
return Users{}, nil
}
type Users []*User
func (u Users) GetExternalIDs() []string {
out := make([]string, 0, len(u))
for _, v := range u {
out = append(out, v.ExternalID)
}
return out
}
type User struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
}
下面是业务方使用 GetByIDs() 方法
go
func TestGetUserByIDs(t *testing.T) {
ids := []string{"1", "2"}
client := &Client{}
users, err := client.GetByIDs(ids)
if err != nil {
panic(err)
}
l := users.GetExternalIDs()
// 执行业务逻辑
// ....
}
代码空行
我非常非常不喜欢代码从头到尾写下来没有任何空行,难以阅读让读者很难提起兴趣。空行在我看来是必不可少的,在代码中使用空行来分隔不同功能或逻辑块之间的代码,空行使得代码更易读。
下面段代码是没有任何空行的,代码比较短阅读起来可能并不费劲。
适当进行空行优化后👇👇👇
上段代码先不关注逻辑,优化后可读性更强了,代码分为3段逻辑,每段逻辑都有各自的职责。
空行是用来区分不同逻辑块的,过度空行也会影响代码阅读,如下:
引入设计模式
设计模式是被大佬们验证过的、开发经验的总结,可以帮助我们更好地组织和管理代码,并提高代码的可维护性、可读性、可扩展性和可重用性。下面链接是我最常用的设计模式,也在这次重构过程中全部用上了,有兴趣可以看看。
最后总结
如果你的项目不是外包项目(交付了就完事儿),一定要多回头看看自己写的代码,跟着版本迭代持续优化和改进,你才能进步。另外对代码一定要有洁癖。
重构是持续的过程,如果是重要项目,每个版本我们都会推进代码优化,保证代码可维护性、可扩展性、另外就是高性能。千万别堆积最后,那可是大工程到后面很多人是没有决心干这个事儿的,所以大家应该平时迭代中不断优化和完善,才可持续性。
大型重构时,一定要明确收益并且是可量化的,比如重构后 qps 提升了10%,应用消耗资源降低了...等等,你才有跟老板谈判的筹码。