GO语言调试利器dlv快速上手

https://www.cnblogs.com/realjimmy/p/13418508.html

一、dlv的安装

1)下载dlv

git clone https://github.com/go-delve/delve.git $GOPATH/src/github.com/go-delve/delve

或者 go get github.com/derekparker/delve/cmd/dlv

2)安装

cd $GOPATH/src/github.com/go-delve/delve

make install

二、dlv简要使用说明

2.1、获取帮助信息

安装后执行dlv -h将会看到帮助信息:

image

上面的信息只是列出了命令列表,具体使用方法没有给出,我们可以执行dlv help + 具体命令来查看详细说明,

比如我们执行dlv help attach:

image

2.2、进入调试模式

1)dlv attach pid:类似与gdb attach pid,可以对正在运行的进程直接进行调试(pid为进程号)。

2)dlv debug:运行dlv debug test.go会先编译go源文件,同时执行attach命令进入调试模式,该命令会在当前目录下生成一个名为debug的可执行二进制文件,退出调试模式会自动被删除。

3)dlv exec executable_file :直接从二进制文件启动调试模式。如果要带参数执行需要添加–,如dlv exec executable_file — -f xxx.conf

4)dlv core executable_file core_file:以core文件启动调试,通常进行dlv的目的就是为了找出可执行文件core的原因,通过core文件可直接找出具体进程异常的信息。

3、常用调试方法

3.1 dlv trace追踪调用轨迹

该命令最直接的用途是可以追踪代码里函数的调用轨迹,

如下源代码,现用trace命令跟踪其调轨迹。

package main import ( “fmt” “time” ) func Test() { fmt.Println(“hello”) time.Sleep(1000 * 1000 * 100) } func Test2() { fmt.Println(“world”) time.Sleep(1000 * 1000 * 100) } func main() { for i := 0; i < 2; i++ { go Test() go Test2() } time.Sleep(1000 * 1000 * 2000) fmt.Println(“end”) }

运行结果,这里看除了Test,test2也被追踪:

$ dlv trace hello.go Test

> goroutine(19): main.Test2()

> goroutine(21): main.Test2()

> goroutine(18): main.Test()

world

hello

world

> goroutine(20): main.Test()

hello

=> ()

=> ()

=> ()

=> ()

end

3.2 调试模式基本命令

这里用上节的源码作为示例进行调试。开始调试:dlv debug hello.go

1)b(break):打断点

设置断点,当需要设置多个断点时,为了断点可识别可进行自定义命名。进入调试模式后先打断点。

例:b Test

b test.go:13

image

2)r(restart):重启当前进程

类似gdb里的run,如果刚执行dlv debug hello.go,进程已经起来,不用执行。如果进程已结算或需要重新开始则需要执行r

3)c(continue):继续执行到断点处

image

4)bp:查看所有断点

image

5)on  :当运行到某断点时执行相应命令

断点可以是名称(在设置断点时可命名断点)或者编号,例如on 3 p i表示运行到断点3时打印变量i。

image

6)cond(condition)   :有条件的断点

针对某个断点,只有表达式成立才会被中断。例:

condition 3 i==1

image

image

7)n(next):逐行执行代码,不进入函数内

8)s(step):逐行执行代码,遇到函数会跳进内部

9)stepout:当使用s命令进入某个函数后,执行它可跳出函数

10)si(step-instruction):单步单核执行代码

如果不希望多协程并发执行可以使用该命令,这在多协程调试时极为方便。

11)args:查看被调用函数所传入的参数值

12)locals:查看所有局部变量

locals var_name:查看具体某个变量,var_name可以是正则表达式。

13)clear:清除单个断点

14)clearall:清除所有断点

15)list:打印当前断点位置的源代码

list后面加行号可以展示该行附近的源代码,要注意该行必须是代码行而不能是空行。

16)bt:打印当前栈信息。

3.3 多协程调试

1)goroutines:显示所有协程

image

2)goroutine:协程切换

先执行goroutine 7表示切换到7号协程上

3.4 其他命令

1)frame:切换栈。

2)regs:打印寄存器内容。

3)sources:打印所有源代码文件路径

4)source:执行一个含有dlv命令的文件

source命令允许将dlv命令放在一个文件中,然后逐行执行文件内的命令。

5)trace:类似于打断点,但不会中断,同时会输出一行提示信息

浅析控制反转

https://zhuanlan.zhihu.com/p/60995312

介绍

控制反转 (Inversion of control) 并不是一项新的技术,是 Martin Fowler 教授提出的一种软件设计模式。那到底什么被反转了?获得依赖对象的过程被反转了。控制反转 (下文统一简称为 IoC) 把传统模式中需要自己通过 new 实例化构造函数,或者通过工厂模式实例化的任务交给容器。通俗的来理解,就是本来当需要某个类(构造函数)的某个方法时,自己需要主动实例化变为被动,不需要再考虑如何实例化其他依赖的类,只需要依赖注入 (Dependency Injection, 下文统一简称为 DI), DI 是 IoC 的一种实现方式。所谓依赖注入就是由 IoC 容器在运行期间,动态地将某种依赖关系注入到对象之中。所以 IoC 和 DI 是从不同的角度的描述的同一件事情,就是通过引入 IoC 容器,利用依赖注入的方式,实现对象之间的解耦。

那反转控制这种设计模式到底给前端带来了什么价值?这里先给出答案:

  1. 提升开发效率
  2. 提高模块化
  3. 便于单元测试

为什么我们需要它?

先给出一个例子,传统模式下当我们创建汽车 (Car) 这个类的时候,我们需要依赖轮子,发动机。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }
}

在 Car 这个类的构造器中我们装备了这个类中需用到的依赖项,这有什么问题呢?正如你所见,构造器不仅需要把依赖赋值到当前类内部属性上还需要把依赖实例化。比如 Engine 是通过 new 实例化的, 而 Tires 是通过工厂模式创建的。这样的高度耦合的依赖关系大大增加了单元测试难度和后期维护的成本。必然会出现牵一发而动全身的情形。而且在依赖 hard-code 写死在代码中并不符合 SOLID 开发原则中的 “开闭原则”。试想一个程序中,我们有超多种类的 Car,他们都依赖同一个依赖 Engine,但是有一天我想把所有的 Engine 换成 V8Engine 我该怎么做?全局搜索 Engine 修改为 V8Engine,想想都有点麻烦。


每辆车都需要自己控制引擎的创建

然后我们尝试一下 IoC 的版本。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';
import { Container } from 'path/to/container';

const container = new Container();
container.bind('engine', Engine);
container.bind('tires', Tires);

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = container.get('engine');
    this.tires = container.get('tires');
  }
}

现在引擎和轮胎的创建不再直接依赖它们的构造函数,而是通过 IoC 容器 (container) 来创建,使得 Car 类 和 Engine,Tires 没有了强耦合关系。代码中不再依赖于具体,而是依赖于 container 抽象容器,即要针对接口编程,不针对实现编程。过去思维中想要什么依赖,需要自己去 “拉” 改为抽象容器主动 “推” 给你,你只管使用实体就可以了。这是依赖倒转 (DIP) 的一种表现形式。


所有车装有引擎

因为汽车不直接依赖引擎,所以现在我想把所有引擎换成 V8 引擎,只需要把 IoC 容器中的引擎替换掉就可以了。


所有车装有 V8 引擎

原理

首先让我们实现一个最简单的容器来管理依赖,这里省略了大量类型定义,类型判断和异常处理,并不适用于生产环境。

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, constructor) {
    this.constructorPool.set(name, constructor);
  }

  get(name) {
    const target = this.constructorPool.get(name);
    return new target();
  }
  
}

container.register('myClass', DemoClass);
const classInstance = container.get('myClass');

constructorPool 是存放所有依赖的集合, 这是最简单的对象池,池中存储着构造函数和唯一标识符的集合。当调用 get 方法时,根据唯一标识符从对象池中拿到构造函数并返回实例,这只考虑了在注册时如参是构造函数,并且每次 get 的时候都返回新的实例。当我们需要在全局使用单一实例,并且在不同的地方拿到同一个实例,就需要在注册 (register) 的时候添加配置区分是单例模式还是工厂模式

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, definition, dependencies) {
    this.constructorPool.set(name, {
      definition: definition,
      dependencies: dependencies
    });
  }

  get(name) {
    const targetConstructor = this.constructorPool.get(name);
    if (this._isClass(targetConstructor.definition)) {
      return this._createInstance(targetConstructor);
    } else {
      return targetConstructor.definition;
    }
  }
  
  // 递归拿到类的所有依赖集合
  _getResolvedDependencies(target) {
    let classDependencies = [];
    if (target.dependencies) {
      classDependencies = target.dependencies.map(dependency => {
        return this.get(dependency);
      });
    }
    return classDependencies;
  }

  _createInstance(target) {
    return new target.definition(...this._getResolvedDependencies(service));
  }

  // 判断是否为构造函数
  _isClass(definition) {
    return Object.prototype.toString.call(definition) === "[object Function]";
  }
}

而且依赖容器中需要维护一套自己的生命周期去满足连接数据库等需求,这里建议大家读一下 midway 团队出品的 injection ,这里有更完整的解决方案。

可测性

接下来我们用实际开发的例子看一下 IoC 是如何提高代码的可测性。

这里还是使用汽车的例子。

import { Engine } from 'engine/path';
import { Tires } from 'tires/path';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }

  async run() {
    const engineStatus = await this.engine.check();
    const tiresStatus = await this.tires.check();

    if (engineStatus && tiresStatus) {
      return console.log('car running.');
    }
    return console.log('car broken');
  }
}

当我们实例化 Car 之后,执行 run 的时候,我们会调用 engine 和 tires 依赖里的方法,这个方法有可能会有外部依赖,比如从数据库中读数据,或者一次 http 请求。

export class Engine {
  private health = true;
  async check() {
    const result1 = await http.get('demo'); //check 1
    const result2 = await db.find({         //check 2
      id: 'demoId'
    });                                     
    const result3 = this.health;            //check 3

    return result1 && result2 && result3;
  }
}

当生产环境下我们执行 check,我们期望 3 个 check 都是 true 才让引擎发动,但是在测试阶段,我们只想执行 check3,忽略 check1 和 check2,这在传统开发模式下是很难做的,因为在 Car 构造函数中,已经写死了 Engine 的创建。想在测试阶段提供一个永远保持健康状态的引擎只能通过实例化时判断环境变量,赋值不同的实例,或者修改构造函数。

实例化时判断环境。

class Car {
  private engine;
  public running = false;

  constructor() {
    if (process.env === 'test') {
      this.engine = new TestEngine();
    } else {
      this.engine = new Engine();
    }
  }

  async run() {
    const engineStatus = await this.engine.check();

    return this.running = engineStatus;
}

公用类判断环境。

export class Engine {
  private health = true;
  async check() {
    if (process.env === 'test') {
      // test check
    } else {
      // normal check
    }
  }
}

这两种方式都不是优雅的解决方案,这种脏代码不应该在项目中出现。为了单元测试而需要判断执行环境的代码不应该写在具体实现上,而是应该放在公共的地方统一处理。

借由 IoC 容器,我们的业务代码不需要为单元测试作出修改,只需要在测试的时候,把测试的实例注册到 IoC 的容器中就可以了。

class Car {
  private engine;
  public running = false;

  constructor() {
    this.engine = container.get('engine');
  }

  async run() {
    const engineStatus = await this.engine.check();

    if (engineStatus) {
      return this.running = true;
    }
    return this.running = false;
  }
}

通过 IoC 我们可以优雅的处理测试环境下,业务代码中需要的依赖实体。因为当测试开始时,我们可以通过配置创建符合预期的类放到对象池中,业务代码中只需要直接使用就可以了。

以下给出一段对于 Car 的测试代码。

// car.spec.js
const Car = require('./car');

describe('Car', function () {
  it('#car.run', async function () {
    // 注册测试用依赖
    container.register('engine', MockEngine);

    const car = new Car();

    await car.run()

    expect(car.running).to.eql(true);
  });
});

社区最佳实践

在前端领域,反转控制可能被提及的比较少 (Angular 2 发布之前),但是在服务端领域, IoC 有很多实现,比如 Java 的 Spring 框架,PHP 的 Laravel 等等。Angular 的出现让我对前端工程化有了新的见解,Angular 把依赖注入作为应用设计模式,在框架的高度管理所有依赖和帮助开发者获取依赖,Angular 官方自己维护了一套自己的 DI 框架。

想揭开 DI 的神秘面纱需要了解两个东西。

首先是 @Injectable。这是 JavaScript 装饰器 (Decorators) 语法特性,装饰器语法已经进入 TC39 提案 Stage 2,但是还没正式进入 ECMA 语法标准。这个特发特性是使类可被注入的关键。开发者可以使用注解的方式自定义类的行为,方法,和运行时的属性。在 Angular 中使用 @Injectable 注解向 IoC 容器注册。angular/packages/core/src/di/ 在这个命名空间下 Angular 组织了 DI 的逻辑。框架提供了一套解决方案跟踪被注解的所有依赖,当你需要时提供正确的实例。

然后是 reflect-metadata。这个包提供了读取和修改类的源数据的能力,是帮助 Angular 判断被注入方所需实例类型的关键点。当使用这个包时,必须设置在 tsconfig.json 中开启 emitDecoratorMetadata: true 。

通过这两位的帮助,TypeScript 便可在编译时拿到被注解类的原数据,而且这些原属组是在运行时可用的。

总结

因篇幅原因,这里只是简单介绍 IoC 的使用,控制反转设计模式的优点是显而易见的,它有益于编写单元测试。因为依赖的实例化交给了容器,所以减少了实例化模版代码。让程序更易于扩展。去除代码之间的直接依赖关系,降低了耦合度。控制反转离不开依赖注入,现阶段社区中解决方案是通过 reflect-metadata 和装饰器来进行注入。

依赖注入

https://www.zhihu.com/question/32108444

第一章:小明和他的手机

从前有个人叫小明

小明有三大爱好,抽烟,喝酒…… 咳咳,不好意思,走错片场了。应该是逛知乎、玩王者农药和抢微信红包


小明的三大爱好

我们用一段简单的伪代码,来制造一个这样的小明

class Ming extends Person
{
    private $_name;

    private $_age;

    function read()
    {
        //逛知乎
    }

    function  play()
    {
        //玩农药
    }

    function  grab()
    {
        //抢红包
    }

}

但是,小明作为一个人类,没有办法仅靠自己就能实现以上的功能,他必须依赖一部手机,所以他买了一台iphone6,接下来我们来制造一个iphone6

class iPhone6 extends Iphone
{
    function read($user="某人")
    {
        echo $user."打开了知乎然后编了一个故事 \n";
    }

    function play($user="某人")
    {
        echo $user."打开了王者农药并送起了人头 \n";
    }

    function grab($user="某人")
    {
        echo $user."开始抢红包却只抢不发 \n";
    }
}

小明非常珍惜自己的新手机,每天把它牢牢控制在手心里,所以小明变成了这个样子

class Ming extends Person
{
    private $_name;

    private $_age;

    public function  __construct()
    {
        $this->_name = '小明';
        $this->_age = 26;
    }

    function read()
    {
        //……  省略若干代码
        (new iPhone6())->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        (new iPhone6())->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        (new iPhone6())->grab($this->_name);//抢红包

    }

}

今天是周六,小明不用上班,于是他起床,并依次逛起了知乎,玩王者农药,并抢了个红包。

$ming = new Ming();  //小明起床
$ming->read();
$ming->play();
$ming->grab();

这个时候,我们可以在命令行里看到输出如下

小明打开了知乎然后编了一个故事 
小明打开了王者农药并送起了人头 
小明开始抢红包却只抢不发

这一天,小明过得很充实,他觉得自己是世界上最幸福的人。

第二章: 小明的快乐与忧伤

小明和他的手机曾一起度过了一段美好的时光,一到空闲时刻,他就抱着手机,逛知乎,刷微博,玩游戏,他觉得自己根本不需要女朋友,只要有手机在身边,就满足了。

可谁能想到,一次次地系统更新彻底打碎了他的梦想,他的手机变得越来越卡顿,电池的使用寿命也越来越短,一直到某一天的寒风中,他的手机终于耐不住寒冷,头也不回地关了机。

小明很忧伤,他意识到,自己要换手机了。

为了能获得更好的使用体验,小明一咬牙,剁手了一台iphoneX,这部手机铃声很大,电量很足,还能双卡双待,小明很喜欢,但是他遇到一个问题,就是他之前过度依赖了原来那一部iPhone6,他们之间已经深深耦合在一起了,如果要换手机,他就要拿起刀来改造自己,把自己体内所有方法中的iphone6 都换成 iphoneX。


漫长的改造过程

经历了漫长的改造过程,小明终于把代码中的 iphone6 全部换成了 iphoneX。虽然很辛苦,但是小明觉得他是快乐的。

于是小明开开心心地带着手机去上班了,并在回来的路上被小偷偷走了。为了应急,小明只好重新使用那部刚刚被遗弃的iphone6,但是一想到那漫长的改造过程,小明的心里就说不出的委屈,他觉得自己过于依赖手机了,为什么每次手机出什么问题他都要去改造他自己,这不仅仅是过度耦合,简直是本末倒置,他向天空大喊,我不要再控制我的手机了。

天空中的造物主,也就是作为程序员的我,听到了他的呐喊,我告诉他,你不用再控制你的手机了,交给我来管理,把控制权交给我。这就叫做控制反转

第三章:造物主的智慧

小明听到了我的话,他既高兴,又有一点害怕,他跪下来磕了几个头,虔诚地说到:“原来您就是传说中的造物主,巴格梅克上神。我听到您刚刚说了 控制反转 四个字,就是把手机的控制权从我的手里交给你,但这只是您的想法,是一种思想罢了,要用什么办法才能实现控制反转,又可以让我继续使用手机呢?”

“呵“,身为造物主的我在表现完不屑以后,扔下了四个大字,“依赖注入!”

接下来,伟大的我开始对小明进行惨无人道的改造,如下

class Ming extends Person
{
    private $_name;

    private $_age;

    private $_phone; //将手机作为自己的成员变量

    public function  __construct($phone)
    {
        $this->_name = '小明';
        $this->_age = 26;
        $this->_phone = $phone;
        echo "小明起床了 \n";
    }

    function read()
    {
        //……  省略若干代码
        $this->_phone->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        $this->_phone->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        $this->_phone->grab($this->_name);//抢红包

    }

}

接下来,我们来模拟运行小明的一天

$phone = new IphoneX(); //创建一个iphoneX的实例
if($phone->isBroken()){//如果iphone不可用,则使用旧版手机
    $phone = new Iphone6();
}
$ming = new Ming($phone);//小明不用关心是什么手机,他只要玩就行了。
$ming->read();
$ming->play();
$ming->grab();

我们先看一下iphoneX 是否可以使用,如果不可以使用,则直接换成iphone6,然后唤醒小明,并把手机塞到他的手里,换句话说,把他所依赖的手机直接注入到他的身上,他不需要关心自己拿的是什么手机,他只要直接使用就可以了。

这就是依赖注入

第四章:小明的感悟

小明的生活开始变得简单了起来,而他把省出来的时间都用来写笔记了,他在笔记本上这样写到

我曾经有很强的控制欲,过度依赖于我的手机,导致我和手机之间耦合程度太高,只要手机出现一点点问题,我都要改造我自己,这实在是既浪费时间又容易出问题。自从我把控制权交给了造物主,他每天在唤醒我以前,就已经替我选好了手机,我只要按照平时一样玩手机就可以了,根本不用关心是什么手机。即便手机出了问题,也可以由造物主直接搞定,不需要再改造我自己了,我现在买了七部手机,都交给了造物主,每天换一部,美滋滋!
我也从其中获得了这样的感悟: 如果一个类A 的功能实现需要借助于类B,那么就称类B是类A的依赖,如果在类A的内部去实例化类B,那么两者之间会出现较高的耦合,一旦类B出现了问题,类A也需要进行改造,如果这样的情况较多,每个类之间都有很多依赖,那么就会出现牵一发而动全身的情况,程序会极难维护,并且很容易出现问题。要解决这个问题,就要把A类对B类的控制权抽离出来,交给一个第三方去做,把控制权反转给第三方,就称作控制反转(IOC Inversion Of Control)控制反转是一种思想,是能够解决问题的一种可能的结果,而依赖注入(Dependency Injection)就是其最典型的实现方法。由第三方(我们称作IOC容器)来控制依赖,把他通过构造函数、属性或者工厂模式等方法,注入到类A内,这样就极大程度的对类A和类B进行了解耦

go 静态检查工具

看了看日历,现在已经是 2021 年了,偶尔还是能看到有人在发诸如 《http body 未关闭导致线上事故》,或者 《sql.Rows 未关闭半夜惊魂》类的文章,令人有一种梦回 2015 的感觉。

在这个 Go 的静态分析工具已经强到烂大街的时代,写这些文章除了暴露这些人所在的公司基础设施比较差,代码质量低以外,并不能体现出什么其它的意思了。毕竟哪怕是不懂怎么读源码,这样的问题你 Google 搜一下也知道是怎么回事了。

特别是有些人还挂着大公司的 title,让人更加不能理解了。下面是简单的静态分析工具的科普,希望给那些还在水深火热的 Gopher 们送点解药。

何谓静态分析

静态分析是通过扫描并解析用户代码,寻找代码中的潜在 bug 的一种手段。

静态分析一般会集成在项目上线的 CI 流程中,如果分析过程找到了 bug,会直接阻断上线,避免有问题的代码被部署到线上系统。从而在部署早期发现并修正潜在的问题。

图片

社区常见 linter

时至今日,社区已经有了丰富的 linter 资源供我们使用,本文会挑出一些常见 linter 进行说明。

go lint

go lint 是官方出的 linter,是 Go 语言最早期的 linter 了,其可以检查:

  • 导出函数是否有注释
  • 变量、函数、包命名不符合 Go 规范,有下划线
  • receiver 命名是否不符合规范

但这几年社区的 linter 蓬勃发展,所以这个项目也被官方 deprecated 掉了。其主要功能被另外一个 linter:revive[^1] 完全继承了。

go vet

go vet 也是官方提供的静态分析工具,其内置了锁拷贝检查、循环变量捕获问题、printf 参数不匹配等工具。

比如新手老手都很容易犯的 loop capture 错误:

package main

func main() {
 var a = map[int]int {1 : 1, 2: 3}
 var b = map[int]*int{}
 for k, r := range a {
  go func() {
   b[k] = &r
  }()
 }
}

go vet 会直接把你骂醒:

~/test git:master ❯❯❯ go vet ./clo.go
# command-line-arguments
./clo.go:8:6: loop variable k captured by func literal
./clo.go:8:12: loop variable r captured by func literal

执行 go tool vet help 可以看到 go vet 已经内置的一些 linter。

~ ❯❯❯ go tool vet help
vet is a tool for static analysis of Go programs.

vet examines Go source code and reports suspicious constructs,
such as Printf calls whose arguments do not align with the format
string. It uses heuristics that do not guarantee all reports are
genuine problems, but it can find errors not caught by the compilers.

Registered analyzers:

    asmdecl      report mismatches between assembly files and Go declarations
    assign       check for useless assignments
    atomic       check for common mistakes using the sync/atomic package
    bools        check for common mistakes involving boolean operators
    buildtag     check that +build tags are well-formed and correctly located
    cgocall      detect some violations of the cgo pointer passing rules
    composites   check for unkeyed composite literals
    copylocks    check for locks erroneously passed by value
    errorsas     report passing non-pointer or non-error values to errors.As
    httpresponse check for mistakes using HTTP responses
    loopclosure  check references to loop variables from within nested functions
    lostcancel   check cancel func returned by context.WithCancel is called
    nilfunc      check for useless comparisons between functions and nil
    printf       check consistency of Printf format strings and arguments
    shift        check for shifts that equal or exceed the width of the integer
    stdmethods   check signature of methods of well-known interfaces
    structtag    check that struct field tags conform to reflect.StructTag.Get
    tests        check for common mistaken usages of tests and examples
    unmarshal    report passing non-pointer or non-interface values to unmarshal
    unreachable  check for unreachable code
    unsafeptr    check for invalid conversions of uintptr to unsafe.Pointer
    unusedresult check for unused results of calls to some functions

默认情况下这些 linter 都是会跑的,当前很多 IDE 在代码修改时会自动执行 go vet,所以我们在写代码的时候一般就能发现这些错了。

但 go vet 还是应该集成到线上流程中,因为有些程序员的下限实在太低。

errcheck

Go 语言中的大多数函数返回字段中都是有 error 的:

func sayhello(wr http.ResponseWriter, r *http.Request) {
 io.WriteString(wr, "hello")
}

func main() {
 http.HandleFunc("/", sayhello)
 http.ListenAndServe(":1314", nil) // 这里返回的 err 没有处理
}

这个例子中,我们没有处理 http.ListenAndServe 函数返回的 error 信息,这会导致我们的程序在启动时发生静默失败。

程序员往往会基于过往经验,对当前的场景产生过度自信,从而忽略掉一些常见函数的返回错误,这样的编程习惯经常为我们带来意外的线上事故。例如,规矩的写法是下面这样的:

data, err := getDataFromRPC()
if err != nil {
 return nil, err
}

// do business logic
age := data.age

而自信的程序员可能会写成这样:

data, _ := getDataFromRPC()

// do business logic
age := data.age

如果底层 RPC 逻辑出错,上层的 data 是个空指针也是很正常的,如果底层函数返回的 err 非空时,我们不应该对其它字段做任何的假设。这里 data 完全有可能是个空指针,造成用户程序 panic。

errcheck 会强制我们在代码中检查并处理 err。

gocyclo

gocyclo 主要用来检查函数的圈复杂度。圈复杂度可以参考下面的定义:

圈复杂度(Cyclomatic complexity)是一种代码复杂度的衡量标准,在 1976 年由 Thomas J. McCabe, Sr. 提出。在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系。

看定义较为复杂但计算还是比较简单的,我们可以认为:

  • 一个 if,圈复杂度 + 1
  • 一个 switch 的 case,圈复杂度 + 1
  • 一个 for 循环,圈复杂度 + 1
  • 一个 && 或 ||,圈复杂度 + 1

在大多数语言中,若函数的圈复杂度超过了 10,那么我们就认为该函数较为复杂,需要做拆解或重构。部分场景可以使用表驱动的方式进行重构。

由于在 Go 语言中,我们使用 if err != nil 来处理错误,所以在一个函数中出现多个 if err != nil 是比较正常的,因此 Go 中函数复杂度的阈值可以稍微调高一些,15 是较为合适的值。

下面是在个人项目 elasticsql 中执行 gocyclo 的结果,输出 top 10 复杂的函数:

~/g/s/g/c/elasticsql git:master ❯❯❯ gocyclo -top 10  ./
23 elasticsql handleSelectWhere select_handler.go:289:1
16 elasticsql handleSelectWhereComparisonExpr select_handler.go:220:1
16 elasticsql handleSelect select_handler.go:11:1
9 elasticsql handleGroupByFuncExprDateHisto select_agg_handler.go:82:1
9 elasticsql handleGroupByFuncExprDateRange select_agg_handler.go:154:1
8 elasticsql buildComparisonExprRightStr select_handler.go:188:1
7 elasticsql TestSupported select_test.go:80:1
7 elasticsql Convert main.go:28:1
7 elasticsql handleGroupByFuncExpr select_agg_handler.go:215:1
6 elasticsql handleSelectWhereOrExpr select_handler.go:157:1

bodyclose

使用 bodyclose[^2] 可以帮我们检查在使用 HTTP 标准库时忘记关闭 http body 导致连接一直被占用的问题。

resp, err := http.Get("http://example.com/") // Wrong case
if err != nil {
 // handle error
}
body, err := ioutil.ReadAll(resp.Body)

像上面这样的例子是不对的,使用标准库很容易犯这样的错。bodyclose 可以直接检查出这个问题:

# command-line-arguments
./httpclient.go:10:23: response body must be closed

所以必须要把 Body 关闭:

resp, err := http.Get("http://example.com/")
if err != nil {
 // handle error
}
defer resp.Body.Close() // OK
body, err := ioutil.ReadAll(resp.Body)

HTTP 标准库的 API 设计的不太好,这个问题更好的避免方法是公司内部将 HTTP client 封装为 SDK,防止用户写出这样不 Close HTTP body 的代码。

sqlrows

与 HTTP 库设计类似,我们在面向数据库编程时,也会碰到 sql.Rows 忘记关闭的问题,导致连接大量被占用。sqlrows[^3] 这个 linter 能帮我们避免这个问题,先来看看错误的写法:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
    return nil, err
}

for rows.Next() {
 err = rows.Scan(...)
 if err != nil {
  return nil, err // NG: this return will not release a connection.
 }
}

正确的写法需要在使用完后关闭 sql.Rows:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
defer rows.Close() // NG: using rows before checking for errors
if err != nil {
    return nil, err
}

与 HTTP 同理,公司内也应该将 DB 查询封装为合理的 SDK,不要让业务使用标准库中的 API,避免上述错误发生。

funlen

funlen[^4] 和 gocyclo 类似,但是这两个 linter 对代码复杂度的视角不太相同,gocyclo 更多关注函数中的逻辑分支,而 funlen 则重点关注函数的长度。默认函数超过 60 行和 40 条语句时,该 linter 即会报警。

linter 集成工具

一个一个去社区里找 linter 来拼搭效率太低,当前社区里已经有了较好的集成工具,早期是 gometalinter,后来性能更好,功能更全的 golangci-lint 逐渐取而代之。目前 golangci-lint 是 Go 社区的绝对主流 linter。

golangci-lint

golangci-lint[^5] 能够通过配置来 enable 很多 linter,基本主流的都包含在内了。

在本节开头讲到的所有 linter 都可以在 golangci-lint 中进行配置,

使用也较为简单,只要在项目目录执行 golangci-lint run . 即可。

~/g/s/g/c/elasticsql git:master ❯❯❯ golangci-lint run .
main.go:36:9: S1034: assigning the result of this type assertion to a variable (switch stmt := stmt.(type)) could eliminate type assertions in switch cases (gosimple)
 switch stmt.(type) {
        ^
main.go:38:34: S1034(related information): could eliminate this type assertion (gosimple)
  dsl, table, err = handleSelect(stmt.(*sqlparser.Select))
                                 ^
main.go:40:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleUpdate(stmt.(*sqlparser.Update))
                      ^
main.go:42:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleInsert(stmt.(*sqlparser.Insert))
                      ^
select_handler.go:192:9: S1034: assigning the result of this type assertion to a variable (switch expr := expr.(type)) could eliminate type assertions in switch cases (gosimple)
 switch expr.(type) {

参考资料

[1] https://revive.run/

[2] https://github.com/timakin/bodyclose

[3] https://github.com/gostaticanalysis/sqlrows

[4] https://github.com/ultraware/funlen

[5] https://github.com/golangci/golangci-lint

滴滴曹乐:如何成为技术大牛?

https://mp.weixin.qq.com/s/blazNWi-z2mAT9U-c6VdPw

桔妹导读:曹乐,清华大学毕业,16年初加入滴滴,带领团队建设了滴滴网约车技术体系,现任滴滴网约车技术部负责人。面对技术团队同学的成长困惑,曹乐给同学们写过一封信,他从各个维度去阐明自己的见解与想法,帮助同学们不再局限于从技术视角去看待问题,而是拥有更广阔的视野与方法。他围绕如何成为技术大牛这一话题提出以下一些想法:寻找范式、刻意练习、及时反馈;垂直打透、横向迁移、深度复盘;聪明人要下笨功夫。在此再次分享给大家这封信的内容。

很多同学都有关于工程师该如何成长的问题,大家普遍对如何成长为牛人,如何获得晋升,如何在繁忙的工作中持续学习充满了困惑,这其实是每一位同学成长过程中必经之路,在这里也想跟大家分享一下我的一些心得。
同学们普遍对成长充满了焦虑感。工作太忙没时间学习,需求太多太琐碎感觉自己没什么进步,做技术是不是做到35岁以后就没人要了,等等,都是对成长焦虑的体现。这种焦虑是正常的,所有的渴望,在内心的投射其实都是焦虑。任何一个渴望成长的人,不管处于什么阶段,一线工程师,架构师,还是总监,副总裁,其实内心中都是充满了焦虑的,无一例外。对于这种焦虑,我们所要做的是接纳,而不需要过度担忧。这种焦虑并不是说,想明白如何成长了就会没有了,到了某个阶段就会没有了的。成长的脚步和期待一刻不止,内心的焦虑也一刻不会停歇。正是这种焦虑感,驱使你写代码追查问题到星夜,驱使你牺牲休息娱乐的时间和一本本厚厚枯燥的书作伴,驱使你不断努力向前,不舍昼夜。相反的,如果内心中没有这种焦虑,反而是值得担忧的。这可能说明已经习惯呆在自己的舒适区了。在现在这样一个高速发展的社会,以及我们这样一个高速发展和变化的行业,失去对成长的渴望和焦虑反而是一个非常危险的信号。
所谓的程序员35岁危机,其实背后的根本原因是,有太多太多人在工作几年以后,就觉得自己什么都会了,之后的十几年工作只不过是头2-3年的简单重复而已。在我们这样一个行业里,在招聘的时候,如果摆在管理面前的两个人,一个是初出茅庐或刚工作2-3年,充满了对成长的渴望;另一个工作十多年了但水平和工作2-3年的人差不多,只是更熟练一些,不过在舒适区已经躺了十年了。如果负责招聘的是你,你会做出什么样的选择?
而另一方面,其实是高端人才在行业内的极度极度稀缺,这在行业内是非常普遍的现象,真正的大牛太稀缺了。在这样一个行业里,如果一个人能够持续成长,能力和工作年限成正比的持续提升,这样的人,任何时候在行业里都是被疯抢,怎么可能会遇到任何年龄的危机呢?
如何学习,其实是有方法论的,那就是刻意练习。所谓的10000小时成为大牛的理论是片面的,如果只是简单重复10000小时,是不可能成为大牛的。刻意练习包含了三个步骤。第一,找到你要学习的这个领域体系的范式(pattern);第二,针对每个范式刻意的反复学习和练习;第三,及时反馈。
大家在过往的工作和学习生活中,或多或少都在实践着刻意练习。拿面临高考的中学生举例子,好的学生通常是把一门功课拆成了很多知识点(寻找pattern),然后针对知识点以及他们的排列组合,有针对性的反复做各种难度的题(刻意练习),每次做完题都对一下答案看看正确与否,如果错了就思考,记录,复盘(持续及时反馈)。这样的学习方法就是事半功倍的。而事倍功半的学习方法,就是不分青红皂白拿起一本习题或卷子就拼命做,我上学的时候身边不少同学非常勤奋但成绩并不好,多半都是这个原因。再举一个我最近在学打羽毛球的例子,正确的学习方法是把打羽毛球拆解成步法和手上动作,小碎步,米字步,正反手挑球,放网,正手和头顶高远球吊球杀球等(寻找pattern),然后针对每一个动作反复练习(刻意练习),然后请教练或者录下来看视频纠正自己的动作(及时反馈);而错误的学习方法是,上来就盲目找人打比赛,以赛代练,这样的进步是很慢的,而且错误的动作形成习惯以后未来反而很难纠正。
当学习方法不正确的时候,刻苦的学习常常只是看起来很勤奋,并没有应有的效果。当接触一个陌生领域的时候,错误的学习方法是不带目的性,上来就找一堆相关的大部头开始啃。而正确的学习方法应该是快速梳理该领域的知识点,形成框架体系(寻找pattern),这里有些小窍门可以快速构建起一个领域的知识点体系,例如看一些该领域的综述性或开创性的文章(看论文,别瞎看网上的文章),或者找本该领域综述性的教科书看它的目录(注意,好的教科书的目录往往就是这个领域的知识框架,内容倒不一定非要看下去)。然后,针对每个知识点,找书里的相关章节,该领域相关paper里的相关section深入学习,建立起自己对这个知识点的理解(刻意练习)。最后,再把知识点和现实工作中的情况(自己工作,或其他公司相关的工作)进行对照(及时反馈),从而建立对一个知识点的深度理解,最后融会贯通建立对一个领域的理解。
这样说可能有点抽象,拿我当年学习分布式存储的过程为例子,先结合自己的工作内容梳理出需要深入了解的知识点(例如,元信息组织,Meta Server设计和HA,副本组织和管理,Recovery,Rebalance,单机存储引擎,数据/元信息流,纠删码,一致性,多租户,存储介质,网络环境和IDC等等),同时看很多综述性的材料,梳理分布式存储的知识点(有网上各种整理的比较好的文章,也有从各种系统实现的paper里抽出),不断迭代构建分布式存储领域的知识点(寻找pattern,这是最难的一个过程);然后针对每一个知识点,找相关材料进行深度学习,例如,对于分布式一致性,需要阅读CAP理论,Paxos的论文,Raft的论文等等以及周边的很多材料(刻意练习);然后找各种系统实现的论文或文章,比如GFS,Dynamo,Aurora,OceanBase,Ceph,Spanner等等,看看和对比它们在一致性上是如何考虑和取舍的,当然,最重要的是结合自己工作中的反复实践和所学知识点进行比对(及时反馈)。
这三个阶段并不是割裂的,而是周而复始的,经常会在刻意练习和及时反馈的学习过程中,发现自己遗漏的知识点,或者发现自己梳理的两个知识点其实是重合的。通过这种交叉比对,以及在实践中不断检验的方式建立的知识点是非常可落地的,而不会看了几篇论文以后就人云亦云。拿分布式存储的一致性举例子,如果不是反复对比、思考和反复实践,你不会发现GFS论文里最难的一段,多个Writer对一个文件进行append的逻辑,在实践中根本没用;你也不会发现看起来优雅而学术的CAP三选二的理论,实践中压根不是这么完美,很多时候只能三选一;你也不会发现Dynamo论文里的Vector Clock,网上有无数文章摇头晃脑的解读,但在Amazon的应用场景里是个典型的over design,Cassandra在这点就务实很多。
这时候大家可能会有个疑问,工作本身就如此繁忙了,哪里能抽出足够多的时间去学习?
其实工作和学习本身,是不应该被割裂的。工作本来就应该是学习的一部分,是学习中的实践和及时反馈的部分。学习如果脱离工作的实践,其实是非常低效的。因此每个同学应该对自己工作所在的这个技术和业务领域进行系统性的学习,并在工作中反复实践和验证。不同的领域之间其实是融汇贯通的,当你对一个领域精通并总结出方法论以后,很容易就能上手别的领域。因此花几年实践彻底研究透一个领域,对于刚工作几年的同学来说,是非常重要,甚至是必须的,也只有在一个领域打透之后才谈得上跨领域迁移,去拓展自己的知识面。更直接的说,对于一个领域还未完全掌握的同学,深度是最重要的,不用想广度的事情,等掌握了一个领域之后,再去拓展广度就变得很容易了。
这里一个常见的误区是,学习的内容和工作的领域没有太多直接的关系。例如,我以前曾经花了非常大的功夫去读Linux内核的源代码以及很多相关的大部头,几乎花掉了我将近两年的所有空闲时间,然而在我这些年的工作里,几乎是没有用处的,最多就是有一些“启发”,ROI实在是太低了,现在也忘得差不多了。更重要的,软件工程是一门实践科学,从书本上得到的知识如果没有在实践中应用和检验,基本上是没有用处的。举一个例子,很多优秀的架构师,尽管日常工作中可能反复在用,但未必说得出开闭原则,里氏替换原则,迪米特法则等等,反过来,对面向对象设计这7大原则出口成章的人,很多其实离真正的架构师还远得很,有些甚至只是博客架构师而已。实践远远比看书,看文章重要得多,上文所述的我构建自己分布式存储知识体系的过程,看起来好像都是看材料,看论文,而实际上80%的收获都来源于带着理论的实践,和从实践中总结沉淀的理论。因此,彻底搞明白自己工作所在的技术和业务领域,是最务实高效的做法,工作和学习割裂,会导致工作和学习都没做好。
这时候大家可能会有另一个疑问,感觉日常工作非常琐碎,学不到什么东西,怎么办?
如果把学习分成从书本中学,和从工作中学这两种的话,那毫无疑问,工作中的“知识密度”,比起书本的“知识密度”,肯定是要低很多的,因为书本里的知识,那都是人家从他们的工作中抽象总结出来的。这也是为什么大家普遍觉得日常工作“琐碎”。然而工作中每个点滴的琐事与平凡,都是可以抽象总结成为方法论的,更别说工作所在的领域自身的博大精深了。从日常工作中学习的秘诀,就是“行动中思考”。
对于每一个软件工程师,最重要的两个能力,是写代码的能力和trouble shooting的能力。并且,要成为优秀的架构师,出色的开发能力和追查问题的能力是一切的基础。提高写代码的能力的核心,首先在于坚持不断的写,但更重要的,在于每天,每周,持续不断的review自己之前的代码;同时,多review牛人写的代码,比如是团队里你觉得代码写的比你好的同事,比如社区里以代码漂亮著称的开源代码(作为一个C++程序员,当年我的榜样之一是boost库)。一旦觉得自己之前的代码不够好,就立刻复盘,立刻重构。更重要的是,多思考自己代码和好的代码之间不同之处背后的为什么,通常这就是为什么这些代码更好的背后的秘密。
特别要说明的是,代码规范除了知道是什么外,要格外重视思考每一个代码规范背后的为什么。代码规范的每一句话,背后无一例外都是一片江湖上的血泪史。要提高trouble shooting的能力,关键在于要深度复盘自己遇到的每一个问题,包括线上的,包括测试发现的,寻找每一个问题,每一次事故背后的root cause,并且思考后续如何避免同类问题,如何更快的发现同类问题。要对团队内外遇到的所有问题都要保持好奇心,关注一下周边的事故、问题背后的root cause。Trouble shooting能力的提高是几乎无法从书本上得到的,完全来源于对每一个问题的深度思考,以及广泛积累每一个问题。对于架构师而言,可能未必在一线写代码了,但看团队中一个架构师是否真正牛逼的一个很重要标准,就是看他是否能够追查出团队其他同学查不出来的问题。我见过的一个真正牛的架构师,对于系统中疑难杂症,通常问几个问题,就能大致猜出是哪里出的问题,以及可能的原因是什么,准确程度如同算命,屡试不爽,令人叹为观止。
对于一个架构师,除了更加优秀的代码能力和trouble shooting能力外,需要构建相对完整的当前技术领域的知识体系,需要有体系化的思维能力,需要对技术所服务的业务有非常深入的了解。体系化的思维能力,来源于两个方面。一方面是在日常工作中,对每一个接口设计,每一个逻辑,每一个模块、子系统的拆分和组织方式,每一个需求的技术方案,每一个系统的顶层设计,都要反复思考和推敲,不断地复盘。另一方面,需要大量广泛地学习行业内相似系统的架构设计,这其实就是开天眼,只是技术相对来说,行业内的交流更加频繁。淘宝、美团、百度、Google、Facebook、Amazon等各个公司介绍系统架构的论文和PPT铺天盖地,需要带着问题持续学习。除了技术领域本身外,架构师需要非常了解业务上是如何使用我们的系统的,否则非常容易over design,陷入技术的自嗨中,这也是为什么我说Amazon Dynamo论文里讲的Vector Clock是个over design的原因。
另一方面,很多时候技术上绕不过去的坎,可能非常复杂的实现,往往只需要上层业务稍微变通一下,就完全可以绕过去,这也是为什么我说GFS论文里,多个Writer同时Append同一个文件是个根本没用的设计(实际上Google内部也把这个功能去掉了)。只有真正知道上层业务是如何使用系统的,才可能真正做好架构。深入了解业务并不难,对于每个同学,只要对于每一个接到的需求,对于每一个需求评审中的需求,对于周边同学或团队要做的需求,都深入思考为什么业务要提出这个需求,这个需求解决了业务的什么问题,有没有更好的方案。遇到不明白的多和周边同学、产品、运营同学请教。最怕的是自己把自己限定为纯粹的研发,接到需求就无脑做,这等于放弃了主动思考。衡量一个人是不是好的架构师,也有一个方法。对于一个需求,如果他给出了好几个可行的方案,说这些方案也可以,那些方案也可以,往往说明他在架构师的路上还没有完全入门。架构师的难点不在于给出方案,而在于找到唯一的那一个最简单优雅的方案。
总结起来看,行动中思考,就是始终保持好奇,不断从工作中发现问题,不断带着问题回到工作中去;不断思考,不断在工作中验证思考;不断从工作中总结抽象,不断对工作进行复盘,持续不断把工作内容和全领域的知识交叉验证,反复实践的过程。
在工作所在的技术和业务领域中刻意练习,加上行动中思考,就是成为技术大牛的秘诀。
看起来方法也不复杂,为什么大牛还是非常稀少?
尽管我们通篇都在讲方法,但其实在成为技术大牛的路上,方法反而是没那么重要的。真正困难的,在于数年,数十年如一日的坚持。太多人遇到挫折,遇到瓶颈,就觉得手头的事情太乏味枯燥,就想要换一个方向,换一个领域,去学新的技术,新的东西。而真正能够成为大牛的,必须是能够青灯古佛,熬得住突破瓶颈前长时间的寂寞的,必须是肯下笨功夫的聪明人。因此,和坚持相比,方法其实并没有那么重要。