Go 代码整洁之道

痛点:

  1. 工程刚开始非常整洁,随着时间的流逝,逐渐变得不太好维护了..
  2. 多人开发同一工程时,架构层次不清晰,重复造轮子?
  3. 接手了一个旧工程,如何快速理解架构与设计,从而快速上手做需求?

有规范的好处:

  1. 利于多人合作开发&理解同一模块/工程。
  2. 降低团队成员之间的代码沟通成本。
  3. 架构&代码规范明确,有效提高编码效率。

前言:

读这本书的时,第一个想到的问题就是:“什么是整洁的代码?”
书中列举了各位程序员鼻祖的名言,我整理总结了下,大概有下面几条:

  • 逻辑直截了当,令缺陷难以隐藏 。
  • 减少依赖关系,便于维护。
  • 合理分层,完善错误处理 。
  • 只做好一件事。没有重复代码。

代码是团队沟通的一种方式

工作的沟通,不只是每天lark拉群或者开会交流,代码也是我们很重要的沟通方式之一。

用代码写完需求,只是万里长征的第一步。我们要用代码表达自己的设计思想。如果我们团队大部分人都能按照一定规范、思路去写代码。那么,工作沟通成本会降低许多。
比如:某位同学之前负责的一个模块,被另一位同事接手了,或者随着业务的扩张,我们多个同学共同开发同一个工程/模块。如果我们的代码结构大同小异,分层清晰、注释合理,就会降低很多沟通成本。

因此,我们需要为团队创造整洁的代码。

一是降低团队内的代码沟通成本,二是便于今后项目需求的维护与迭代。

让营地比来时更整洁

随着需求的不断迭代,保持代码整洁、工程更易理解。

有时候,我们会维护一些老项目,或者交接过来的项目。代码可能不太美观,工程可能不太好理解。

一般我们会面临两种选择:

  1. 重构
  2. 优化迭代

重构的成本比较高,得先理解原有逻辑,再进行重新设计落地。代价大,周期长,短期看不到效果。

在人力有限的情况下。我们一般会先选择“优化迭代”。

这时候,我们每做一个新需求 / 修复一个bug时,我们要尽可能的去小范围“重构”。

每一次Merge,代码都比之前更干净,工程变得更好理解。那么,我们的工程就不会变的更糟。

清理不一定要花多少功夫。也许只是改一个更加容易理解的命名;抽象一个函数,消除一点重复/冗余代码;处理一下嵌套的 if / else 等等。

一、有意义的命名

名副其实:
起有意义的名字,让人一目了然。
一看这个变量,就能知道它存储的是什么对象。
一看这个方法,就能知道它处理的是什么事。
一看这个包名,就能知道它负责处理哪个模块。

看看反例:

var array []int64
var theList []int64
var num int64

看看正例:

var mrList []*MRInfo
var buildNum int64

避免误导:
不要用太长或者很偏僻的单词来命名,也不要用拼音代替英文。
更不要用容易混淆的字母(字母+数字)。尤其是lO两个字母,和数字1和0太像了。

看看反例:

func getDiZhi() string {
   // ..
}

func modifyPassword(password1, password2 string) string {
   // ..
}

看看正例:

func getAddress() string {
   // ..
}

func modifyPassword(oldPassword, newPassword string) string {
   // ..
}

有意义的区分:
声明两个同类型的变量/函数,需要用有明确意义的命名加以区分。

看看反例:

var accountData []*Account
var account []*Account

func Account(id int) *Account {
    // ...
}

func AccountData(id int) *Account {
    // ...
}

可读可搜索:
起可读的,可以被搜索的名字。

看看反例:

var ymdhms = "2021-08-04 01:55:55"
var a = 1

看看正例:

var date = "2021-08-04 01:55:55"
var buildNum = 1

命名规范(重点)

package

  • 同一项目下,不允许出现同名的package。
  • 只由小写字母组成。不包含大写字母和下划线等字符。
  • 简短并包含一定的上下文信息。例如timehttp等。
  • 不能是含义模糊的常用名,或者与标准库同名。例如不能使用util或者strings
  • 包名能够作为路径的 base name,在一些必要的时候,需要把不同功能拆分为子包。(例如应该使用encoding/base64而不是encoding_base64或者encodingbase64。)

以下规则按照先后顺序尽量满足

  • 不使用常用变量名作为包名。
  • 使用单数而不是复数。(关键字除外,例如consts
  • 谨慎地使用缩写,保证理解。

文件名

  • 文件名都使用小写字母,且使用单数形式,如需要可使用下划线分割。

函数和方法

Function 的命名应该遵循如下原则:

  • 对于可导出的函数使用大写字母开头,对于内部使用的函数使用小写字母开头。
  • 若函数或方法为判断类型(返回值主要为 bool 类型),则名称应以 has, is, can 等判断性动词开头。
// HasPrefix tests whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {...
  • 函数采用驼峰命名,不能使用下划线,不能重复包名前缀。例如使用http.Server而不是http.HTTPServer,因为包名和函数名总是成对出现的。
// WriteRune appends the UTF-8 encoding of Unicode code point r to b's buffer.
// It returns the length of r and a nil error.
func (b *Builder) WriteRune(r rune) (int, error) {...
  • 遵守简单的原则,不应该像 ToString 这类的方法名,而直接使用 String 代替。
// String returns the accumulated string.
func (b *Builder) String() string {...
  • Receiver 要尽量简短并有意义
    • 不要使用面向对象编程中的常用名。例如不要使用selfthisme等。
    • 一般使用 1 到 2 个字母的缩写代表其原来的类型。例如类型为Client,可以使用ccl等。
    • 在每个此类型的方法中使用统一的缩写。例如在其中一个方法中使用了c代表了Client,在其他的方法中也要使用c而不能使用诸如cl的命名。
func (r *Reader) Len() int {...

常量

  • 常量使用驼峰形式。(尽量不要用下划线)
const AppVersion = "1.1.1"
  • 如果是枚举类型的常量,需要先创建相应类型:
type Scheme string 

 const ( 
    HTTP  Scheme = "http" 
    HTTPS Scheme = "https" 
 )

变量

  • 变量命名基本上遵循相应的英文表达或简写。
  • 采用驼峰命名,不能使用下划线。首字母是否大写根据是否需要外部访问来定。
  • 遇到专有名词时,可以不改变原来的写法。例如:
{ 
    "API":   true, 
    "ASCII": true, 
    "CPU":   true, 
    "CSS":   true, 
    "DNS":   true, 
    "EOF":   true, 
    "GUID":  true, 
    "HTML":  true, 
    "HTTP":  true, 
    "HTTPS": true, 
    "ID":    true, 
    "IP":    true, 
    "JSON":  true, 
    "LHS":   true, 
    "QPS":   true, 
    "RAM":   true, 
    "RHS":   true, 
    "RPC":   true, 
    "SLA":   true, 
    "SMTP":  true, 
    "SSH":   true, 
    "TLS":   true, 
    "TTL":   true, 
    "UI":    true, 
    "UID":   true, 
    "UUID":  true, 
    "URI":   true, 
    "URL":   true, 
    "UTF8":  true, 
    "VM":    true, 
    "XML":   true, 
    "XSRF":  true, 
    "XSS":   true, 
}

二、函数

短小

尽可能的缩短每个函数的长度。能抽象就抽象。
任何一个函数都不应该超过50行。甚至,20行封顶最佳。(PS:16寸mac满屏是60多行)
想象下,如果有个几百行,甚至上千行的函数。后面维护得多困难。

单参数

每个函数最理想应该是有0或1个入参。
尽量不要超过三个入参。如果超过,建议封装成结构体。

只做一件事

函数应该只做一件事,做好这件事,只做这一件事。

抽象层级

按顺序,自顶向下读代码/写代码。

看看反例:

// 更新组件升级结果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
   // 更新组件核心表,写了20行

   // 更新历史,写了40行

   // 更新构建产物,写了20行

   // ...代码越来越多,越来越不好维护。

   return nil
}

看看正例:

// 更新组件升级结果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
   // 更新组件
   err = updatePodMain(ctx, req)
   if err != nil {
      return err
   }

   // 更新历史
   err = updatePodHistory(ctx, req)
   if err != nil {
      return err
   }

   // 更新Builds
   err = updatePodBuilds(ctx, req)
   if err != nil {
      return err
   }

   return nil
}

func updatePodMain(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

func updatePodHistory(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

func updatePodBuilds(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

尽量少嵌套 if / else

看看反例:

func GetItem(extension string) (Item, error) {
    if refIface, ok := db.ReferenceCache.Get(extension); ok {
        if ref, ok := refIface.(string); ok {
            if itemIface, ok := db.ItemCache.Get(ref); ok {
                if item, ok := itemIface.(Item); ok {
                    if item.Active {
                        return Item, nil
                    } else {
                      return EmptyItem, errors.New("no active item found in cache")
                    }
                } else {
                  return EmptyItem, errors.New("could not cast cache interface to Item")
                }
            } else {
              return EmptyItem, errors.New("extension was not found in cache reference")
            }
        } else {
          return EmptyItem, errors.New("could not cast cache reference interface to Item")
        }
    }
    return EmptyItem, errors.New("reference not found in cache")
}

看看正例:

func GetItem(extension string) (Item, error) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, errors.New("reference not found in cache")
    }

    ref, ok := refIface.(string)
    if !ok {
        // return cast error on reference 
    }

    itemIface, ok := db.ItemCache.Get(ref)
    if !ok {
        // return no item found in cache by reference
    }

    item, ok := itemIface.(Item)
    if !ok {
        // return cast error on item interface
    }

    if !item.Active {
        // return no item active
    }

    return Item, nil
}

安全并发处理(SafeGo)

建议:开协程的地方,尽量使用SafeGo(内部有 recover 以及打印 panic 堆栈日志)

func SafeGo(ctx context.Context, f func()) {
   go func() {
      defer func() {
         if err := recover(); err != nil {
            content := fmt.Sprintf("Safe Go Capture Panic In Go Groutine\n%s", string(debug.Stack())){
               logs.CtxFatal(ctx, content)
            }
         }
      }()

      f()
   }()
}

For 循环并发处理(Routine Pool)

for 循环开协程时,优先考虑使用封装的 Routine Pool(协程池)控制并发量。

好处:

  1. 避免协程创建过多,导致程序崩溃。(对服务本身)
  2. 控制流量速度,防止把下游服务打雪崩。(对下游服务)

参考代码:

type content struct {
    work func() error
    end  *struct{}
}

func work(w func() error) content {
    return content{work: w}
}

func end() content {
    return content{end: &struct{}{}}
}

// Goroutine routine_pool
type RoutinePool struct {
    capacity uint
    ch       chan content
}

func NewRoutinePool(ctx context.Context, capacity uint) *RoutinePool {
    ch := make(chan content)
    pool := RoutinePool{
        capacity: capacity,
        ch:       ch,
    }

    for i := uint(0); i < capacity; i++ {
        SafeGo(ctx, func() {
            for {
                select {
                case cont := <-ch:
                    if cont.end != nil {
                        return
                    }

                    if cont.work != nil {
                        if err := cont.work(); err != nil {
                            LogCtxError(ctx, "run work failed: %v", err)
                        }
                    }
                }
            }
        })
    }

    return &pool
}

func (pool *RoutinePool) Submit(w func() error) {
    pool.ch <- work(w)
}

func (pool *RoutinePool) Shutdown() {
    defer close(pool.ch)
    for i := uint(0); i < pool.capacity; i++ {
        pool.ch <- end()
    }
}

Copy 传入协程的 Context

Gin:直接调用context.Copy()即可。

三、注释与格式

注释

  • 所有可导出的函数、类型、变量等都应该有注释,注释以函数名、类型名、变量名打头,函数注释建议同时包含参数和返回值的说明。
  • 每行注释不超过100个字符。
  • 包、函数、方法和类型的注释说明都是一个完整的句子。
  • 有具体方案文档,在对应地方留下文档链接注释。便于后续快速了解这部分需求。

格式

这部分只要我们打开 Goland 相关配置,即可完成。

推荐配置

File Watcher 开启 go fmt、go imports:

image

配置可以参考:https://www.jetbrains.com/help/go/using-file-watchers.html#enableFileWatcher

垂直格式:

每个文件从上到下的代码规范。

一个文件,尽量不要超过 400 行。(超过可读性会降低)

  1. 垂直方向的间隔

package声明、导入声明和每个函数之间都要有一个空行隔开。

  1. 垂直方向的靠近:

靠的越近的代码,关系越紧密。

  1. 垂直距离:

变量声明:尽可能靠近其使用的位置。
局部变量,声明在函数顶部。
实体变量,声明在类的顶部。

相关函数:尽节能互相靠近,保证顺序。

首先,应该放到一起。
其次,“调用”函数应该放到“被调用”函数的上面。

概念相关:做某类事情的函数,应该放一起。

比如,一个 interface,它有 read/write 方法,他们应该放一起

  1. 垂直顺序:

“调用”函数应该放到“被调用”函数的上面。
建立了一种自顶向下贯穿源代码的良好信息流。

横向格式:

每一行代码从左到右的代码规范。

每一行代码,尽量不要超过 120 个字。(超过150字,一个屏幕就看不全了)

  1. 水平方向的间隔与靠近

操作符周围加上空格。

  1. 水平对齐
type PodType string

const (
   PodTypeIOS      PodType = "iOS"
   PodTypeAndroid  PodType = "Android"
   PodTypeFlutter  PodType = "Flutter"
)
  1. 缩进

这部分 go-fmt 帮我们做了,只要集成 go-fmt 即可。

四、对象与数据结构

数据抽象成对象

以组件升级为例,将组件升级流程抽象成对象。不关心底层的数据结构与实现。

分析,组件升级流程需要:

  • ValidateParam(校验参数)
  • FormatParam(格式化参数)
  • SendUpgradeRequest(触发升级)
  • GenerateHistory(生成历史)
  • UpdateHistory(更新历史)
type mpaasRepoUpgradeHandlerType interface {
   ValidateParam(ctx context.Context) error                                                   //判断某个升级请求,是否合法
   FormatUpgradeParam(ctx context.Context) error                                              //处理参数,补充额外信息或者补上默认信息等等
   SendUpgradeRequest(ctx context.Context, history *podHistory) (int, error) //各 Handler 自行发送升级请求
   UpgradeHistory(ctx context.Context) *podHistory                           //生成升级历史
   UpdateHistoryInfo(ctx context.Context) *podHistory                        //重试的时候要更新的组件升级历史字段
   baseHandler() *podUpgradeBaseHandler                                                 //获取 baseHandler
}

组件升级会分为多种:iOSAndroidFlutterCustom(构建脚本)、RubyGem等等..

不论哪种组件升级只要实现这套 interface,即可完成组件升级流程。

数据 vs. 对象

对象:把数据隐藏于抽象之后,暴露操作数据的方法。

数据:通过数据结构暴露处理。

面向过程(直接使用数据结构):
好处:在不改动既有数据结构的前提下,新增新函数。
坏处:难以增删改数据结构。

面向对象(抽象):
好处:方便增删改数据结构。
坏处:难以新增函数,必须所有类改。

两者没有绝对的优劣比较,需要 case by case 在具体场景下的应用。

得墨忒(tuī)耳律
模块不应该了解它所操作对象的内部结构。
对象需要隐藏数据,暴露操作。

五、错误处理

常规流程

  • 先看看反例:
package smelly
func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return Item{}, errors.New("item could not be found in the store") 
    }
    return item, nil
}

handler里如果要对特殊错误做特殊处理:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := smelly.GetItem("123")
    if err != nil {
        if err.Error() == "item could not be found in the store" {
            http.Error(w, err.Error(), http.StatusNotFound)
                return
        }
        http.Error(w, errr.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

  • 再看看正例:

提前在包里,定义好错误类型。

package clean

var (
    ErrItemNotFound = errors.New("item could not be found in the store") 
)

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return nil, ErrItemNotFound
    }
    return item, nil
}

handler里如果要对特殊错误做特殊处理:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := clean.GetItem("123")
    if err != nil {
        if errors.Is(err, clean.ErrItemNotFound) {
           http.Error(w, err.Error(), http.StatusNotFound)
                return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

好处:方便拓展,增加代码可读性。

六、边界

我们的系统都微服务化了。

每个子服务都会存在自己的边界。

我们需要尽量保证我们的服务边界整洁。

边界整洁

我们依赖的服务、库、代码是要可控的。
假如,我们依赖了一个不可控的库。
如果他有一天被检测出有安全问题、亦或 bug。
我们就很被动,导致服务需要大改。

简单来说,依赖我们能控制的东西,好过依赖我们控制不了的东西。
免得日后被控制,导致重写或修改。

层级架构明确

属于同一层的服务,最好只依赖下层服务。
理论上来说,不该依赖同层服务,更不应该依赖上层服务。

每个团队/业务的架构图应该要梳理出来。

模块职责明确

其实,不光服务于服务之间要有层级架构。
我们服务内部应该也需要按照层级来写代码。
另外,每个工程的 ReadMe,最好能阐述下大概设计思路和架构,便于协作开发。

参考资料:

clean-go-article

你可能感兴趣的:(Go 代码整洁之道)