Go 代码整洁之道

2021-08-21  本文已影响0人  齐舞647

痛点:

  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

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

文件名

函数和方法

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

// HasPrefix tests whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {...
// 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) {...
// String returns the accumulated string.
func (b *Builder) String() string {...
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()即可。

三、注释与格式

注释

格式

这部分只要我们打开 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 即可。

四、对象与数据结构

数据抽象成对象

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

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

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

上一篇 下一篇

猜你喜欢

热点阅读