前言
最近工作较忙,总是少了总结。每当急急匆匆的做完需求之后,在回头看代码,尤其是把代码给其他人看的时候,就会发现自己写的代码像一坨…。本篇小记总结一下代码风格,完全属于自己的理解,不会像编程书上罗列一些什么什么原则,让人读了又容易忘记。文章会举一些实际的例子,加以说明。
一、编程习惯
结构体的成员变量vs传参?
我有这样一个方法,需要先根据index(索引)去查询学生信息,如果索引查询失败,则根据offset去查询。
方式一:成员变量
在对象里添加一个成员变量retryOffset来表示是否使用offset方式进行重试。
1.初始调用 Run 方法时:
- 因为 retryOffset 默认为 false,checkDataByIndex 方法会按照 index(索引)方式 进行查询。
2.在 checkDataByIndex 方法中:
- 如果索引查询失败(QueryDataByIndexOrOffset 返回错误),会检查当前查询方式是否是 index 方式。
- 如果是 index 方式,会将 retryOffset 设置为 true,表示需要切换到 offset(偏移量)方式 并重新尝试。
- 此时 checkDataByIndex 返回 nil,表明已经切换到 offset 方式,但未立即抛出错误。
3.在 Run 方法中:
- 如果第一次调用 checkDataByIndex 返回 nil,且 retryOffset 被设置为 true,则说明需要以 offset方式 再次重试。
- Run 会再次调用 checkDataByIndex,这次 QueryDataByIndexOrOffset 会按照 offset 方式查询。
- 如果这次查询仍然失败,则会返回错误。
type struct Student{
...
retryOffset bool
---
}
func (p *Student) Run(ctx context.Context) error {
err := p.checkDataByIndex(ctx)
if err != nil {
return err
}
if p.retryOffset { //根据p.retryOffset来判断是否重试
err = p.checkDataByIndex(ctx)
}
return err
}
func (p *Student) checkDataByIndex(ctx context.Context) error{
...
err:=p.QueryDataByIndexOrOffset(ctx,p.retryOffset)
if err!=nil{
if !p.retryOffset{ //如果之前不是offset的方式,则这里进行offset尝试
p.retryOffset=true //设置为true
return nil
}
return err
}
...
return nil
}
方式二:通过参数去传递
- 第一次调用Run方法,checkDataByIndex方法中useOffset设置为false,QueryDataByIndexOrOffset默认会走index方式去查询。
- 如果index方式报错,则判断当前操作是否是index方式(即入参useOffset是否为false),如果是,则return true,nil。‘
- 如果checkDataByIndex返回的参数为true,nil则进行offset方式重试,checkDataByIndex的参数设置为true,如果仍然失败,则抛错误。
type struct Student{
...
}
func (p *Student) Run(ctx context.Context) error {
isRetry, err := p.checkDataByIndex(ctx, false)
if err != nil {
return err
}
if isRetry {
_, err = p.checkDataByIndex(ctx, true)
}
return err
}
func (p *Student) checkDataByIndex(ctx context.Context, useOffset bool) (bool, error) {
...
err:=p.QueryDataByIndexOrOffset(ctx, useOffset)
if err!=nil{
if !useOffset { //如果之前不是offset的方式,则这里进行offset尝试
return true,nil
}
return false,err
}
...
return false, nil
}
这两种方式那种更优雅呢?实现的效果是一样的,我最开始按照自己的编码习惯,用的是方式一,即往对象里塞字段。通过控制对象里的字段里选择不同的方式,我给出的理由是往字段里塞对象,这样函数的入参和出参就不需要额外的参数,好像操作起来更方便。
直到大佬rw我的代码之后,问了我两个问题。
- 这个对象跟这个参数有什么关系?是强依赖的吗?如果这个对象每增加一个方法,这个方法有两种执行模式,那是不是就又得往里面堆一个字段?
- 这里调用了两次checkDataByIndex方法,但是这两个checkDataByIndex方法的入参完全一样,如果我不点进去看checkDataByIndex方法里的具体逻辑我完全不知道这一段代码在干嘛,我会感到很奇怪。
err := p.checkDataByIndex(ctx)
if err != nil {
return err
}
if p.retryOffset { //根据p.retryOffset来判断是否重试
err = p.checkDataByIndex(ctx)
}
return err
突然感觉醍醐灌顶,以前只为自己写的爽,完全没有在于代码的可读性,并且总喜欢往对象里面堆字段,这样字段和对象就耦合在一起了,导致你的功能越来越多,为每一个功能设置的字段就越来越多,可能过了一段时间,自己在看代码时,就会发现自己都要捋半天。
单一职能原则
定义:每个函数或方法应该只完成一个明确的功能,并且这个功能应该是可以清楚描述的。如果一个函数承担了多个职责或功能,那么当某一个职责发生变化时,会影响到其他职责,从而导致代码难以维护。
反例
假设你有一个函数,用来处理用户注册,同时完成以下多个职责:
- 校验用户输入。
- 将用户信息保存到数据库。
- 发送欢迎邮件。
func RegisterUser(username, email, password string) error {
// 校验用户输入
if username == "" || email == "" || password == "" {
return fmt.Errorf("invalid input")
}
// 将用户信息保存到数据库
1.先查select
.....
2.如果存在,则报已存在。
....
3.如果不存在,则插入。
....
if err != nil {
return fmt.Errorf("failed to save user: %v", err)
}
// 发送欢迎邮件
1.找到用户绑定的邮箱
2.封装请求头等信息
3.发送邮件
if err != nil {
return fmt.Errorf("failed to send email: %v", err)
}
return nil
}
问题:
- 职责不清:这个函数同时负责校验输入、保存数据库和发送邮件,违反了单一职能原则。
- 不易扩展:如果未来需要修改某一个职责(例如邮件服务更换或数据库逻辑变化),很可能需要修改整个函数。
- 难以测试:无法单独测试某一职责(例如,只测试数据库存储逻辑或邮件发送逻辑)。
- 低复用性:其他场景可能只需要部分功能,但必须重复执行所有逻辑。
遵循单一原则,把主函数进行拆分
将 RegisterUser 函数拆分成多个小函数,每个函数只专注于一个职责。
func validateUserInput(username, email, password string) error {
if username == "" || email == "" || password == "" {
return fmt.Errorf("invalid input")
}
return nil
}
func saveToDatabase(username, email, password string) error {
// 模拟将用户保存到数据库的逻辑
fmt.Println("User saved to database:", username)
return nil
}
func sendWelcomeEmail(email string) error {
// 模拟发送欢迎邮件的逻辑
fmt.Println("Welcome email sent to:", email)
return nil
}
func RegisterUser(username, email, password string) error {
// 校验用户输入
if err := validateUserInput(username, email, password); err != nil {
return err
}
// 将用户信息保存到数据库
if err := saveToDatabase(username, email, password); err != nil {
return err
}
// 发送欢迎邮件
if err := sendWelcomeEmail(email); err != nil {
return err
}
return nil
}
优点:
- 职责明确:validateUserInput 负责校验输入。saveToDatabase 负责数据库存储逻辑。sendWelcomeEmail 负责发送邮件。 每个函数有单一的职责,清晰且易于理解。更易维护:如果将来需要修改某个职责(如邮件发送方式),只需修改相关函数即可,不会影响其他逻辑。
- 提高测试性:每个职责都可以单独测试:测试 validateUserInput 时,只需关注校验逻辑。测试saveToDatabase 时,可以单独验证数据库逻辑是否正确。
- 代码复用性高:如果其他功能需要校验用户输入,可以直接复用 validateUserInput。如果需要单独保存用户,也可以直接调用 saveToDatabase。
- 更易扩展:如果需要在注册流程中添加新的步骤(如发送短信验证码),可以轻松插入新的职责,而不会干扰其他部分。
单一职能原则带来的好处总结
- 提高代码可读性:每个函数只负责一件事,逻辑清晰,容易理解。
- 简化调试和测试:分离职责后,可以单独测试和调试每个职责。 降低代码耦合:修改一个功能时,不会影响其他功能,减少风险。
- 提高代码复用性:职责单一的函数更容易在其他地方复用。
- 便于扩展:新增功能时,只需增加新的职责函数,而无需修改现有逻辑。
编码原则其实有很多项,这里把单一原则单独拿出来讲,主要是我感觉这是我们平常写代码最应该注意且最重要的地方。不然有一个rw你的代码人,就会看到你又长又臭的代码,很头疼。并且也不利用你维护和单侧。
二、如何设计代码?
一般拿到任务之后,就是一个对象,然后在给这个对象添加一些方法。然后对象里面套对象。如何去整体的设计代码,这是一个长期的积累过程,现在就用一个例子来浅谈一下。
背景是开发一个数据迁移的工具,即从一个数据库迁移到另一个数据库。
第一步需要思考,我们应该设计那些对象呢?并如何把这些对象统一起来?
需求分析:
1.迁移对象:负责完成数据库到数据库的迁移
2.校验对象:负责迁移完成之后,数据对比。
3.清理对象:负责清理目标库,即迁移前要保证目标库没有数据。
4.创建对象:负责创建目标库和目标表
我们知道数据库迁移过程中,我们以表为最小单位进行操作是没有如何问题的,表与表之间的数据是相隔里的。上述每一个对象有一个共同的特点就是都依赖于真正的数据库连接对象。
1.如何联系不同的对象呢?那就是通过一个抽象的接口,不同的对象在去实现这个接口。
type Task interface {
Execute(ctx context.Context) error
}
2.定义一个队列,该队列控制并发执行的表的个数。
type TaskQueue struct {
tasks chan Task // 待执行的任务
stats []*DBStats // 统计信息,以数据库为单位
mutex sync.Mutex
}
func NewTaskQueue(workerCount int) *TaskQueue {
queue := &TaskQueue{
tasks: make(chan Task, 1),
}
for i := 0; i < workerCount; i++ {
go queue.Worker()
}
return queue
}
func (q *TaskQueue) Worker() {
for {
task := <-q.tasks
err := task.Execute(context.Background())
if err != nil {
panic("task execution failed")
}
}
}
3.DBStats 统计信息,在任务执行过程中,我们需要从控制台时时读取进度。所以统计信息需要做成异步的。
type DBStats struct {
dbName string // 数据库名称
tablesDone int64 // 当前已完成表的个数
totalTables int64 // 数据库总表的个数
startTime time.Time // 开始时间
endTime *time.Time // 结束时间
waitGroup *sync.WaitGroup // 等待控制,用于等待所有表操作完成
totalRecords int64 // 数据库的总数据量
progress int64 // 当前已处理的数据量,进度
tableStats sync.Map // 存储该数据库每张表的已处理数据量
}
以上结构体和接口设计完成之后,剩下的就是填充内容了。我们根据不同的功能去定义不同的结构体(对象),实现不同的Execute方法。对每一个表创建一个对象,然后把对象扔到这个队列中,在不同的表对象执行过程中,每一次的查询结束之后(一张表的数据我们一般都是分多次的批量查询),我们可以对DBStats 对象修改数据,只要启动一个go show(),我们就可以时时的在控制台打印当前的进度。
总结
本篇属于工作之余的一篇杂谈。在完成任务与需求时,也要回过头多多总结自己的代码质量与风格,慢慢养成好的习惯和总体设计与抽象的思维。感谢你的关注,希望我们在工作求知的路上多多分享经验,如有不正之处,欢迎指正。