痛点:
- 工程刚开始非常整洁,随着时间的流逝,逐渐变得不太好维护了..
- 多人开发同一工程时,架构层次不清晰,重复造轮子?
- 接手了一个旧工程,如何快速理解架构与设计,从而快速上手做需求?
有规范的好处:
- 利于多人合作开发&理解同一模块/工程。
- 降低团队成员之间的代码沟通成本。
- 架构&代码规范明确,有效提高编码效率。
前言:
读这本书的时,第一个想到的问题就是:“什么是整洁的代码?”
书中列举了各位程序员鼻祖的名言,我整理总结了下,大概有下面几条:
- 逻辑直截了当,令缺陷难以隐藏 。
- 减少依赖关系,便于维护。
- 合理分层,完善错误处理 。
- 只做好一件事。没有重复代码。
代码是团队沟通的一种方式
工作的沟通,不只是每天lark拉群或者开会交流,代码也是我们很重要的沟通方式之一。
用代码写完需求,只是万里长征的第一步。我们要用代码表达自己的设计思想。如果我们团队大部分人都能按照一定规范、思路去写代码。那么,工作沟通成本会降低许多。
比如:某位同学之前负责的一个模块,被另一位同事接手了,或者随着业务的扩张,我们多个同学共同开发同一个工程/模块。如果我们的代码结构大同小异,分层清晰、注释合理,就会降低很多沟通成本。
因此,我们需要为团队创造整洁的代码。
一是降低团队内的代码沟通成本,二是便于今后项目需求的维护与迭代。
让营地比来时更整洁
随着需求的不断迭代,保持代码整洁、工程更易理解。
有时候,我们会维护一些老项目,或者交接过来的项目。代码可能不太美观,工程可能不太好理解。
一般我们会面临两种选择:
- 重构
- 优化迭代
重构的成本比较高,得先理解原有逻辑,再进行重新设计落地。代价大,周期长,短期看不到效果。
在人力有限的情况下。我们一般会先选择“优化迭代”。
这时候,我们每做一个新需求 / 修复一个bug时,我们要尽可能的去小范围“重构”。
每一次Merge
,代码都比之前更干净,工程变得更好理解。那么,我们的工程就不会变的更糟。
清理不一定要花多少功夫。也许只是改一个更加容易理解的命名;抽象一个函数,消除一点重复/冗余代码;处理一下嵌套的 if / else 等等。
一、有意义的命名
名副其实:
起有意义的名字,让人一目了然。
一看这个变量,就能知道它存储的是什么对象。
一看这个方法,就能知道它处理的是什么事。
一看这个包名,就能知道它负责处理哪个模块。
看看反例:
var array []int64
var theList []int64
var num int64
看看正例:
var mrList []*MRInfo
var buildNum int64
避免误导:
不要用太长或者很偏僻的单词来命名,也不要用拼音代替英文。
更不要用容易混淆的字母(字母+数字)。尤其是l
和O
两个字母,和数字1和0太像了。
看看反例:
func getDiZhi() string {
// ..
}
func modifyPassword(password1, password2 string) string {
// ..
}
看看正例:
func getAddress() string {
// ..
}
func modifyPassword(oldPassword, newPassword string) string {
// ..
}
有意义的区分:
声明两个同类型的变量/函数,需要用有明确意义的命名加以区分。
看看反例:
var accountData []*Account
var account []*Account
func Account(id int) *Account {
// ...
}
func AccountData(id int) *Account {
// ...
}
可读可搜索:
起可读的,可以被搜索的名字。
看看反例:
var ymdhms = "2021-08-04 01:55:55"
var a = 1
看看正例:
var date = "2021-08-04 01:55:55"
var buildNum = 1
命名规范(重点)
package
- 同一项目下,不允许出现同名的package。
- 只由小写字母组成。不包含大写字母和下划线等字符。
- 简短并包含一定的上下文信息。例如
time
、http
等。 - 不能是含义模糊的常用名,或者与标准库同名。例如不能使用
util
或者strings
。 - 包名能够作为路径的 base name,在一些必要的时候,需要把不同功能拆分为子包。(例如应该使用
encoding/base64
而不是encoding_base64
或者encodingbase64
。)
以下规则按照先后顺序尽量满足:
- 不使用常用变量名作为包名。
- 使用单数而不是复数。(关键字除外,例如
consts
) - 谨慎地使用缩写,保证理解。
文件名
- 文件名都使用小写字母,且使用单数形式,如需要可使用下划线分割。
函数和方法
Function 的命名应该遵循如下原则:
- 对于可导出的函数使用大写字母开头,对于内部使用的函数使用小写字母开头。
- 若函数或方法为判断类型(返回值主要为 bool 类型),则名称应以 has, is, can 等判断性动词开头。
// HasPrefix tests whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {...
- 函数采用驼峰命名,不能使用下划线,不能重复包名前缀。例如使用
http.Server
而不是http.HTTPServer
,因为包名和函数名总是成对出现的。
// WriteRune appends the UTF-8 encoding of Unicode code point r to b's buffer.
// It returns the length of r and a nil error.
func (b *Builder) WriteRune(r rune) (int, error) {...
- 遵守简单的原则,不应该像 ToString 这类的方法名,而直接使用 String 代替。
// String returns the accumulated string.
func (b *Builder) String() string {...
- Receiver 要尽量简短并有意义
- 不要使用面向对象编程中的常用名。例如不要使用
self
、this
、me
等。 - 一般使用 1 到 2 个字母的缩写代表其原来的类型。例如类型为
Client
,可以使用c
、cl
等。 - 在每个此类型的方法中使用统一的缩写。例如在其中一个方法中使用了
c
代表了Client
,在其他的方法中也要使用c
而不能使用诸如cl
的命名。
- 不要使用面向对象编程中的常用名。例如不要使用
func (r *Reader) Len() int {...
常量
- 常量使用驼峰形式。(尽量不要用下划线)
const AppVersion = "1.1.1"
- 如果是枚举类型的常量,需要先创建相应类型:
type Scheme string
const (
HTTP Scheme = "http"
HTTPS Scheme = "https"
)
变量
- 变量命名基本上遵循相应的英文表达或简写。
- 采用驼峰命名,不能使用下划线。首字母是否大写根据是否需要外部访问来定。
- 遇到专有名词时,可以不改变原来的写法。例如:
{
"API": true,
"ASCII": true,
"CPU": true,
"CSS": true,
"DNS": true,
"EOF": true,
"GUID": true,
"HTML": true,
"HTTP": true,
"HTTPS": true,
"ID": true,
"IP": true,
"JSON": true,
"LHS": true,
"QPS": true,
"RAM": true,
"RHS": true,
"RPC": true,
"SLA": true,
"SMTP": true,
"SSH": true,
"TLS": true,
"TTL": true,
"UI": true,
"UID": true,
"UUID": true,
"URI": true,
"URL": true,
"UTF8": true,
"VM": true,
"XML": true,
"XSRF": true,
"XSS": true,
}
二、函数
短小
尽可能的缩短每个函数的长度。能抽象就抽象。
任何一个函数都不应该超过50
行。甚至,20
行封顶最佳。(PS:16寸mac满屏是60多行)
想象下,如果有个几百行,甚至上千行的函数。后面维护得多困难。
单参数
每个函数最理想应该是有0或1个入参。
尽量不要超过三个入参。如果超过,建议封装成结构体。
只做一件事
函数应该只做一件事,做好这件事,只做这一件事。
抽象层级
按顺序,自顶向下读代码/写代码。
看看反例:
// 更新组件升级结果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
// 更新组件核心表,写了20行
// 更新历史,写了40行
// 更新构建产物,写了20行
// ...代码越来越多,越来越不好维护。
return nil
}
看看正例:
// 更新组件升级结果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
// 更新组件
err = updatePodMain(ctx, req)
if err != nil {
return err
}
// 更新历史
err = updatePodHistory(ctx, req)
if err != nil {
return err
}
// 更新Builds
err = updatePodBuilds(ctx, req)
if err != nil {
return err
}
return nil
}
func updatePodMain(ctx context.Context, req *UpdatePodReq) error {
// ...
}
func updatePodHistory(ctx context.Context, req *UpdatePodReq) error {
// ...
}
func updatePodBuilds(ctx context.Context, req *UpdatePodReq) error {
// ...
}
尽量少嵌套 if / else
看看反例:
func GetItem(extension string) (Item, error) {
if refIface, ok := db.ReferenceCache.Get(extension); ok {
if ref, ok := refIface.(string); ok {
if itemIface, ok := db.ItemCache.Get(ref); ok {
if item, ok := itemIface.(Item); ok {
if item.Active {
return Item, nil
} else {
return EmptyItem, errors.New("no active item found in cache")
}
} else {
return EmptyItem, errors.New("could not cast cache interface to Item")
}
} else {
return EmptyItem, errors.New("extension was not found in cache reference")
}
} else {
return EmptyItem, errors.New("could not cast cache reference interface to Item")
}
}
return EmptyItem, errors.New("reference not found in cache")
}
看看正例:
func GetItem(extension string) (Item, error) {
refIface, ok := db.ReferenceCache.Get(extension)
if !ok {
return EmptyItem, errors.New("reference not found in cache")
}
ref, ok := refIface.(string)
if !ok {
// return cast error on reference
}
itemIface, ok := db.ItemCache.Get(ref)
if !ok {
// return no item found in cache by reference
}
item, ok := itemIface.(Item)
if !ok {
// return cast error on item interface
}
if !item.Active {
// return no item active
}
return Item, nil
}
安全并发处理(SafeGo)
建议:开协程的地方,尽量使用SafeGo(内部有 recover 以及打印 panic 堆栈日志)
func SafeGo(ctx context.Context, f func()) {
go func() {
defer func() {
if err := recover(); err != nil {
content := fmt.Sprintf("Safe Go Capture Panic In Go Groutine\n%s", string(debug.Stack())){
logs.CtxFatal(ctx, content)
}
}
}()
f()
}()
}
For 循环并发处理(Routine Pool)
for 循环开协程时,优先考虑使用封装的
Routine Pool
(协程池)控制并发量。
好处:
- 避免协程创建过多,导致程序崩溃。(对服务本身)
- 控制流量速度,防止把下游服务打雪崩。(对下游服务)
参考代码:
type content struct {
work func() error
end *struct{}
}
func work(w func() error) content {
return content{work: w}
}
func end() content {
return content{end: &struct{}{}}
}
// Goroutine routine_pool
type RoutinePool struct {
capacity uint
ch chan content
}
func NewRoutinePool(ctx context.Context, capacity uint) *RoutinePool {
ch := make(chan content)
pool := RoutinePool{
capacity: capacity,
ch: ch,
}
for i := uint(0); i < capacity; i++ {
SafeGo(ctx, func() {
for {
select {
case cont := <-ch:
if cont.end != nil {
return
}
if cont.work != nil {
if err := cont.work(); err != nil {
LogCtxError(ctx, "run work failed: %v", err)
}
}
}
}
})
}
return &pool
}
func (pool *RoutinePool) Submit(w func() error) {
pool.ch <- work(w)
}
func (pool *RoutinePool) Shutdown() {
defer close(pool.ch)
for i := uint(0); i < pool.capacity; i++ {
pool.ch <- end()
}
}
Copy 传入协程的 Context
Gin:直接调用
context.Copy()
即可。
三、注释与格式
注释
- 所有可导出的函数、类型、变量等都应该有注释,注释以函数名、类型名、变量名打头,函数注释建议同时包含参数和返回值的说明。
- 每行注释不超过100个字符。
- 包、函数、方法和类型的注释说明都是一个完整的句子。
- 有具体方案文档,在对应地方留下文档链接注释。便于后续快速了解这部分需求。
格式
这部分只要我们打开 Goland 相关配置,即可完成。
推荐配置
File Watcher 开启 go fmt、go imports:
配置可以参考:https://www.jetbrains.com/help/go/using-file-watchers.html#enableFileWatcher
垂直格式:
每个文件从上到下的代码规范。
一个文件,尽量不要超过 400 行。(超过可读性会降低)
- 垂直方向的间隔:
package声明、导入声明和每个函数之间都要有一个空行隔开。
- 垂直方向的靠近:
靠的越近的代码,关系越紧密。
- 垂直距离:
变量声明:尽可能靠近其使用的位置。
局部变量,声明在函数顶部。
实体变量,声明在类的顶部。
相关函数:尽节能互相靠近,保证顺序。
首先,应该放到一起。
其次,“调用”函数应该放到“被调用”函数的上面。
概念相关:做某类事情的函数,应该放一起。
比如,一个 interface,它有 read/write 方法,他们应该放一起
- 垂直顺序:
“调用”函数应该放到“被调用”函数的上面。
建立了一种自顶向下贯穿源代码的良好信息流。
横向格式:
每一行代码从左到右的代码规范。
每一行代码,尽量不要超过 120 个字。(超过150字,一个屏幕就看不全了)
- 水平方向的间隔与靠近
操作符周围加上空格。
- 水平对齐
type PodType string
const (
PodTypeIOS PodType = "iOS"
PodTypeAndroid PodType = "Android"
PodTypeFlutter PodType = "Flutter"
)
- 缩进
这部分 go-fmt 帮我们做了,只要集成 go-fmt 即可。
四、对象与数据结构
数据抽象成对象
以组件升级为例,将组件升级流程抽象成对象。不关心底层的数据结构与实现。
分析,组件升级流程需要:
- ValidateParam(校验参数)
- FormatParam(格式化参数)
- SendUpgradeRequest(触发升级)
- GenerateHistory(生成历史)
- UpdateHistory(更新历史)
type mpaasRepoUpgradeHandlerType interface {
ValidateParam(ctx context.Context) error //判断某个升级请求,是否合法
FormatUpgradeParam(ctx context.Context) error //处理参数,补充额外信息或者补上默认信息等等
SendUpgradeRequest(ctx context.Context, history *podHistory) (int, error) //各 Handler 自行发送升级请求
UpgradeHistory(ctx context.Context) *podHistory //生成升级历史
UpdateHistoryInfo(ctx context.Context) *podHistory //重试的时候要更新的组件升级历史字段
baseHandler() *podUpgradeBaseHandler //获取 baseHandler
}
组件升级会分为多种:iOS
、Android
、Flutter
、Custom
(构建脚本)、RubyGem
等等..
不论哪种组件升级只要实现这套 interface,即可完成组件升级流程。
数据 vs. 对象
对象:把数据隐藏于抽象之后,暴露操作数据的方法。
数据:通过数据结构暴露处理。
面向过程(直接使用数据结构):
好处:在不改动既有数据结构的前提下,新增新函数。
坏处:难以增删改数据结构。
面向对象(抽象):
好处:方便增删改数据结构。
坏处:难以新增函数,必须所有类改。
两者没有绝对的优劣比较,需要 case by case 在具体场景下的应用。
得墨忒(tuī)耳律
模块不应该了解它所操作对象的内部结构。
对象需要隐藏数据,暴露操作。
五、错误处理
常规流程
- 先看看反例:
package smelly
func (store *Store) GetItem(id string) (Item, error) {
store.mtx.Lock()
defer store.mtx.Unlock()
item, ok := store.items[id]
if !ok {
return Item{}, errors.New("item could not be found in the store")
}
return item, nil
}
handler
里如果要对特殊错误做特殊处理:
func GetItemHandler(w http.ReponseWriter, r http.Request) {
item, err := smelly.GetItem("123")
if err != nil {
if err.Error() == "item could not be found in the store" {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, errr.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(item)
}
- 再看看正例:
提前在包里,定义好错误类型。
package clean
var (
ErrItemNotFound = errors.New("item could not be found in the store")
)
func (store *Store) GetItem(id string) (Item, error) {
store.mtx.Lock()
defer store.mtx.Unlock()
item, ok := store.items[id]
if !ok {
return nil, ErrItemNotFound
}
return item, nil
}
handler
里如果要对特殊错误做特殊处理:
func GetItemHandler(w http.ReponseWriter, r http.Request) {
item, err := clean.GetItem("123")
if err != nil {
if errors.Is(err, clean.ErrItemNotFound) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(item)
}
好处:方便拓展,增加代码可读性。
六、边界
我们的系统都微服务化了。
每个子服务都会存在自己的边界。
我们需要尽量保证我们的服务边界整洁。
边界整洁
我们依赖的服务、库、代码是要可控的。
假如,我们依赖了一个不可控的库。
如果他有一天被检测出有安全问题、亦或 bug。
我们就很被动,导致服务需要大改。
简单来说,依赖我们能控制的东西,好过依赖我们控制不了的东西。
免得日后被控制,导致重写或修改。
层级架构明确
属于同一层的服务,最好只依赖下层服务。
理论上来说,不该依赖同层服务,更不应该依赖上层服务。
每个团队/业务的架构图应该要梳理出来。
模块职责明确
其实,不光服务于服务之间要有层级架构。
我们服务内部应该也需要按照层级来写代码。
另外,每个工程的 ReadMe,最好能阐述下大概设计思路和架构,便于协作开发。
参考资料:
clean-go-article