这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记
1. 前言
本篇主要介绍Go语言项目如何基于基本编程原则与规范从需求设计到性能调优的相关内容(以用户话题讨论功能为例)。
了解到,编码规范或者性能优化建议大部分是通用的;Go 语言本身在持续更新迭代,每个版本在性能上有着不同的优化。
2. 需求设计
需求描述
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅是先一个本地的web服务
- 话题和回帖数据用文件存储
需求用例
页面是面向用户去消费浏览的,可以从中抽出两个实体:
- Topic话题
- PostList帖子
ER 图
在模型分析设计时经常用到,表征了现实世界的概念模型
-
有了这个模型,实体与属性之间的关系为我们后续的开发能够提供清晰的思路
-
帖子的属性topic_id:每个帖子需要关联到一个话题上
-
话题和帖子是一对多的关系
分层结构
分层结构能够做到一定程度上的领域分割,提高代码的可读性
-
数据层:关联底层的数据Model,封装外部数据的增删改查
-
针对需求,数据会存储在本地文件上,通过本地的操作来拉取话题和帖子的数据。
-
主要面向Service层,对其透明(数据层会屏蔽下游的数据差异:需求是一个File,可能把数据存在DB或下游的一个微服务里,这样的话Repository对Service暴露的函数/接口是可以不变的,也就是Service不需要关心底层数据的存储,只需要拿到Repository返回的一个Model数据就OK)
-
-
逻辑层:业务Entity,处理核心业务逻辑输出
-
通过接受Repository的数据做打包分装,会输出一个实体Entity
-
对于需求而言,Entity就是话题页面
-
-
视图层:试图view,处理和外部的交互逻辑
-
对上游负责,包装一些数据格式
-
例如,对于需求,进行JSON格式化,做一些封装,通过API的形式进行访问
-
组件工具
-
Gin高性能go web框架
https://github.com/gin-gonic/gin#installation -
Go Mod
go mod initgo get gopkg.in/gin-gonic/gin.v1@1.3.0:下载gin的依赖,执行之后可以看到go.mod中存在gin的依赖
3. 编程原则&规范
编程原则
实际应用场景千变万化,各种语言的特性和语法各不相同,但是高质量编程遵循的原则是相通的
高质量的编程需要注意以下原则:
-
简单性
-
消除“多余的复杂性”,以简单清晰的逻辑编写代码
-
不理解的代码无法修复改进
-
-
可读性
-
代码是写给人看的,而不是机器
-
编写可维护代码的第一步是确保代码可读
-
-
生产力
- 团队整体工作效率非常重要
编码规范
-
代码格式
推荐使用gofmt自动格式化代码
-
gofmt
-
是Go语言官方提供的工具,能自动格式化Go语言代码为官方统一风格
-
常见IDE都支持方便的配置
-
-
goimports
-
也是Go语言官方提供的工具
-
实际等于gofmt加上依赖包管理
-
自动增删依赖的包引用,将依赖包按字母排序并分类
-
-
-
注释
代码是最好的注释
注释应该提供代码未表达出的上下文信息-
注释应该提供
-
代码未表达出的上下文信息
-
解释代码作用
- 适合注释公共符号,github.com/golang/go/b…
-
-
注释应该解释
-
代码如何做的
- 适合注释方法,github.com/golang/go/b…
-
代码实现的原因
-
代码的外部因素,github.com/golang/go/b…
-
代码什么情况会出错
-
适合解释代码的限制条件
-
-
公共符号始终要注释
-
包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
-
任何既不明显也不简短的公共功能必须予以注释
-
Good code has lots of comments, bad code requires lots of comments
-----Dave Thomas and Andrew Hunt
-
-
命名规范
-
variable
// Good func (c *Client) send(req *Request, deadline time.Time) // Bad func (c *Client) send(req * Request, t time.Time)-
将deadline替换成t降低了变量名的信息量
-
t常代指任意时间
-
deadline指截止时间,有特定的含义
-
-
function
-
函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
-
函数名尽量简短
-
当名为foo 的包某个函数返回类型 Foo 时,可以省略类型信息而不导致岐义
-
当名为 foo 的包某个函数返回类型T时(T并不是 Foo),可以在函数名中加入类型信息
-
-
package
-
只由小写字母组成。不包含大写字母和下划线等字符
-
简短并包含一定的上下文信息。例如 schema、task 等
-
不要与标准库同名。例如不要使用 syno 或者 strings
-
以下规则尽量满足,以标准库包名为例
-
不使用常用变量名作为包名。例如使用bufio 而不是 but
-
使用单数而不是复数。例如使用 encoding 而不是 encodings
-
谨慎地使用缩写。例如使用 fmt 在不破坏上下文的情况下比 format 更加简短
小结
-
核心目标是降低阅读理解代码的成本
-
重点考虑上下文信息,设计简洁清晰的名称
Good naming is like a good joke. If you have to explain it, it's not funny
---- Dave Cheney
-
-
控制流程
- 避免嵌套,保持正常流程清晰
// Bad if foo { return x } else { return nil } // Good if foo { return x } return nil-
如果两个分支中都包含return语句,则可以取出冗余的else
-
尽量保持正常代码路径为最小缩进
// 调整前 func OneFunc() error { err := doSomething() if err == nil { err := doAnotherThing() if err == nil { return nil // normal case } return err } return err } // 调整后 func OneFunc() error { if err := doSomething(); err != nil { return err } if err := doAnotherThing(); err != nil { return err } return nil // normal case }-
优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套
-
最常见的正常流程的路径被嵌套在两个 if 条件内
-
成功的退出条件是return nil, 必须仔细匹配大括号来发现
-
函数最后一行返回一个错误,需要追溯到匹配的左括号,才能了解何时会触发错误
-
如果后续正常流程需要增加一步操作,调用新的函数,则又会增加一层嵌套
小结
-
线性原理,处理逻辐尽量走直线,避免复杂的嵌套分支
-
正常流程代码沿着屏幕向下移动
-
提升代码可维护性和可读性
-
故障问题大多出现在复杂的条件语句和循环语句中
4. 错误&异常处理
错误
-
简单错误
func defaultCheckRedirect(req *Request, via []*Request) error { if len(via) >= 10 { return errors.New("stopped after 10 redirects") } return nil }-
简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
-
优先使用 errors.New 来创建匿名变量来直接表示简单错误
-
如果有格式化的需求,使用 fmt.Errorf
-
-
错误的Wrap和Unwarp
list, _, err := c.GetBytes(cache.Subkey(a.actionID, "srcfiles")) if err != nil { return fmt.Errorf("reading srcfiles list: %w", err) }-
错误的 Wrap 实际上是提供了一个error 嵌套另一个 error 的能力,从而生成一个 error 的跟踪链
-
在fmt.Errorf 中使用:%w 关键字来将一个错误关联至 错误链中
Go1.13 在errors 中新增了三个新AP(和一个新的 format 关键宇,分别是 errors.Is errors.As, errors.Unwrap 以及 fmt.Errorf 的 %w。如果项目 运行在小于 Go1.13 的版本中,导入 golang.org/x/xerrors 来使用
-
-
错误判定
- 在错误链上获取特定种类的错误,使用
errors.As
if _, err := os.Open("non-existing"); err != nil { var pathError * fs.PathError if errors.As(err, &pathError) { fmt.Println("Failed at path:", pathError.Path) } else { fmt.Println(err) } } - 在错误链上获取特定种类的错误,使用
异常处理
-
panic
-
不建议在业务代码中使用 panic
-
调用函数不包含 recover 会造成程序崩溃
-
若问题可以被屏蔽或解決,建议使用 error 代替 panic
-
当程序启动阶段发生不可逆转的错误时, 可以在 init 或 main 函数中使用 panic
-
-
recover
-
recover 只能在被 defer 的函数中使用
-
嵌套无法生效
-
只在当前 goroutine 生效
-
defer 的语句是后进先出
-
如果需要更多的上下文信息,可以revocer后在log中记录当前的调用栈
-
小结
-
error 尽可能提供简明的上下文信息链,方便定位问题
-
panic 用于真正异常的情况
-
recover 生效范围,在当前 goroutine 的被 defer 的函数中生效
5. 性能调优
Go语言内置了获取程序的运行数据的工具,包括以下两个标准库:
runtime/pprof:采集工具型应用运行数据进行分析net/http/pprof:采集服务型应用运行时数据进行分析
pprof开启后,每隔一段时间(10ms)就会收集下当前的堆栈信息,获取格格函数占用的CPU以及内存资源;最后通过对这些采样数据进行分析,形成一个性能分析报告。
注意,我们只应该在性能测试的时候才在代码中引入pprof。
定义
-
性能优化的前提是满足正确可靠、简洁清晰等质量因素
-
性能优化是综合评估,有时候时间效率和空间效率可能对立
-
针对 Go 语言特性,介绍 Go 相关的性能优化建议
优化指标
Go语言项目中的性能优化主要有以下几个方面:
-
CPU性能分析
开启CPU性能分析:
pprof.StartCPUProfile(w io.Writer)停止CPU性能分析:
pprof.StopCPUProfile()应用执行结束后,就会生成一个文件,保存了我们的 CPU profiling 数据。得到采样数据之后,使用
go tool pprof工具进行CPU性能分析。- CPU profile:报告程序的 CPU 使用情况,按照一定频率去采集应用程序在 CPU 和寄存器上面的数据
-
内存性能优化
记录程序的堆栈信息
pprof.WriteHeapProfile(w io.Writer)得到采样数据之后,使用
go tool pprof工具进行内存性能分析。go tool pprof默认是使用-inuse_space进行统计,还可以使用-inuse-objects查看分配对象的数量。- Memory Profile(Heap Profile):报告程序的内存使用情况
-
Block Profiling:报告 goroutines 不在运行状态的情况,可以用来分析和查找死锁等性能瓶颈
-
Goroutine Profiling:报告 goroutines 的使用情况,有哪些 goroutine,它们的调用关系是怎样的