[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 NamesGo 包样式指南.

相似的声明放在一组

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)
  • nil 是一个有效的 slice

要检查切片是否为空,请始终使用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) 命名

  • 尽量简短并有意义。
  • 禁止使用 thisself 等面向对象语言中特定的叫法。
  • 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
  • 所有的配置通过 Context 传递。

路由文件

  • 每个 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 名使用驼峰命名
  • 命名是可以使用阿拉伯数字 24 表示 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

注释

接口文档都是可以通过代码生成的,而生成的时候,依赖注释,所以:

  • Operator
  • 参数
  • 数据库表及字段

都应有注释,且为单行注释!

API

HTTP 方法的语义和限制

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) 查询多个用户的信息

参数

统一的数据类型及结构

避免使用多个参数去为同一个模型属性定义筛选条件(如 startTimeendTime 筛选 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) {
    ...
}

参数的定义

  • path 上的参数一定是必填的。

  • query 上的参数一般可选。

  • header 上的参数一般是跨接口的,或者是某种标准需要,如 Referer

  • body 上的参数,一般是 model 的一部分。

  • 参数名需要和模块的名称一致。

    • 如果是可以表示多个属性,在 path 上的参数可以写成 appIDOrName

  • 对于参数结构体,以 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 也可以通过 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 后缀,示例:UserDataListUserWithRoleDataList

列表类结构

列表类结构,结构固定包含 Data / Total 字段

1
2
3
4
type AccountDataList struct {
    Data   []Account  `json:"data"`
	Total  int        `json:"total"`
}

HTTP Status Code 的语义

  • 200 :正常返回数据
  • 201:创建(POST) 成功后的返回
  • 204:无数据的正常返回

错误返回

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