[TOC]
Golang开发规范
一致性的代码更容易维护、是更合理的、需要更少的学习成本、并且随着新的约定出现或者出现错误后更容易迁移、更新、修复 bug。
注释
- 首要原则:一定要写注释,多写注释。代码可维护性基本等价于好的代码设计+注释,设计好坏视个人能力而定,但注释是每个人都可以写好的,重要的地方尽量写中文。
- api接口注释:一个服务对外提供的api一定要写注释,包括接口注释和出入参字段注释,以便生成标准接口文档,方便调用者使用。
- 核心逻辑注释:核心逻辑较复杂的一定要写注释,将逻辑和步骤解释清楚,核心逻辑中有变化的部分尽量留下历史注释并写上时间戳和修改人。
- 一般注释:一般的结构体,方法,变量等视情况添加注释,考虑因素有两点:1,英文能力有限,有取名字困难症的最好写注释;2,方法,字段等名字太长的可使用简写并添加注释。
- 在注释或文档中,遇到中英文混写时,需要在连接处加空格,示例:
示例 example 示例
变量
- 采用驼峰方式命名,禁止使用下划线命名。首字母是否大写,根据是否需要外部访问来决定。
- 避免可变全局变量。
- 如果将变量明确设置为某个值,则应使用短变量声明形式
(:=)
,在某些情况下,var 使用关键字时默认值会更清晰。例如,声明空切片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// Bad
var s = "foo"
func f(list []int) {
filtered := []int{}
for _, v := range list {
if v > 10 {
filtered = append(filtered, v)
}
}
}
// Good
s := "foo"
func f(list []int) {
var filtered []int
for _, v := range list {
if v > 10 {
filtered = append(filtered, v)
}
}
}
|
- 在顶层,使用标准
var
关键字。请勿指定类型,除非它与表达式的类型不同。
1
2
3
4
5
6
7
8
9
10
11
|
// Bad
var s string = F()
func F() string { return "A" }
// Good
var s = F()
// 由于 F 已经明确了返回一个字符串类型,因此我们没有必要显式指定_s 的类型
// 还是那种类型
func F() string { return "A" }
|
1
2
3
4
5
6
7
8
9
10
|
// Bad
err := ioutil.WriteFile(name, data, 0644)
if err != nil {
return err
}
// Good
if err := ioutil.WriteFile(name, data, 0644); err != nil {
return err
}
|
常量
- 都使用大写字母,如果需要,可以使用下划线分割
- 不要在程序中直接写数字、特殊字符串,全部使用常量替代
包
当命名包时,请按下面规则选择一个名称:
- 全部小写。
- 大多数使用命名导入的情况下,不需要重命名。
- 简短而简洁。
- 不用复数。例如
net/url
,而不是 net/urls
。
另请参阅 Package Names 和 Go 包样式指南.
相似的声明放在一组
Go 语言支持将相似的声明放在一个组内。
1
2
3
4
5
6
7
8
9
|
// Bad
import "a"
import "b"
// Good
import (
"a"
"b"
)
|
这同样适用于常量、变量和类型声明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// Bad
const a = 1
const b = 2
var a = 1
var b = 2
type Area float64
type Volume float64
// Good
const (
a = 1
b = 2
)
var (
a = 1
b = 2
)
type (
Area float64
Volume float64
)
|
仅将相关的声明放在一组。不要将不相关的声明放在一组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Bad
type Operation int
const (
A Operation = iota + 1
B
C
ENV = "ONLINE"
)
// Good
type Operation int
const (
A Operation = iota + 1
B
C
)
const ENV = "ONLINE"
|
函数
命名
- 采用驼峰方式命名,禁止使用下划线命名。首字母是否大写,根据是否需要外部访问来决定。
函数分组与顺序
- 函数应按粗略的调用顺序排序。
- 同一文件中的函数应按接收者分组。
因此,导出的函数应先出现在文件中,放在 struct, const, var 定义的后面。
在定义类型之后,但在接收者的其余方法之前,可能会出现一个 newXYZ()/NewXYZ()
由于函数是按接收者分组的,因此普通工具函数应在文件末尾出现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// Bad
func (s *something) Cost() {
return calcCost(s.weights)
}
type something struct{ ... }
func calcCost(n []int) int {...}
func (s *something) Stop() {...}
func newSomething() *something {
return &something{}
}
// Good
type something struct{ ... }
func newSomething() *something {
return &something{}
}
func (s *something) Cost() {
return calcCost(s.weights)
}
func (s *something) Stop() {...}
func calcCost(n []int) int {...}
|
初始化
初始化 Struct 引用
在初始化结构引用时,请使用 &T{}
代替 new(T)
,以使其与结构体初始化一致。
初始化 Maps
对于空 map 请使用 make(..) 初始化, 并且 map 是通过编程方式填充的。 这使得 map 初始化在表现上不同于声明,并且它还可以方便地在 make 后添加大小提示。
在尽可能的情况下,在使用 make() 初始化的时候提供容量信息,向 make() 提供容量提示会在初始化时尝试调整map 的大小,这将减少在将元素添加到 map 时为 map 重新分配内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// Bad
var (
// m1 读写安全;
// m2 在写入时会 panic
m1 = map[T1]T2{}
m2 map[T1]T2
)
// 声明和初始化看起来非常相似的。
// Good
var (
// m1 读写安全;
// m2 在写入时会 panic
m1 = make(map[T1]T2)
m2 map[T1]T2
)
// 声明和初始化看起来差别非常大。
|
如果 map 包含固定的元素列表,则使用 map literals(map 初始化列表) 初始化映射
1
2
3
4
5
6
7
8
9
10
11
12
|
// Bad
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
// Good
m := map[T1]T2{
k1: v1,
k2: v2,
k3: v3,
}
|
基本准则是:在初始化时使用 map 初始化列表 来添加一组固定的元素。否则使用 make (如果可以,请尽量指定 map 容量)。
切片
在尽可能的情况下,在使用 make() 初始化切片时提供容量信息,特别是在追加切片时。
1
|
make([]T, length, capacity)
|
要检查切片是否为空,请始终使用len(s) == 0。而非 nil
1
2
3
4
5
6
7
8
9
|
// Bad
func isEmpty(s []string) bool {
return s == nil
}
// Good
func isEmpty(s []string) bool {
return len(s) == 0
}
|
其他
接收器 (receiver) 命名
- 尽量简短并有意义。
- 禁止使用
this
、self
等面向对象语言中特定的叫法。
- receiver 的命名要保持一致性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// GOOD:
// call()和done()都使用了在上下文中有意义的"c"进行receiver命名
func (c Client) call() error {
// ...
}
func (c Client) done() error {
// ...
}
// BAD:
// 1. "c"和"client"命名不一致:done() 用了c,call()用了client
// 2. client命名过于冗余
func (c Client) done() error {
// ...
}
func (client Client) call() error {
// ...
}
// 不允许使用self
func (self Server) rcv() error {
// ...
}
|
减少嵌套
代码应通过尽可能先处理错误情况/特殊情况并尽早返回或继续循环来减少嵌套。减少嵌套多个级别的代码的代码量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Bad
for _, v := range data {
if v.F1 == 1 {
v = process(v)
if err := v.Call(); err == nil {
v.Send()
} else {
return err
}
} else {
log.Printf("Invalid v: %v", v)
}
}
// Good
for _, v := range data {
if v.F1 != 1 {
log.Printf("Invalid v: %v", v)
continue
}
v = process(v)
if err := v.Call(); err != nil {
return err
}
v.Send()
}
|
不必要的 else
如果在 if 的两个分支中都设置了变量,则可以将其替换为单个 if。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// Bad
var a int
if b {
a = 100
} else {
a = 10
}
// Good
a := 10
if b {
a = 100
}
|
结构体中的嵌入
嵌入式类型(例如 mutex )应位于结构体内的字段列表的顶部,并且必须有一个空行将嵌入式字段与常规字段分隔开。
1
2
3
4
5
6
7
8
9
10
11
12
|
// Bad
type Client struct {
version int
http.Client
}
// Good
type Client struct {
http.Client
version int
}
|
使用字段名初始化结构体
初始化结构体时,应该指定字段名称。
1
2
3
4
5
6
7
8
9
10
|
// Bad
k := User{"Tom", "MAN", true}
// Good
k := User{
Name: "Tom",
Sex: "MAN",
Admin: true,
}
|
使用原始字符串字面值,避免转义
Go 支持使用 原始字符串字面值,也就是 " ` " 来表示原生字符串,在需要转义的场景下,我们应该尽量使用这种方案来替换。可以跨越多行并包含引号。使用这些字符串可以避免更难阅读的手工转义的字符串。
1
2
3
4
5
|
// Bad
wantError := "unknown name:\"test\""
// Good
wantError := `unknown error:"test"`
|
不要使用 panic
- 除非出现不可恢复的程序错误,不要使用 panic,用多返回值和 error
使用 defer 释放资源,诸如文件和锁
使用 defer 释放资源,诸如文件和锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// Bad
p.Lock()
if p.count < 10 {
p.Unlock()
return p.count
}
p.count++
newCount := p.count
p.Unlock()
return newCount
// 当有多个 return 分支时,很容易遗忘 unlock
// Good
p.Lock()
defer p.Unlock()
if p.count < 10 {
return p.count
}
p.count++
return p.count
// 更可读
|
避免使用内置名称
Go 语言规范 language specification 概述了几个内置的, 不应在 Go 项目中使用的名称标识predeclared identifiers。
优先使用 strconv 而不是 fmt
将原语转换为字符串或从字符串转换时,strconv
速度比 fmt
快。
true/false 求值
- 当明确 expr 为 bool 类型时,禁止使用 == 或 != 与 true/false 比较,应该使用 expr 或 !expr
- 判断某个整数表达式 expr 是否为零时,禁止使用 !expr,应该使用 expr == 0
处理类型断言失败
type assertion 的单个返回值形式针对不正确的类型将产生 panic。因此,请始终使用 “comma ok” 的惯用法。
1
2
3
4
5
6
7
8
9
|
// Bad
t := i.(string)
// Good
t, ok := i.(string)
if !ok {
// 优雅地处理错误
}
|
服务规范
目录结构及定义
目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
├── pkg
│ ├── clients # 依赖服务
│ │ ├── client_xxx
│ │ │ ├── client.go
│ │ │ ├── operations.go
│ │ │ └── types.go
│ │ └── gen.go # general client
│ ├── constants # 存放通用类型
│ │ ├── types # 存放枚举类型
│ │ │ ├── account_type.go # 枚举类型
│ │ │ ├── account_type__generated.go
│ │ │ ├── ....
│ │ │ └── types.go # 通用三方类型别名定义
│ │ └── errors # 存放通用错误类型
│ │ ├── status_error.go # general errors
│ │ └── status_error__generated.go
│ ├── controllers
│ │ └── account
│ │ ├── context.go
│ │ └── account__datalist.go
│ ├── command
│ │ └── cmd.go
│ ├── models # 数据模型和对应的方法
│ │ ├── account.go
│ │ ├── ...
│ │ └── db.go
│ └── utils # 工具类定义
│
├── cmd
│ └── srv-example
│ ├── apis
│ │ ├── user
│ │ │ ├── list_user.go # 具体接口,一个接口一个文件
│ │ │ └── root.go
│ │ ├── middleware
│ │ │ └── must_account.go
│ │ └── root.go
│ ├── config
│ │ ├── default.yml
│ │ ├── local.yml
│ │ └── master.yml
│ ├── deploy
│ │ └── qservice.yml
│ ├── global
│ │ └── config.go
│ ├── Dockerfile # 复制 Dockerfile.default 并重命名
│ ├── Dockerfile.default # 程序运行时自动生成
│ └── main.go
│
├── .gitignore
├── .gitlab-ci.yml
├── .husky.yaml
├── .version
├── CHANGELOG.md
├── go.mod
├── Makefile
└── README.md
|
路由文件
- 每个 Operator 一个文件
- Operator 文件结构
- init()
- Operator 定义
- Output(ctx context.Context) (interface{}, error)
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
package
import
func init() {
Routers.Register(courier.NewRouter(&Operator{}))
}
type Operator struct {
httpx.MethodGet `summary:"示例接口" path:"/examples"`
}
func (req *Operator) Output(ctx context.Context) (interface{}, error) {
...
}
|
错误定义
通用错误的结构体定义为:https://godoc.org/github.com/go-courier/statuserror#StatusErr
- 错误代码为 9 位:3 位 HTTP Status + 3位服务 id + 3位错误码,保证每个服务返回的错误 code 都是唯一的,当需要透传错误代码时,对外返回的错误代码依然是唯一的
- 当用户提交的表单输入的值存在错误时,我们通过 ErrorField s来定位用户输错的每个字段以及错误原因,从而避免一个一个字段提示,造成非常糟糕的用户体验
- source 字段是为了便于定位最终是在哪个服务上发生了错误,但在框架中,source 将自动 append
但为了方便开发(同时也方便 API 文档的抓取),我们用 const 代替手动定义该结构体,比如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
package errors
import (
"net/http"
)
//go:generate tools gen status-error
type StatusError int
func (StatusError) ServiceCode() int {
return 999 * 1e3
}
const (
// InternalServerError
InternalServerError StatusError = http.StatusInternalServerError*1e6 + iota + 1
)
const (
// @errTalk Unauthorized
Unauthorized StatusError = http.StatusUnauthorized*1e6 + iota + 1
)
|
这些定义放在 constants/errors
包中的 status_error.go
中。其中
- 每一个 http code 代表的错误范围都单独起一个 const 集合,即 const 关键字包裹的枚举列表
- 每一个错误枚举都要符合错误 code 的编码规则
- 每一个服务ID 都必须唯一,使用 gitlab 项目ID。
- 每一个错误枚举的上方加上注释表明该错误的说明
- 第一行定义 msg,需要简明扼要,如果
@errTalk
前缀表示需要对外提供话术
- 其他行为描述,可以为空,如果在业务使用中中需要用到其他描述可以这样使用
IncomeRangeInvalidError.StatusErr().WithDesc("others desc")
- 使用
go generate
来生成这些错误的一些函数代码 (也可以用 govendor generate +l
,批量执行)
枚举定义
枚举的定义
1
2
3
4
5
6
7
8
9
10
11
12
|
package types
// openapi:enum
//go:generate tools gen enum CustomerType
type CustomerType int8
// 客户类型
const (
CUSTOMER_TYPE_UNKNOWN CustomerType = iota
CUSTOMER_TYPE__PERSON // 个人
CUSTOMER_TYPE__ENTERPRISE // 企业
)
|
- 每一个枚举名称的前缀都为该枚举类型的大写通过下划线分隔
- 第一个枚举类型都为
_UNKNOWN
,使用一个下划线,值为 iota
- 后续的枚举类型使用两个下划线来分隔前缀和真实的类型
- 后续的枚举类型都加上注释,表明该类型的含义
- 使用
tools gen enum
来生成所有枚举的操作函数,其中主要包括序列化和反序列化的函数,以及与 string
之间的相互转换
- 虽然为了可读性,外部使用枚举都是字符串,但是数据中存储的都是整型,同时便于 API 文档的抓取
数据库定义
公共部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
var DB = sqlx.NewDatabase("dbName")
// 使用 pgbuilder 时需要
// pgbuilder 创建、修改、删除都需要 Mark 时间
type OperationTimes struct {
// 创建时间
CreatedAt types.Timestamp `db:"f_created_at,default='0'" json:"createdAt" `
// 更新时间
UpdatedAt types.Timestamp `db:"f_updated_at,default='0'" json:"updatedAt"`
}
func (times *OperationTimes) MarkUpdatedAt() {
times.UpdatedAt = types.Timestamp(time.Now())
}
func (times *OperationTimes) MarkCreatedAt() {
times.MarkUpdatedAt()
times.CreatedAt = times.UpdatedAt
}
type OperationTimesWithDeletedAt struct {
OperationTimes
// 删除时间
DeletedAt types.Timestamp `db:"f_deleted_at,default='0'" json:"-"`
}
func (times *OperationTimesWithDeletedAt) MarkDeletedAt() {
times.MarkUpdatedAt()
times.DeletedAt = times.UpdatedAt
}
type PrimaryID struct {
// 自增 ID
ID uint64 `db:"f_id,autoincrement" json:"-"`
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
//go:generate tools gen model2 Account --database=DB
// 账户
// @def primary ID
// @def unique_index I_account_id AccountID
// @def index I_account_type AccountType
// @def index I_nick_name NickName
// @def index I_state State
// @def index I_created_at CreatedAt
// @def index I_updated_at UpdatedAt
type Account struct {
PrimaryID
RefAccountID
AccountBase
// 状态
State types.State `db:"f_state,default='1'" json:"state"`
OperationTimesWithDeletedAt
}
type AccountBase struct {
// 昵称
NickName string `db:"f_nick_name,size=20" json:"nickName"`
// 账户类型
AccountType types.AccountType `db:"f_account_type" json:"accountType"`
// 头像
Avatar string `db:"f_avatar,default=''" json:"avatar"`
}
type AccountID = types.SFID // 推荐但不强制
type RefAccount struct {
// @rel Account.AccountID
// 账户唯一 ID
AccountID AccountID `db:"f_account_id" json:"accountID"`
}
|
- 对公共部分的结构体进行抽离,推荐结构体组合。
- 表结构定义应有注释,且注释在
go:generate tools gen model2
下一行 。
- 表结构中每个字段都应有相应注释。
- 唯一 ID 关联字段使用结构体:
1
2
3
4
5
6
7
|
type AccountID = types.SFID
type RefAccountID struct {
// @rel Account.AccountID
// 账户唯一 ID
AccountID AccountID `db:"f_account_id" json:"accountID"`
}
|
- 结构体名使用
Ref
作为前缀,example: RefAccount
- 使用注释
@rel Account.Account
标识
对于复杂的数据结构,可以定义数据结构实现 DataType 、Value、Scan 三个方法,示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import (
"github.com/go-courier/sqlx/v2/datatypes"
)
type Meta map[string][]string
func (Meta) DataType(driver string) string {
return "text"
}
func (m Meta) Value() (driver.Value, error) {
return datatypes.JSONValue(m)
}
func (m *Meta) Scan(src interface{}) error {
return datatypes.JSONScan(src, m)
}
|
命名规范
服务
- API 服务名称应与 gitlab 仓库名称相同。
- 全小写,单词之间应该使用英文减号(
-
) 符号链接。
- 使用
srv-
前缀 srv-<service_name>
,example: srv-weather
- 部署时,域名和根路径格式为
api.rockontrol.com/<service_name>
,ex api.rockontrol.com/weather
- 为避免浏览器请求 TCP 连接数限制,还可以遵循
srv-<service_name>.rockontrol.com/<service_name>
,ex srv-weather.rockontrol.com
- 根据服务类型不同,还应在
srv-
的前缀基础上再进行扩展
- 业务网关:前缀应为
srv-bff-
,BFF:即 Backend For Frontend(服务于前端的后端),example: srv-bff-park
- 代理网关:应在服务名后面加上
-gateway
,example:srv-amap-gateway
Operator
- Operator 名使用驼峰命名
- 命名是可以使用阿拉伯数字
2
、 4
表示 To 、For 。
- 使用以下关键字作为前缀
前缀 |
说明 |
Method |
示例 |
Get |
指定获取资源 |
GET |
GetUser |
List |
获取资源列表 |
GET |
ListUser |
Create |
创建资源 |
POST |
CreateUser |
Update |
修改资源 |
PUT |
UpdateUser |
Delete |
删除资源 |
DELETE |
DeleteUser |
Put |
创建或更新资源 |
PUT |
PutUser |
Set |
设置资源某个属性或设置资源与其他资源之间的关联关系 |
PUT |
SetUserState |
SetUserRoleBind |
|
|
|
例外:Authorize
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
type CreateAccount struct {
httpx.MethodPost `path:"/" summary:"创建账户"`
}
type DeleteAccount struct {
httpx.MethodDelete `path:"/:accountID" summary:"删除账户"`
}
type UpdateAccount struct {
httpx.MethodPut `path:"/:accountID" summary:"修改账户"`
}
type GetAccount struct {
httpx.MethodGet `path:"/:accountID" summary:"获取用户详情"`
}
type ListAccount struct {
httpx.MethodGet `path:"/" summary:"账户列表"`
}
|
文件
- 文件名都使用小写字母,如果需要,可以使用下划线分割。
- 文件名的后缀使用小写字母。
- 每个 Operator 都是独立文件。
错误的文件名:
文件名 |
描述 |
webServer.go |
文件名不允许出现大写字符 |
http.GO |
文件名后缀不允许出现大写字符 |
正确的文件名:
文件名 |
描述 |
http.go |
文件名全部小写 |
web_server.go |
可以使用下划线分割文件名 |
account__datalist.go |
可以使用双下划线进行资源区分 |
API 字段
无论是 API 参数的 Key,还是返回 JSON 的 Key,一般以 **小驼峰 **的格式来命名,比如: userProfile
- 在 HTTP Header 上的 Key 是 短横首字母大写,如
Content-Type
, X-Meta
, X-Rate-Limit
- 现有规范要求除外,如 OAuth 2 中的,
access_token
等
数据库
数据库名使用 gitlab 组名 + 服务名,使用下划线 _
链接。example: base_user
如果当前组下只需一个数据库,可例外,可直接使用 gitlab 组名。example: idp
注释
接口文档都是可以通过代码生成的,而生成的时候,依赖注释,所以:
都应有注释,且为单行注释!
API
HTTP 方法的语义和限制
POST
: 创建、新增
PUT
:更新,强制创建,绑定
PATCH
:部分更新
DELETE
: 删除,解除绑定
GET
: 查询
- HTTP 协议中 GET 是可以使用 body 的,但是由于部分环境中的请求库不支持在 GET 请求中包含 body,如浏览器的 XMLHttpRequest 。这里也统一禁止在 GET 中使用 body
- 如遇到 URI 过长的情况,更新 Nginx 等转发层的配置
URL 路由
1
2
3
4
5
6
7
|
/<ServiceName>/<Version>/<Resources>
/<ServiceName>/<Version>/<Resources>/<ResourceID>
/<ServiceName>/<Version>/<Resources>/<ResourceID>/<Action>
/<ServiceName>/<Version>/<Resources>/<ResourceID>/<SubResouces>/<SubResouceID>
/<ServiceName>/<Version>/<Resources>/0/<SubResouces>/<SubResouceID>
/<ServiceName>/<Version>/<Resources>?<IndexKey1>=<Value>&<IndexKey2>=<Value>
/<ServiceName>/<Version>/<Resource>?<IndexKey1>=<Value>&<IndexKey2>=<Value>
|
- URI 的第一段为服务名称,为了便于做流量分发
- 例如 仓库名称为
srv-weather
的服务,上述 为 weather
。
- URI 的第二段为版本号,为了便于平滑的升级不兼容的接口
- 其余部分为 Resources 规则
- URI 的奇数段为 Resource 类型,偶数段为 Resource ID (务必为业务 ID,若无,则为较为稳定的 unique index)
- Resource 类型名应为单词复数,如涉及多个单词组合,不使用小驼峰,使用
-
连接,示例 [http://example.com/user/v0/enterprise-accounts](http://example.com/user/v0/users)
- 对于单一索引,应满足上述条件
- 对于复合索引,Resource 为单数,通过 query 来设定条件
- 单复数同形换替代单词
- 当无须指定一个资源类型的 ID 时,使用 0 占位
- 由于 METHOD 的表达能力有限,所以当对同一个资源存在两种不同类型的更新操作时,允许 URI 的最后一个段可以为一个对某资源的操作。
样例:
- Create
POST [http://example.com/user/v0/users](http://example.com/user/v0/users)
创建用户
- Update
PUT [http://example.com/user/v0/users/:userID](http://example.com/user/v0/users/:userID)
更新某个用户的信息
PUT [http://example.com/user/v0/users/:userID/ban](http://example.com/user/v0/users/:userID/ban)
禁用某个用户
PUT [http://example.com/user/v0/users/:userID/promote](http://example.com/user/v0/users/:userID/promote)
给某个用户提升权限
- Delete
DELETE [http://example.com/user/v0/users/:userID](http://example.com/user/v0/users/:userID)
删除用户
DELETE [http://example.com/user/v0/user?username=xxx](http://example.com/user/v0/user?username=xxx)
通过用户名删除用户
- Find
GET [http://example.com/user/v0/users/:userID](http://example.com/user/v0/users/:userID)
查询某个用户的信息
GET [http://example.com/user/v0/users/:userID/car?carNo=](http://example.com/user/v0/users/:userID/car?carNo=)川Axxx
通过车牌查询某个用户的某个车辆
GET [http://example.com/user/v0/user?mobile=123467890](http://example.com/user/v0/user?mobile=123467890)
通过手机号查询用户信息
GET [http://example.com/user/v0/user?username=xxx](http://example.com/user/v0/user?username=xxx)
通过用户名查询用户信息
- Listing
GET [http://example.com/user/v0/users/:userID/cars](http://example.com/user/v0/users/:userID/cars)
查询某个用户的车辆信息
GET [http://example.com/user/v0/users[?filter=xx]](http://example.com/user/v0/users%5B?filter=xx%5D)
查询多个用户的信息
参数
统一的数据类型及结构
避免使用多个参数去为同一个模型属性定义筛选条件(如 startTime
和 endTime
筛选 createdAt
),应声明特定字符传格式,来表达对应的筛选条件,详见「数据类型」一章。
**package: **git.querycap.com/tools/datatypes
对于复杂的传参也可以通过定义对应的数据类型去处理。
1
2
3
4
5
6
7
8
9
10
11
12
|
// openapi:strfmt example
type Example struct{
}
func (e Example) MarshalText() ([]byte, error) {
...
}
func (d *Example) UnmarshalText(data []byte) (err error) {
...
}
|
参数的定义
- 对于参数结构体,以
Params
作为前缀,示例 ParamsAccount
- 所有的参数(除系统生成的枚举类型)都需要进行参数校验
- 对于 query 参数而言,如果接收的是数组,不需要将参数定定义为复数
- 对于 Body 中的参数 json tag 使用小驼峰。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
// Bad
type ParamsAccount struct {
// 名称
Names []string `name:"names,omitempty" in:"query" validate:"@slice<@string[1,]>"`
...
}
// Good
type ParamsAccount struct {
// 名称
Names []string `name:"name,omitempty" in:"query" validate:"@slice<@string[1,]>"`
...
}
// Query
type ParamsAccount struct {
// 大小
Size int64 `name:"size,omitempty" in:"query" default:"10" validate:"@int64[-1,]"`
// 偏移量
Offset int64 `name:"offset,omitempty" in:"query" default:"0" validate:"@int64[0,]"`
// 名称
Names []string `name:"name,omitempty" in:"query" validate:"@slice<@string[1,]>"`
// 排序
Sort types.Sort `name:"sort,omitempty" default:"createdAt" validate:"@string{createdAt,updatedAt}{,!asc}" in:"query"`
// 创建时间范围
CreatedAt types.DateTimeOrRange `name:"createdAt,omitempty" in:"query"`
}
// Body
type ParamsCreateAccount struct {
// 昵称
NickName string `json:"nickName" validate:"@string[2,10]"`
// 账户类型
AccountType types.AccountType `json:"accountType"`
// 头像
Avatar string `json:"avatar,omitempty" validate:"@string[2,10]"`
}
|
鉴权 Authorization
除了 AccessKey 的验签鉴权方式,对外服务将采用 Authorization 对每一个接口进行权限校验。
但由于我们最终服务可能嵌入到不同的客户端中(手机管车,微信), 可能需要对每个请求进行多系统的权限校验,因此扩展 HTTP Header Authorization。
1
|
Authorization: <type> <credentials>[; <type> <credentials>]
|
- 多段 Authorization 以
;
分隔, 也意味着,credentials
不应该出现 ;
- 解析时,
<type> <credentials>
需要 trim 空字符
- 主系统 type
- 额外验证
PayPassword <encoded_passsword>
PicAuthCode <auth_code>
- 旁系统 type 以对应系统加入前缀
同时,Authorization 也可以通过 query 参数提供 authorization=encodeURIComponent(xxx)
example:
1
2
3
4
|
Authorization: Bearer xxxxxxx
Authorization: Bearer xxxxxxx; CentreBearer xxxxxx
# base64(aladdin:opensesame)
Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l
|
Refs
API 返回
正常返回
可根据需要返回 map / []byte 等,如返回的是结构体,需满足下面的规则:
- 直接返回模型结构
- 如返回数据是包含其他资源,可以使用
With
链接,示例:UserWithRole
or UserWithRoleDataList
- 如返回数据完全自定义,可使用 Operator 名 +
Resp
后缀 ,示例:CreateUserResp
- 如返回的数据是列表列,命名规则为 资源 +
DataList
后缀,示例:UserDataList
、UserWithRoleDataList
列表类结构
列表类结构,结构固定包含 Data / Total 字段
1
2
3
4
|
type AccountDataList struct {
Data []Account `json:"data"`
Total int `json:"total"`
}
|
HTTP Status Code 的语义
错误返回
API 返回的错误统一如下结构,并且有非 2xx, 3xx 的 HTTP status code 提现在请求中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
interface StatusError {
// 错误标识,服务内唯一
key: string;
// 错误代码 9 位,三段
// 如 400100001,400 即 http status code,100 为 服务编号,001 为自增序列号
code: number;
// 错误简要描述
msg: string;
// 错误详细描述
desc?: string;
// 标记错误是否可直接通知给用户
canBeTalkError: boolean;
// request id 或者其他上下文
id?: string;
// 错误的产生的链路
sources: string[];
// 错误的具体位置
errorFields?: Array<{
// 字段名和路径
// 复杂结构
// user.name
// user.photos[0]
// user.photos[1]
field: string,
// 错误的参数位置
in: "path" | "query" | "header" | "body" | "cookie",
// 错误信息
msg: string;
}>;
}
|
HTTP Status Code 的语义
- 400:传入的参数有问题等
- 401:access token 验证失败
- 403:权限控制访问了,不属于此用户的资源或其他不允许其访问,
- 404:访问了不存在的资源
- 409:重复创建或更新的冲突等
- 429:请求太多
- 499: 客户端主动断开连接
- 500:内部处理发生错误,如请求超时等
特殊场景
过期控制
当调用方需要使用轮训的方式来刷新数据时,需要 API 告诉调用方什么时候再次请求。
借鉴 缓存控制,通过在 Response 的 Header 上的设置这些规则即可。
1
2
|
Cache-Control: max-age=<seconds>
Expires: <http-date>
|
Cache-Control
是相对时间
Expires
是绝对时间 (如果前后端时间不同步则有坑,使用这个的时候,建议使用方传递调用时间,以修正差值)
频率控制
当 API 服务需要限制 API 的调用频率时,在 Response 的 Header 上加上额外的信息,对于调用方会更加友好。
前端也可以根据这些信息,适当的添加如倒计时等 UI 控件(下次可用 3600/X-RateLimit-Limit
秒后),提升用户体验。
1
2
3
|
X-RateLimit-Limit: <每小时请求总次数>
X-RateLimit-Remaining: <当前限制剩余次数>
X-RateLimit-Reset: <频率控制清零的时间戳,如账户锁定场景时,告诉用户什么时候可用再次使用>
|
服务调用
服务与服务之间不能相互调用
数据库规范
命名规范
- 表名、字段名、索引名使用小写字母,采用下划线分割,
表名、字段名不超过 32 个字符,表名采用
't_' 前缀
,字段采用 'f_' 前缀
。
- 存储实体数据的表,名称使用名词,单数
- 索引名称采用
表名+'_i' 前缀
,之后顺序跟随索引的字段名,字段名直接以下划线分割,名称示例:t_instance_i_instance
- 不使用保留字
- 存储实体表间多对多对应关系的表,使用两个表名叠加。示例:
t_xxxx_tag
- SQL 语句中:
表的设计
- 所有的时间均使用时间戳
- 如非必要,请使用软删除,使用
f_deleted_at
表示
- 地理相关使用 geography
- 字段定义为 NOT NULL
- 字段设置 DEFAULT 值
- 不直接存储图片、音频、视频等大容量内容
- 各表之间相同含义的字段,类型定义要完全相同
- 必须设置唯一主键,尽量使用自增id作为主键
- 表的唯一键使用 int8
- 禁止使用enum
- …
索引
查询
- 禁止使用 % 前导查询
- 尽量不使用联表查询
- 尽量不使用子查询
- 一次查询的结果集不超过 100 行
代码提交
- 提交代码前,使用git diff命令或gitk等工具查看本次改动是否符合预期,避免提交不必要的文件或无关修改
- 提交代码前,使用 husky (golangci-lint) 代码检测
- commit 的格式:
<提交类型>: <描述>
示例: feat: 增加账户管理功能
- 提交类型见下。描述需简要说明修改的内容或原因,不能图方便使用固定的msg,不要太长,支持中文。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# commit示例 feat: 增加账户管理功能
# 提交类型包含
# feat = 'Features', // 功能点
# fix = 'Bug Fixes', // bug 修复
# refactor = 'Code Refactoring', // 重构代码
# perf = 'Performance Improvements', // 性能提升
# chore = 'Chores', // 杂事, 更新grunt任务等; 不包括生产代码变动
# revert = 'Reverts', // 回滚提交
# improvement = 'Improvement', // 代码优化
# docs = 'Documentation', // 文档改动
# style = 'Styles', // 格式化, 缺失分号等; 不包括生产代码变动
# test = 'Tests', // 添加缺失的测试, 重构测试, 不包括生产代码变动
# build = 'Builds', // 编译
# ci = 'Continuous Integration', // CI
|