高质量编程与性能调试 | 青训营

97 阅读9分钟

Part 1 引言

  在业务环境中

  • 如何编写更简洁清晰的代码

  • 常用Go语言程序优化手段

  • 熟悉Go程序性能分析工具

  • 了解工程中性能优化的原则和流程

    显得尤其重要,所以本篇文章将主要围绕高质量编程以及性能调优俩方面入手

Part 2 高质量编程

  本节主要简要介绍了高质量编程的定义和原则,分享了代码格式、注释、命名规范、控制流程、错误和异常处理五方面的常见编码规范。

  首先,什么是高质量——编写代码达到正确可靠、简介清晰的目标称之为高质量代码

  • 各种边界条件是否考虑完备
  • 异常情况处理,稳定性保证
  • 易读易维护

  编程原则

  实际应用场景千变万化,各种语言的特性和语法各不相同,但是高质量编程遵循的原则是相通的

  1. 简单性
    消除“多余的复杂性”,以简单清晰的逻辑编写代码
    不理解的代码无法修复改进

  2. 可读性
    代码是写给人看的,而不是机器
    编写可维护代码的第一步是确保代码可读

  3. 生产力
    团队整体工作效率非常重要

编码规范

  如何编写高质量的Go代码 需要我们遵守以下的公共约定

  • 代码格式

    推荐使用 gofmt​​ 自动格式化代码

    Go语言官方提供的工具,能自动格式化Go语言代码为官方统一风格

    常见IDE都支持方便的配置

    goimports​​ = gofmt​​ + 依赖包管理。

    其也是GO语言官方提供的工具,自动增删依赖的包引用、将依赖包按字母序排序并分类

    当然在IDE中我们也可以打开 自动格式化代码

  • 注释

    好的代码有很多注释,坏代码需要很多注释

    可以在 GitHub​​ / 官方文档 参考开发者给出的注释规范,代码是最好的注释

    Should do

    1. 解释代码作用——适合注释公共符号

      ✅包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
      ✅任何既不明显也不简短的公共功能必须予以注释
      ✅无论长度或复杂程度如何,对库中的任何函数都必须进行注释

      ❌不需要注释实现接口的方法,如下所示

      //Read implements the io.Reader interface
      funcr *FileReader) Read(buf []byte)(int,error
    2. 解释代码如何做的——适合注释实现过程

    3. 解释代码实现的原因——适合解释代码外部因素、提供额外上下文

    4. 解释代码什么情况会出错——适合解释代码的限制条件

  • 命名规范

    好的命名就像一个好笑话。如果你必须解释它,那就不好笑了

    核心目的——降低阅读理解代码的成本,综合考虑上下文设计简洁清晰的名称

    1. variable 变量

      • 变量距离其被使用的地方越远,则需要携带越多的上下文信息

      • 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义

      • 简洁胜于穴长,当穴长没有增加对程序的理解时可以考虑简洁的命名

      • 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写

        ✅ServeHTTP ❌ServeHttp
        ✅XMLHTTPRequest ❌xmlHTTPRequest

    2. function 函数

      • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
      • 函数名尽量简短
      • 当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
      • 当名为foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息
    3. package 包

      • 只由小写字母组成。不包含大写字母和下划线等字符
      • 简短并包含一定的上下文信息。例如 schema​、task​ 等
      • 不要与标准库同名。例如不要使用 sync​ 或者 strings

      尽量满足

      • 不使用常见变量名作为包名
      • 使用单数而不是复数
      • 谨慎使用缩写,在不破坏上下文的理解情况下适当缩写
  • 控制流程

    1. 避免嵌套,保证正常流程清晰

      eg: if else​ 语句若俩个分支都包含 return​ 语句,可以去除冗(rong)余

    2. 尽量保持正常代码路径为最小缩进

      优先处理错误情况,尽早返回或继续循环来减少嵌套

      总结

      • 线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支
      • 正常流程代码沿着屏幕向下移动
      • 提升代码可维护性和可读性
      • 故障问题大多出现在复杂的条件语句和循环语句中

错误和异常处理

  1. 简单错误

    • 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
    • 优先使用 errors.New​​ 来创建匿名变量来直接表示简单错误
    • 如果有格式化的需求,使用 fmt.Errorf​​
  2. 错误的 Wrap 和 Unwarp

    • 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链

    • fmt.Errorf​​中使用:%w​​关键字来将一个错误关联至错误链中

      if err != nil {
              return fmt.Errorf("bar failed: %w", err)
      }
      

      需要注意的是 %w​​ 的特性,是在Go 1.13 版本之后新加的

      Go1.13在errors中新增了三个新API和一个新的format关键字,分别是 errors.lserrors.As,errors.Unwrap以及 fmt.Errorf 的 %w。如果项目运行在小于Go1.13的版本中,导入
      golang.org/x/xerrors来使用

    • panic

      1. 不建议在业务代码中使用panic
      2. 调用函数不包含recover会造成程序崩溃
      3. 若问题可以被屏蔽或解决,建议使用 error​ 代替 panic
      4. 当程序启动阶段发生不可逆转的错误时,可以在 init​ 或 main​ 函数中使用 panic
    • recover

      1. recover 只能在被defer的函数中使用
      2. 嵌套无法生效
      3. 只在当前goroutine生效
      4. defer的语句是后进先出
      5. 如果需要更多的上下文信息,可以 recover 后在log 中记录当前的调用栈

    总结

    • error​​ 尽可能提供简明的上下文信息链,方便定位问题

    • panic​​ 用于真正异常的情况

    • recover​​ 生效范围,在当前goroutine的被defer的函数中生效

题外话

  入营考试题 - 程序的输出是什么?

func main() { //输出结果为31
	if true {
		defer fmt.Printf("1")
	} else {
		defer fmt.Printf("2")
	}
	defer fmt.Printf("3")
}
  • defer​ 语句会在函数返回前调用
  • 多个 defer​​ 语句是后进先出

Part 3 Go 语言 程序性能优化

  引言

  • 性能优化的前提是满足正确可靠、简洁清晰等质量因素
  • 性能优化是综合评估,有时候时间效率和空间效率可能对立
  • 针对GO语言特性,介绍GO相关的性能优化建议

  如何评估代码的性能表现?

  在这里需要使用到Go语言提供的支持基准性能测试的 benchmark​ 工具,性能数据需要用数据衡量

go test -bench=. -benchmem

  当然执行命令后对于得到的结果,也需要我们能够看懂,才能知道还能在哪些地方下刀

BenchmarkFib10-8	1855870		602.5ns/op	0 B/op		0 allocs/op

  测试结果说明:

  1. BenchmarkFib10​是测试函数名 -8​表示GOMAXPROCS的值为8

    GOMAXPROCS 1.5版本后,默认值为CPU核数 参考链接:pkg-go.dev/runtime#GOM…

  2. 1855870​ 表示一共执行1855870次 即b.N的值

  3. 602.5ns/op​ 每次执行花费602.5ns

  4. 0 B/op​ 每次执行申请多大的内存

  5. 0 allocs/op​​ 每次执行申请几次内存

性能优化建议

slice 预分配内存

  1. 尽可能在使用 make()​ 初始化切片时提供容量信息

    原因是由于其本质的底层数据结构 扩容,需要时间

  2. 大内存未释放

    • 在已有切片基础上创建切片,不会创建新的底层数组场景

    • 场景

      • 原切片较大,代码在原切片基础上新建小切片
      • 原底层数组在内存中有引用,得不到释放
    • 可使用copy替代re-slice

map 预分配内存

  分析

  • 不断向 map​ 中添加元素的操作会触发 map​ 的扩容
  • 提前分配好空间可以减少内存拷贝和 Rehash​ 的消耗
  • 建议根据实际需求提前预估好需要的空间

字符串处理

  对于字符串处理常见拼接方式进行性能测试

  一共三种方式,分别是

  • strings.Builder
  • "+" 拼接
  • bytes.Buffer

  测试结果:

  使用+拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Buffer更快

  分析:

  1. 字符串在Go语言中是不可变类型,占用内存大小是固定的

  2. 使用+每次都会重新分配内存。

  3. strings.Builder,bytes.Buffer底层都是byte数组

  4. 内存扩容策略,不需要每次拼接重新分配内存

空结构体

  使用空结构体节省内存

  • 空结构体 struct{}​ 实例不占据任何的内存空间

  • 可作为各种场景下的占位符使用

    • 节省资源
    • 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符

atomic 包

使用 atomic​ 包 多线程

  • 锁的实现是通过操作系统来实现,属于系统调用
  • atomic操作是通过硬件实现,效率比锁高
  • sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}

优化建议总结

  1. 避免常见的性能陷阱可以保证大部分程序的性能
  2. 普通应用代码,不要一味地追求程序的性能
  3. 越高级的性能优化手段越容易出现问题
  4. 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能

  ‍