努力写“好”代码

代码规范与质量

本文是关于代码规范和代码质量相关的主题。

随着我们写的代码越来越多,技术债务也就随之升高。什么是技术债务呢?通俗来讲就是我们在软件开发的过程中,为了达到当前的目标,写一些只对当前工作有效的代码。在项目紧急的时候,这么做无可厚非,只要我们在事后能够及时的偿还这些“技术债务”就行了。在事后能够及时偿还“技术债务”,这是理想情况,现实情况更多的是在代码上写上“TODO:这个我们有时间再优化”,而众所周知,TODO is NOT TODO。“出来混迟早要还的”,“技术债”也是这样。

随着项目一个接着一个,似乎每个项目又都是紧急的,“技术债”越积越高,当初承诺要优化的似乎永远没有时间去优化。而我们在项目紧急的时候写的那些“又不是不能用”的代码,好像也变得很难理解了。想再在上面写这些勉强可用的代码也变得困难了,结果造成的情况就是“前方产品故障频发,后方开发人员不停地扑火”。

影响产品质量的因素有很多,这里只讨论代码质量与产品质量的关系,应该怎样去提高代码质量。本文将从以下三个方面来讨论:

  • 代码风格与规范
  • 代码重构的方式
  • 单元测试

代码风格与规范

首先是为人编写程序,其次才是计算机。

代码风格无非就是那些不写也没关系,不会影响正常功能的东西,譬如命名、注释、函数名等。但是风格良好的代码,可读性就更好,“挨揍”和“挨骂”的可能性就更少,而这些“揍”你和“骂”你的也许就是半年后的自己。

可读性基本定理

代码的写法应当使别人(可能是6个月后的自己)理解它所需的时间最小化。要经常地想一想其他人是不是会觉得你的代码容易理解,这需要额外的时间。这样做就需要你打开大脑中从前在编码时可能没有打开的那部分功能。

命名

一个好的、有意义的命名能够传递足够多的信息,达到替代冗余注释的效果,只通过名字就可以获得大量的信息。

  • 名字要有意义

    在为函数或者变量去名字的时候,想一想这个名字需不需要注释来补充说明,如果需要的话,说明取的名字不够好。

    var d int32 // 文件创建时间,天
    var daysSinceCreation int32
    

    以上代码中,第一行中的变量d就没有第二行的daysSinceCreation更有意义。

  • 避免空泛的名字

    比如tmp,ret,foo,bar等无实际信息的名字。当然,在作用域小,只是临时存储一下中间变量除外。

    tmp := user.Name()
    tmp += " " + user.Tel()
    tmp += " " + user.Score()
    

    以上代码,userInfo就比tmp变量可读性更好。

  • 使用更专业的名词,更有表现力

    func GetPage() 
    func DownloadPage() 
    

    比如有个函数,作用是从网络上获取页面信息,用GetPage容易造成疑惑,不清楚是从数据库还是从网络上获取数据,使用DownloadPage就更专业,更能体现这个函数的功能。

  • 用具体的名字代替抽象的名字

    在给变量、函数或者其他元素命名时,要把它描述得更具体而不是更抽象。

    func ServerCanStart() 
    func CanListenOnPort()    // 更好
    

    以上代码中CanListenOnPort比ServerCanStart`更具体。

    代码中硬编码的数字也更抽象,用一个便于搜索的变量替代。

  • 名字可以带上其他重要信息

    比如,在值为毫秒的变量后面加上_ms,或者在还需要转义的,未处理的变量前面加上raw_

  • 类名和方法名

    • 类名和对象名使用名词或名词短语,如Customer、AddressParser()
    • 方法名使用动词或动词短语,如 DeleteUser() 、Save()

名字容易且重要,当你看到一个毫无意义的变量或方法名时,毫不犹豫的改掉,现在的IDE都支持一键改名,几乎毫不费力的改善了代码质量。

注释

首先引用《Clean Code》中的几句对注释的描述:

“什么也比不上放置良好的注释来得有用。什么也不会比乱七八糟的注释更有本事搞乱一个模块。什么也不会比陈旧、提供错误信息的注释更具有破坏力”

“注释是为了弥补代码的不足”

“能不能不写注释,从代码上就可以很清晰的了解用途”

好的代码自己本身就是最好的文档。当你打算加注释的时候,问问自己‘我如何才能把我的代码改善到不需增加注释?’重构自己的代码,然后使文档让其更清楚。 — Steve McConnell《代码大全》的作者

从上面大概可以看出,该书的作者对注释持负面态度,作者认为好代码 > 坏代码 + 好注释。

很多时候我们又不得不写一些注释,仅仅用代码不能很清晰的描述我们的真实意图。那么什么是好的注释?什么样的注释是坏注释呢?又该如何去写呢?

注释应该是对“意图”进行诠释,好的注释言简意赅,用最少的文字传达了必要的信息,例如以下几种情况的注释:

  • 这些看起来很怪异的代码为什么这么写
  • 代码中埋下的“坑”是什么,“这个函数只有我才能调用通,且不能改”
  • 常量定义,常量的用途,为何取这个值
  • 总结性的文字描述,告诉别人这段代码的工作机制
  • 产品/老板要求一定要有这个业务,特殊处理

而坏注释对代码并没有多少用处,甚至会造成理解上的障碍,如下面这几种情况:

  • 从代码本身就能快速推断的事实写注释

    // Account 结构体定义
    type Account struct{
    }
    // 设置账户名
    func (a *Account)SetName(name string){
    }
    
  • 给不好的名字加注释

    // Release the handle for this key. This doesn't modify the actual registry.
    func DeleteRegistry(key string)
    
    func ReleaseRegistryHandle(key string)
    

    与其花额外的精力为不好的名字进行注释,还不如取一个更好的名字。

  • 大量的废弃代码注释

    现在都有版本管理,代码不会丢失,在需要的时候看看文件提交历史就能够找回。这些代码果断删除吧,让代码更干净一点。

那我们注释的时候又该如何写呢,下面有些小Tips可以作为参考。

  • 精简注释,让注释描述简洁

    // 学生的分数情况,第一个map中的key是学生姓名,第二个map分别是课程名和对应的分数
    ScoreMap map[string]map[string]int32    
    
    // userName -> [project:score]
    ScoreMap map[string]map[string]int32
    

    上面两个注释都能说明意图,但是第二个注释显得更简洁,不啰嗦。

  • 精确描述函数的行为

    // 返回文件的行数
    func CountLines(string fileName)
    // 统计文件中的 '\n' 换行符个数
    func CountLines(string fileName)
    

    第一个注释描述的不够清晰,例如空文件算0行还是1行?hello\nworld 算几行?而第二个注释就没有歧义。

  • 用适当例子说明特殊情况

    // 移除 src 中的 chars 
    func Strip(src, chars string)string
    
    // Example:Strip("abba/a/ba", "ab") -> "/a/"
    func Strip(src, chars string)string
    

    第二个在注释中添加恰当的例子,说明了Strip函数的意图。

  • 直接申明代码意图

    //add by xx  产品要求一定要给某些高级用户增加xx功能。 时间:2020-04-30
    func Increase(userId string)
    

当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

函数

函数应该尽量写的短小,但是该写多小并没有明确的标准,一般来说20-30行最佳。Go语言中,一个函数如果很大,我们可以采用匿名函数将逻辑写的更清晰。

一个函数只做一件事,不要把不相关的逻辑都堆在一个函数中,把这个函数变成了万能函数。如果发现足够的行数都是在解决不相关的字问题时,果断的把它抽取到独立的函数中。

在写代码时,可以问问自己下面这些问题,有助于识别不相关代码:

  • 看到某个函数或者代码块时,问问自己:这段代码的高层次的目标时什么?
  • 对于每一行代码,问一下:它是直接为了目标而工作吗?这段代码高层次的目标是什么呢?

过度设计

程序员倾向于高估有多少功能真的对于他们的项目来讲是必不可少的。很多功能结果没有完成,或者没有用到,也可能只是让程序更复杂。程序员还倾向于低估实现一个功能所要花的工夫。

有些用不到的功能并没有想象中那么重要,不一定会在未来用到,我们不需要为这些可能用不到的功能投入宝贵的时间。如一个内部的系统,本身没几个人用,去要求系统的高可用、分布式、微服务和国际化等明显不合理的需求。

逻辑

逻辑是代码的血肉,清晰的逻辑会让代码更容易理解,有些常用的手段可以稍微简化代码的复杂度。

  • 使用“卫语句(guard clause)”从函数中提前返回

    func Save(content string)bool{
        if len(content) == 0 {
            return false
        }
        // 很多其他判断条件
        ...
    }
    
  • 最小化嵌套

    if userResult == SUCCESS{
        if permissionResult != SUCCESS{
            reply.Error("error..")
            reply.Done
            return;
        }
        reply.Error("")
    }else{
        reply.Error(userResult)
    }
    reply.Done()
    

    上面代码 if ... else 嵌套对理解并没增加什么困难,但是当在一个复杂函数中时,理解这个嵌套就比较吃力,我们可以提前返回,调整代码顺序,理解起来更容易。

    if userResult != SUCCESS{
        reply.Error("error..")
        reply.Done()
        return;
    }
    if permissionResult != SUCCESS{
        reply.Error("error..")
        reply.Done()
        return;
    }
    reply.Error("")
    reply.Done()
    

    在循环中出现嵌套我们也可以采取提前返回的方法,只是把return换成breakcontinue

    for _, item := range results{
      if item != nil{
        // do ...
        if item.name != ""{
          // do...
        }
      }
    }
    

    采取条件提前返回,调整代码后:

    for _, item := range results{
      if item == nil{
        continue
      }
      if item.name == ""{
        continue
      }
      // do ...
    }
    

    以上就是代码规范相关的几点内容,没多少难度却意义重大。


代码重构的方式

一幢有少许破窗的建筑为例,如果那些窗不被修理好,可能将会有破坏者破坏更多的窗户。最终他们甚至会闯入建筑内,如果发现无人居住,也许就在那里定居或者纵火。一面墙,如果出现一些涂鸦没有被清洗掉,很快的,墙上就布满了乱七八糟、不堪入目的东西;一条人行道有些许纸屑,不久后就会有更多垃圾,最终人们会视若理所当然地将垃圾顺手丢弃在地上。这个现象,就是犯罪心理学中的破窗效应。

我们的代码也是一样,一段优秀的代码,如果从小到变量命名开始不注意,就会开始“腐烂”,直到自己都忍受不了,最后推倒重来,开始恶性循环。因此,我们需要经常对代码进行重构,不断改善软件设计,使软件更容易理解,保持软件的“生命力”。

何为重构?

在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。本质上说,重构就是在代码写好之后改进它的设计,这个改进会让代码更容易理解,更易于修改,而这可能使程序运行得更快,也可能使程序运行得更慢。它融入到整个开发过程,而不是需要专门的时间,需要专门时间来写那是“重写”,而不是“重构”。

重构时机

重构是融入到整个开发过程的,上一分钟在写代码,下一分钟可能就在重构。当出现下面一些时机,就可以考虑重构了。

  • 新添加功能

    每次在新添加功能之前,考虑一下是不是把当前代码重构一下,更容易添加这个功能。

    每次要修改时,首先令修改很容易(警告:这件事有时会很难),然后再进行这次容易的修改。

  • 重复代码——DRY

    当看到重复代码时,毫不犹豫进行重构。

  • 过时的知识

    例如当一个语言出现新特性,发现这个特性能够使代码更简洁,更优雅时,可以考虑重构。

  • 一个小的重构是否能使代码更容易理解

有个通用的原则就是,保证每次离开你所见到的代码时,比你刚看到时更漂亮。当然也有不需要重构的情况,例如有些运行良好的底层“祖传代码”,已经成功运行N年没出现过bug,很少人动的代码,那就不要重构了,不能保证你重构的代码比现在更好。而有些代码发现重写比重构更容易时,也不要去重构了,直接重写吧。

在重构之前,一定要确保有良好的测试,在这个前提下用简短又慎重的步骤进行,一次修改一个小点,然后频繁的运行测试用例,保证每次的重构都能通过所有测试,不影响系统正常运行。

“坏味道”与技巧

识别出代码中的“坏味道”,然后用一些重构技巧来优化,下面列举一些典型的“坏味道”和常用的重构技巧。

  • 神秘的命名

    正如本文代码风格中关于命名的描述,遇到令人费解的变量、字段或命名时,对它们进行改名操作。现在IDE都支持一键改名操作。

  • 重复代码

    重复代码的危害众所周知,修改一处代码的时候还需要兼顾其他代码。

    改变代码的顺序,重组代码,把重复代码提炼成函数。

  • 过长的函数

    每当感觉某段代码需要注释来说明的时候,可以把要说明的东西写进一个独立的函数中,并以其用途命名。我们可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。

  • 参数列表过长

    引入参数对象,将相关的参数用一个对象封装起来。

    func Save(name, path, perm string, length int, flag int ...)
    

    有些函数有甚至上十个参数,调用的时候容易传参错误,可以定义一个对象,调用时传递该对象即可。

    type Param struct{
      name string
      path string 
      perm string
      length int
      flag int 
      ...
    }
    func Save(data Param)
    
  • 全局变量

    全局变量的危害在于,当不止一处代码会对其进行修改,往往会造成不可预期的结果。

    我们可以采用单例模式或着封装变量,控制全局变量的作用域,对外只提供操作的接口。

    var global *Asset
    
    func SetAssetName(name string){
      global.Name = name
    }
    
  • 发散式变化

    当我们修改或增加一处功能时,必须同时修改若干个函数,这叫做发散式变化。可以提炼和搬移函数,做到“每次只关心一个上下文”,将相关函数搬到一起。

  • 依恋情节

    一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。

    判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起,将那些总是一起变化的东西放在一块。

  • 数据泥团

    将分散的变量汇总起来,为数据泥团新建一个类,不要担心只用到了其中几个字段。

    type PermData struct{
      path string
      name string
      perm string
      ...
    }
    
  • 过长的消息链(调用链)

    过长的消息链,一旦当其中一个对象发生修改,我们就不得不更改所有地方。

    name = company().section().user().name()
    

    增加一个“中间者”,减少消息链。

  • 过大的类

    观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。


单元测试

有人说,看一段代码质量如何,就看代码中的接口多不多。一个接口都没有的代码,很难说是好代码,至少很难测试。在敏捷开发中,有一种TDD方案,在正式写代码之前,先写单元测试,然后再让单元测试通过,

这种方式能够让代码更优雅,更简洁,当然需要的开发时间也很多。理想是美好的,现实情况是,由于各种原因,不可能严格按照TDD所提倡的方式来进行开发。但是核心代码的单元测试必不可少,只有在有单元测试覆盖的情况下,我们才能放心的进行重构与开发。

一个好的单元测试要求运行时间短、可以帮忙快速的定位问题,试想一下,如果一个单元测试用例需要运行1s,那么整个项目上千个测试用例耗时难以忍受,我们更不愿意频繁的跑测试。

有时候,我们会写一些测试代码,来测试功能是否通过,但有时这些不能称之为单元测试,因为往往这些临时写的测试代码,有下面这些特点:

  • 依赖数据库
  • 依赖网络通信
  • 调用文件系统
  • 需要额外的配置文件

下面简单介绍一些单元测试中常用到的框架。

goconvey

虽然 Golang 自带了单元测试功能和很多其他第三方测试框架,但是 GoConvey 更简洁,更优雅,使用起来更方便。

我们通过一个案例来介绍如何使用。

待测试函数:

func CompareSlice(a, b []int) bool{
    if len(a) != len(b){
        return false
    }

    if (a == nil) != (b == nil){
        return false
    }
    for i, v  := range a{
        if v != b[i]{
            return false
        }
    }
    return true
}

测试代码:

func TestCompareSlice(t *testing.T) {
    Convey("TestSliceEqual", t, func() {
        Convey("should be true", func() {
            a := []int{1, 2, 3}
            b := []int{1, 2, 3}
            So(CompareSlice(a, b), ShouldBeTrue)
        })
        Convey("should return true a=nil&b=nil", func() {
            So(CompareSlice(nil, nil), ShouldBeTrue)
        })
        Convey("should return false", func() {
            a := []int{1, 2, 3}
            b := []int{1, 2}
            So(CompareSlice(a,b), ShouldBeFalse)
        })
    })
}

最常用的函数就是通过So 进行断言。

输出:

=== RUN   TestCompareSlice

  TestSliceEqual
    should be true ✔
    should return true a=nil&b=nil ✔
    should return false ✔


3 total assertions

--- PASS: TestCompareSlice (0.00s)

测试失败的情况:

=== RUN   TestCompareSlice2

  should be true ✔


1 total assertion


  should return true a=nil&b=nil ✔


2 total assertions


  should return false ✘


Failures:

  * /Users/jiami/go/src/awesome/example_test.go
  Line 44:
  Expected: false
  Actual:   true
  goroutine 7 [running]:

使用时有如下几个要点:

1、import goconvey包时,前面加"."号,以减少冗余代码。

2、测试函数名称以Test开头,参数是*testing.T

3、每个测试用例必须用Convey函数包裹起来。

gomoke

当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件I/O等。这种场景就非常适合使用 mock/stub 测试。简单来说,就是用 mock 对象模拟依赖项的行为。

gomoke是官方提供的moke框架,下面将用一个简单的例子来介绍如何使用。

// 文件名: db.go
type DB interface {
    Get(key string)(int, error)
}

func GetFromDB(db DB, key string)int{
    if value, err := db.Get(key); err == nil{
        return value
    }
    return -1
}

在测试用例中,不能创建真正的数据库连接,这个时候我们就可以moke DB 接口。

首先生成 moke 对象:

mockgen -source=db.go -destination=db_mock.go -package=main

此时,就会生成一个名为 db_moke.go 的文件,部分代码如下:

// MockDB is a mock of DB interface
type MockDB struct {
    ctrl     *gomock.Controller
    recorder *MockDBMockRecorder
}

// MockDBMockRecorder is the mock recorder for MockDB
type MockDBMockRecorder struct {
    mock *MockDB
}

// NewMockDB creates a new mock instance
func NewMockDB(ctrl *gomock.Controller) *MockDB {
    mock := &MockDB{ctrl: ctrl}
    mock.recorder = &MockDBMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockDB) EXPECT() *MockDBMockRecorder {
    return m.recorder
}

// Get mocks base method
func (m *MockDB) Get(key string) (int, error) {
    ret := m.ctrl.Call(m, "Get", key)
    ret0, _ := ret[0].(int)
    ret1, _ := ret[1].(error)
    return ret0, ret1
}
...

接下来,在我们的测试用例中就可以使用 MockDB 来模拟DB操作了,测试代码如下:

func TestGetFromDB(t *testing.T) {
    Convey("test mock",t, func() {
        ctrl := gomock.NewController(t)
        defer ctrl.Finish()


        m := NewMockDB(ctrl)
        m.EXPECT().Get(gomock.Eq("Test")).Return(0, errors.New("not exist"))
        So(GetFromDB(m, "Test"), ShouldEqual, -1)

        m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string) (int, error){
            if key == "test"{
                return 1, nil
            }
            return -1, errors.New("not exist")
        }).AnyTimes()
        So(GetFromDB(m, "test"), ShouldEqual, 1)
        So(GetFromDB(m, "test1"), ShouldEqual, -1)
    })
}

我们可以使用EXCEPT()函数来“打桩”——给定明确的参数和返回值、检测调用次数、调用顺序,动态设置返回值等。

gomonkey

这是国人写的一个很方便的打桩框架,接口友好,功能强大,支持下面一些特性:

  • 支持为一个函数打一个桩
  • 支持为一个成员方法打一个桩
  • 支持为一个全局变量打一个桩
  • 支持为一个函数变量打一个桩
  • 支持为一个函数打一个特定的桩序列
  • 支持为一个成员方法打一个特定的桩序列
  • 支持为一个函数变量打一个特定的桩序列

下面就“支持为一个函数打一个桩”、“支持为一个成员方法打一个桩”、“支持为一个全局变量打一个桩”来举例说明如何使用,具体功能有兴趣可自行搜索。

为一个函数打一个桩

需要用到下面一个接口:

func ApplyFunc(target, double interface{}) *Patches
func (this *Patches) ApplyFunc(target, double interface{}) *Patches

ApplyFunc 第一个参数是函数名,第二个参数是桩函数。测试完成后,patches 对象通过 Reset 成员方法删除所有测试桩。
假设我们有一个用来比较大小的函数:

func CompareInt(a,b int)int{
    if a == b{
        return 0
    }else if a > b{
        return 1
    }else{
        return -1
    }
}

我们需要为这个函数“打桩”,让它在被调用时返回指定的结果,用法如下:

func TestCompareInt(t *testing.T) {
    Convey("CompareInt", t, func() {
        patch := ApplyFunc(CompareInt, func(_ int, _ int) int{
            return 0
        })
        defer patch.Reset()
        So(CompareInt(1,1), ShouldEqual, 0)
    })
}

设置完桩函数之后,我们再次调用,无论参数是什么,它都会返回 0 值。

这个也可以给标准库中的函数进行“打桩”,比如 strconv.FormatInt函数:

func TestPackage(t *testing.T){
    Convey("test", t, func() {
        patch := ApplyFunc(strconv.FormatInt, func(_ int64, _ int)string{
            return "test_package"
        })
        defer patch.Reset()
        So(strconv.FormatInt(1,10), ShouldEqual, "test_package")
        Convey("test_2", func() {
            So(strconv.FormatInt(1,10), ShouldEqual, "test_package")
            patch.Reset()
            So(strconv.FormatInt(1,10), ShouldEqual, "1")
        })
    })
}

设置完桩函数后,strconv.FormatInt就不是原先的函数了,它只会返回"test_package"。

为一个成员方法打一个桩

需要用到下面这个接口:

func ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches
func (this *Patches) ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches

例如,我们有个 RedisCli 类,用来表示 redis 连接,其中有个 Get 方法:

type RedisCli struct{
}

func (r *RedisCli)Get(key string)(string, error){
    return key + "test", nil
}

在单元测试中,不能真正的连接 redis,为了测试代码,我们除了可以 moke 外,还可以为 Get 方法打桩,让它暂时返回指定结果。

func TestClass(t *testing.T){
    Convey("test class", t, func() {
        var redisCli *RedisCli
        patch := ApplyMethod(reflect.TypeOf(redisCli), "Get", func(_ *RedisCli, _ string) (string, error){
            return "test_redis_get", nil
        })
        defer patch.Reset()

        redisCli = &RedisCli{}
        rsp, err := redisCli.Get("test")
        So(err, ShouldBeNil)
        So(rsp, ShouldEqual, "test_redis_get")
    })
}

注意:如果打桩目标为内联的函数或成员方法,请在测试时通过命令行参数 -gcflags=-l 关闭内联优化。

为一个全局变量打一个桩

需要用到下面一个接口:

func ApplyGlobalVar(target, double interface{}) *Patches
func (this *Patches) ApplyGlobalVar(target, double interface{}) *Patches

使用示例如下:

// 全局变量插桩
var num = 10
func TestApplyGlobalVar(t *testing.T){
    Convey("test global var", t, func() {
        Convey("change", func() {
            patch := ApplyGlobalVar(&num, 100)
            defer patch.Reset()

            So(num, ShouldEqual, 100)
        })

        Convey("recover", func() {
            So(num, ShouldEqual, 10)
        })
    })
}

总结

本文内容只是作为一个简单的介绍,很多内容被舍弃掉了,如果觉得太浅显或太长不看,那么只需要在编码时记得时刻问自己下面两个问题:

  • 如果这样写代码,半年后的我会不会骂现在的自己?
  • 这块代码怎样写才方便测试?

你可能感兴趣的:(努力写“好”代码)