祖传屎山代码平时不优化,一重构就翻天覆地

写作背景

写背景之前先放一张网图,侵删。

有一个活跃应用包含了2个相似业务场景,所以共用了底层模型。

  1. 前期在开发过程中,强行将两波研发组正在研发的产品底层模型和能力统一。底层能力统一遇到了挺多问题,比如数据库字段适配、转换、冗余;repo 层 SQL 条件拼接用了大量 if else,导致建索引困难...等等。

  2. 一些历史原因,该应用经手了十多个研发,代码是垒了又垒,出现一个很有规律的现象,大家都是只增代码不减代码。

  3. 代码性能随着数据规模增加不断降低,靠着优化补丁缝缝补补支撑着,业务高峰期经常被运维同学拿着 SQL 光顾。

重构该项目想法不止10次,想逮着机会拉着各方大佬商讨重构事项,因为重构对业务是没有收益的,并且重构难度相当大,所以迟迟没有下定决心。

最近刚好产品需要打磨下一个版本,需要挺长时间,几个后端研发商讨要不重构吧。嗯,我想可以,于是我找上前端负责人沟通拉他入伙,找上前端之前测试已经同意了。

于是一场重构拉开序幕了。

  1. A 同学负责梳理和收敛模型、数据订正、向上提供能力。

  2. B 同学负责梳理前端接口,编排底层能力,提供原子接口给前端(最复杂,直接面向Web端业务,接口有很多特殊逻辑)。

  3. C 同学负责引擎层,和一些计算类逻辑,另外就是打打杂。

重构

大型重构耗时不说还费人力,搞不好重构完你拿不到业务结果,所以重构前你要明确收益是啥?无非就是下面几种

  1. 性能提升产品体验更好;

  2. 简化架构并提升架构扩展性(后面迭代基于重构后架构能快速开发上线);

  3. 历史债清理,历史代码可读性差维护费劲(大部分程序员看别人代码都是这样吧)。

我们是三种情况都中,下面简单总结重构的思路吧。

模型梳理和能力收敛

底层模型我认为最重要,要可靠、稳定且变动少,如果在迭代中你的模型变来变去,上层业务根本开发不了或者边开发边改,到项目收尾就是另一坨屎山。

上层业务是根据底层模型长出来的,所以一定要跟产品讨论确定最终模型,若有好的竞品参考更好了,你的设计可能会看的更远,以防过度设计,架构设计满足未来1-2年迭代即可。

模型设计需要预估数据规模,数据规模决定是否采用分库分表/分区表。如果不好预估采用简单原则先上线看看业务效果,但基础框架这些能力一定要预留好,上量后能快速开发上线。

底层模型和能力收敛了,上层业务编排对能力的复用性更高。ps:这次模型梳理我们干掉了 2 张千万级表。

数据订正

模型梳理和收敛一般会涉及数据订正,特指线上模型对应的数据割接在新模型。一般会有下面几种方式

  1. 写脚本从数据库捞数据订正数据,一般会先 select 查询到内存中,重新组装数据再 insert 新模型。数据量小场景完全可行,但数据量大是跑不动(已踩过坑,线上数据几天没跑完最后发布失败)。

  2. 写 SQL 直接操作数据表,简单的数据处理场景、数据量小场景可行,数据量大场景不可靠,容易超时并且会有数据库稳定性风险,另外订正逻辑复杂是搞不定的(已踩过坑,线上跑数据失败导致发布失败)。

  3. oplog、binlog.... 日志采集同步到消息队列(kafka、pulsar 等),启消费组消费订正数据(我最常用也是最可靠的),数据量大的场景特别爽,处理存量数据的同时还能保证增量数据同步处理。

数据订正是清理过期数据最佳时期。假若平台过期数据体量大,这部分数据不迁移新表,留在历史表中当备份就行,亦可快速恢复。ps:本次重构过期数据预估是千万级别。

API 接口

接口是你对外的门面,应该提前规划明确,不能新增需求就干一个接口,需求迭代到后期,大大小小接口加起来几十上百个维护成本是很高的。

我们一般会按照下面几个原则:

  1. 按操作分类比如:增、删、改、查是一类,只会定义4个接口上游业务方调用需传入 source 区分调用源。

  2. 接口保持简洁,不耦合非当前业务的复杂数据。比如业务上需要回显组织架构数据(员工名称、部门、员工上级等),这类数据需要业务方自行编排组织架构 byids 接口。

  3. 接口具备降级能力,不能因为接口内部编排的非重要接口、逻辑报错导致整个接口不可用。 降级指将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。

这次重构 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 非常上头)

所以我强烈建议,一旦代码不用了,应该立刻删除。若删除这部分代码后面迭代可能会使用,我建议重新开发。我列一些删除代码后的收益。

  1. 清晰度和简洁性;

  2. 减少维护成本;

  3. 减少冗余和混乱;

  4. 避免误导。

卫语句取代条件表达式

卫语是用来提前结束方法执行的结构。通常情况下,卫语句用来检查某些前置条件是否满足,如果条件不满足,则立即退出方法执行,以避免进入后续的代码块。有助于减少代码嵌套深度,增加代码的可读性和可维护性。

按照我的经验,卫语句应该有下面 2 种情况:

  1. 两个条件分支都属于正常行为;

  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
}
提炼类

一个类应该是一个明确的抽象,它的职责是单一的,只处理一些明确的职责。

提炼类一般是下面两种情况

  1. 需求是在不停变化和累加,你会这儿加一个函数,那儿加一个方法。导致某些文件或者类非常臃肿。

  2. 相似的能力,散落在不同的业务板块,涉及的开发都在重复建设,有一个需求建一个烟囱。

典型案例是项目中事件上报能力,本应该是一个通用能力集中收敛上报代码,据我梳理代码散落在多处,上报触点有 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 执行业务逻辑。在业务上这种操作还真不少,所以决定优化复用一部分代码。👇👇👇

  1. 定义 Users 切片。

  2. GetByIDs() 方法返回 Users。

  3. 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段逻辑,每段逻辑都有各自的职责。

空行是用来区分不同逻辑块的,过度空行也会影响代码阅读,如下:

引入设计模式

设计模式是被大佬们验证过的、开发经验的总结,可以帮助我们更好地组织和管理代码,并提高代码的可维护性、可读性、可扩展性和可重用性。下面链接是我最常用的设计模式,也在这次重构过程中全部用上了,有兴趣可以看看。

责任链模式GO版本「附框架讲解+详细案例」 - 掘金

深入设计模式之适配器模式GO版本「附详细案例」 - 掘金

策略模式GO版本「附详细案例」 - 掘金

工厂模式GO版本「附详细案例」 - 掘金

深入 GO 选项模式「附详细案例」 - 掘金

最后总结

  1. 如果你的项目不是外包项目(交付了就完事儿),一定要多回头看看自己写的代码,跟着版本迭代持续优化和改进,你才能进步。另外对代码一定要有洁癖。

  2. 重构是持续的过程,如果是重要项目,每个版本我们都会推进代码优化,保证代码可维护性、可扩展性、另外就是高性能。千万别堆积最后,那可是大工程到后面很多人是没有决心干这个事儿的,所以大家应该平时迭代中不断优化和完善,才可持续性。

  3. 大型重构时,一定要明确收益并且是可量化的,比如重构后 qps 提升了10%,应用消耗资源降低了...等等,你才有跟老板谈判的筹码。

相关推荐
Java程序之猿13 分钟前
微服务分布式(一、项目初始化)
分布式·微服务·架构
isolusion2 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp2 小时前
Spring-AOP
java·后端·spring·spring-aop
我是前端小学生2 小时前
Go语言中的方法和函数
go
TodoCoder3 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
小蜗牛慢慢爬行3 小时前
Hibernate、JPA、Spring DATA JPA、Hibernate 代理和架构
java·架构·hibernate
凌虚3 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心4 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
思忖小下4 小时前
梳理你的思路(从OOP到架构设计)_简介设计模式
设计模式·架构·eit
.生产的驴5 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven