努力写“好”代码

2020-08-30  本文已影响0人  mjammer

代码规范与质量

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

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

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

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


代码风格与规范

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

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

可读性基本定理

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

命名

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

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

注释

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

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

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

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

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

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

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

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

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

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

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

函数

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

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

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

过度设计

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

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

逻辑

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


代码重构的方式

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

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

何为重构?

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

重构时机

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

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

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

“坏味道”与技巧

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


单元测试

有人说,看一段代码质量如何,就看代码中的接口多不多。一个接口都没有的代码,很难说是好代码,至少很难测试。在敏捷开发中,有一种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)
        })
    })
}

总结

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

上一篇下一篇

猜你喜欢

热点阅读