Go 语言编程 — 编程规范

目录

一个项目使用单个 GOPATH

GOPATH 指定了 Golang 项目的 Workspace,Golang 是支持多 GOPATH 的,也就是说:在同一个 Golang 项目中可以同时拥有多个运行环境。多 GOPATH 支持带来了一定的灵活度,但也会导致某些副作用,例如:软件版本的一致性。

诸如 Etcd 或 Camlistore 这样的大项目通常会使用 godep 类似的依赖包管理工具,将所有依赖都保存到某个目录中。也就是说,这些项目都会要求使用一个单一的 GOPATH,它们只能在这个目录内找到对应的版本。

简而言之,如果你认为项目需要一个独立的 GOPATH,那么就创建它,但不要尝试在一个项目中使用多个 GOPATH。

import 规范

  1. 使用 goimports 工具进行管理:能够在保存 *.go 文件时自动格式化文件并检查 import 规范。如果使用的包没有导入,则自动导入;如果导入的包没有被使用,则自动删除。
$ go get golang.org/x/tools/cmd/goimports
$ goimports -w=true hello.go
  1. 坚持使用分行导入,即便只导入一个包:
import (
    "fmt"
)
  1. 导入多个包时注意按照类别顺序并使用空行区分:标准库包,第三方包,程序内部包:
import (
    "encoding/json"
    "strings"

    "github.com/astaxie/beego"
    "github.com/go-sql-driver/mysql"

    "myproject/models"
    "myproject/controller"
    "myproject/utils"
)
  1. 禁止使用相对路径导入:
import "../net"
  1. 禁止使用 “.” 简化导入:
import . " pubcode/api/broker"

坚持使用 gofmt、golint、go vet 等工程化工具

Go 在项目的工程化上提供了良好的支持,这是 Go 能够在服务器领域有一席之地的重要原因。这里说的工程友好包括:

  • gofmt 保证代码的基本一致,增加可读性,避免在争论不清楚的地方争论;
  • 原生支持的 profiling,为性能调优和死锁问题提供了强大的工具支持;
  • utest 和 coverage,持续集成,为项目的质量提供了良好的支撑;
  • example 和注释,让接口定义更友好合理,让库的质量更高。
  1. 提交代码时,必须使用 gofmt 对代码进行格式化。

go fmt 工具可以尽力保持项目代码风格的一致性,有些 IDE 会在保存 *.go 文件时会自动执行 gofmt,否则需要手动运行指令 gofmt -w .,可以将当前目录和子目录下的所有文件都格式化一遍。

需要注意的是,gofmt 不识别空行,因为 gofmt 不能理解空行的意义。这一点需要引起注意,空行是非常好的体现了逻辑关联的方式,所以空行不能随意,非常严重地影响可读性。

  1. 提交代码时,必须使用 golint 对代码进行检查。

golint 检测举例:

  • 变量名规范
  • 变量的声明,像 var str string = "test" 会有警告,应该写成 var str = "test"
  • 大小写问题,大写导出包的要有注释。
  • x += 1 应该 x++
  1. 提交代码前,必须使用 go vet 对代码进行检查。

golint 检查的是代码规范,而 go vet 则是静态分析源码中存在的各种问题,例如:多余的代码,提前 return 的逻辑,struct 的 tag 是否符合标准等。执行指令 go vet . 即可。

大小约定

  • 单个文件长度不超过 500 行。
  • 单个函数长度不超过 50 行。
  • 单个函数圈复杂度最好不要超过 10,禁止超过 15。
  • 单行语句最多 80 个字符。
  • 函数中嵌套不超过 3 层。
  • 保持函数内部实现的组织粒度是相近的,用空行分隔。

使用 Context 来完成并发

Context 是官方推荐的并发模式,主要用于调度 goroutine,在很多库和框架都有支持。

因为 goroutine 创建成本极低,一个请求处理的过程中往往会产生很多和这个请求相关的 goroutine,请求处理结束或者中断后,没能及时结束的 goroutine 会泄漏, goroutine 本质上是线程,会继续占用 CPU,并且容易进一步导致内存泄漏。

Context 是一种接口,相同请求范围内的 goroutine 需要主动检查 Context 的状态来进 行合适的处理:

  • Done() <-chan struct{}:返回一个管道,当 Context 取消或者超时的时候会被关闭。
  • Err() error:返回 Done 管道关闭的原因。
  • Deadline() (deadline time.Time, ok bool):返回将要超时的时间
  • Value(key interface{}) interface{}:返回 Context 的值。

Context 是一个独立的变量,不能保存在结构体中,需要在第一个参数以名称 ctx 主动传递。

使用经过验证的第三方依赖

  • GIN(github.com/gin-gonic/gin):HTTP 框架,有中间件方案。
  • ORM(github.com/jinzhu/gorm):支持数据库连接池。
  • go-redis(github.com/go-redis/redis):Redis 的 Golang 客户端。
  • json-iterator(github.com/json-iterator/go):JSON 库。
  • logrus(https://github.com/sirupsen/logrus):结构化的日志模块。

代码风格

文件命名风格

  • 工具代码文件:驼峰命名描述工具作用,暴露 Pascal 风格的操作名。
  • 模型代码文件:全小写,描述单一模型,暴露 Pascal 风格的模型 type 或者 interface。
  • 业务代码文件:全小写,描述业务合辑,不得暴露工具类意图。
  • 测试代码文件:必须为某个代码的 _test.go 不得跨文件编写测试代码。

标识符命名风格

  • 包名和目录名保持一致。
  • 可导出名称需要大写开头的驼峰命名方式。e.g. APIClient
  • 局部名称使用小写开头的驼峰命名方式。e.g. apiClient
  • 常量命名规则:使用全部大写字母组成,并使用下划线分词。e.g. const APP_VER = "1.0"
  • 变量命名规则:遵循相应的英文表达或简写,不使用下划线和驼峰命名。e.g. var isExist bool
  • 函数命名规则: 动词 + 名词。
  • 结构体命名规则:名词或名词短语。
  • 接口命名规则:单个函数的接口名以 ”er” 作为后缀,例如:Reader、Writer。接口的实现则去掉 “er”,例如:Read、Write。
type Reader interface {
    Read(p []byte) (n int, err error)
}

// 多个函数接口
type WriteFlusher interface {
   Write([]byte) (int, error)
    Flush() error
}

函数

  • error 总是作为最后一个返回值。
  • 内部 goroutine 生存期和函数生存期相同。
  • 尽量设计同步调用,直接返回结果,而不要以其他同步方式。
  • 构造函数以 New + 类型名称的方式来命名。特别的,如果一个包中只有一个构造函数,可以直接使用 New。
  • 初始化函数,不要在 init 函数里做与变量初始化无关的工作。
  • 对于少量数据,不要传递指针。
  • 对于大量数据的 struct 可以考虑使用指针。
  • 传入的参数是 map,slice,chan 不要传递指针,因为 map,slice,chan 是引用类型,不需要传递指针的指针。

单元测试

  • 功能文件和单元测试文件放在同一目录下
  • 单元测试文件名带上 *_test 后缀。
  • 单元测试可以用 . 导入需要测试的包,除此之外都不要使用 . 进行简易导入。

错误与异常

  • 清晰区分错误和异常:
    • 错误(Error):作为流程的一部分,被调用方显式返回,调用方主动处理。
    • 异常(Exception):预料之外出现或者在流程中不应该出现的错误。
  • 错误是正常流程的一部分,应该直接 return。
  • 错误是一个接口,不是某种特定类型:
type error interface {
    Error() string
}
  • Golang 使用 panic 和 recover 来处理异常。
    • panic:接收一个错误,中断当前流程。尽量不要使用 panic,除非你知道你在做什么。
    • recover:一般和 defer 配合使用,恢复中断的流程并且处理对应的错误。
package main

import (
    "fmt"
    "regexp"
)

func main() {
    regex := `(\d` // should be `(\d)`
    defer func() {
        e := recover()
        if e != nil {
            fmt.Printf("compile error: %v\n", regex)
		} 
	} ()
    regexp.MustCompile(regex)
}

goroutine

  • goroutine:并不是协程,底层实现是个线程池,一 个 goroutine 在执行的过程中可能会跑在不同的线程和 CPU 上。
  • 因为 goroutine 是在线程池中执行,因此我们在 goroutine 中访问闭包需要考虑线程安全的问题。sync.Once 提供了一个线程安全的单次执行接口,常用于单例模式或者初始化的场景。
  • Golang 没有提供类似 thread.join 等待 goroutine 结束的接口,可以用 sync.WaitGroup 来实现。
  • goroutine 可以使用闭包特性访问上层函数的局部变量,或者多个 goroutine 共同修改同一个变量,这样很容易陷入了变量并发访问的陷阱。这个时候可以借助 sync.atomic 包提供的一系列底层内存同步原语来进行同步处理。
  • sync.Mutex 提供了一个基本的互斥锁,而且不同于 Python 的 threading.RLock,这种锁不可重入。
  • 锁对程序的性能会造成很大的影响,因此减少锁竞争时间是优化的关键。在读操作比写操作频繁的情况下,可以用 sync.RWMutex 实现的读优先(读者不竞争)读写锁来优化性能。

channel

  • 不要通过共享内存来通信,而应该通过通信来共享内存。
  • 创建管道时使用 make() 函数。不要直接声明,这样可能会导致 goroutine 死锁:
ch := make(chan int, 0)
  • 不带缓冲区的管道专用于同步模式,读写同步。
  • 带缓冲区的管道在以下情况会阻塞:
    • 当缓冲区满了之后,发送者会进入发送队列等待。
    • 当缓冲区为空,接收者会进入接收队列等待。
  • 管道可以使用内置函数 close 关闭,关闭后的管道需要注意:
    • 重复关闭会导致 panic。
    • 向关闭的管道发送数据会 panic。
    • 已关闭的管道读取数据会有以下情况:
      • 先返回缓冲区的数据,直到缓冲区为空。
      • 直接返回类型默认值,第二个返回值是 false。
    • 关闭管道会退出 for … range 循环。

项目

  • 使用 dep 来进行依赖管理。
  • vendor 目录要跟随项目进行版本控制,不要每次下载。

实践经验

Socket 编程

Socket 编程的流程:

  1. 建立 Socket
  2. 绑定 Socket
  3. 监听 Socket
  4. 接受连接请求
  5. 接收数据

Golang 的 net 标准库对此流程进行了抽象和封装。无论我们期望使用什么协议建立什么形式的连接,都只需要调用 net.Dial() 即可。

net.Dial() 支持的协议类型:

  • tcp
  • tcp4
  • tcp6
  • udp
  • udp4
  • udp6
  • ip
  • ip4
  • ip6
// TCP
conn, err := net.Dial("tcp", "192.168.0.10:2100")

// UDP
conn, err := net.Dial("udp", "192.168.0.12:975")

// ICMP
conn, err := net.Dial("ip4:icmp", "www.baidu.com")
// or
conn, err := net.Dial("ip4:1", "10.0.0.3")

HTTP 编程

Golang 的 net/http 标准库封装了 HTTP 编程函数。net/http 的 Client 类型提供了如下几个方法:

func (c *Client) Get(url string) (r *Response, err error)
func (c *Client) Post(url string, bodyType string, body io.Reader) (r *Response, err error)
func (c *Client) PostForm(url string, data url.Values) (r *Response, err error)
func (c *Client) Head(url string) (r *Response, err error)
func (c *Client) Do(req *Request) (resp *Response, err error)

RPC 编程

RPC 采用 C/S 工作模式。当执行一个远程过程调用时,客户端程序首先发送一个带有参数的调用信息到服务端,然后等待服务端响应。在服务端,服务进程保持睡眠状态直到客户端的调用信息到达为止。当一个调用信息到达时,服务端获得进程参数,计算出结果,并向客户端发送应答信息,然后等待下一个调用。最后,客户端接收来自服务端的应答信息,获得进 程结果,然后调用执行并继续进行。

一个对象中只有满足如下这些条件的方法,才能被 RPC 服务端设置为可供远程访问:

  • 必须是在对象外部可公开调用的方法(首字母大写)。
  • 必须有两个参数,且参数的类型都必须是包外部可以访问的类型或者是 Go 内建支持的类型。
  • 第二个参数必须是一个指针。
  • 方法必须返回一个 error 类型的值。

示例:

func (t *T) MethodName(argType T1, replyType *T2) error

JSON 处理

Golang 提供了的标准库 encoding/json 对 JSON 数据进行编解码,并且允许使用 map[string]interface{} 和 []interface{} 类型的值来分别存放未知结构的 JSON 对象或数组。

  1. 使用 json.Marshal() 函数对一组数据进行 JSON 格式的编码:
func Marshal(v interface{}) ([]byte, error)

示例:

type Book struct { 
	Title string
	Authors []string 
	Publisher string 
	IsPublished bool 
	Price float
}

gobook := Book{
	"Go 语言编程",
	["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan", "XuDaoli"],
	"xxx.com.cn",
	true,
	9.99
}

b, err := json.Marshal(gobook)

b == []byte(`{
	"Title": "Go语言编程",
	"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan", "XuDaoli"],
	"Publisher": "xxx.com.cn",
	"IsPublished": true,
	"Price": 9.99
}`)

编码时的数据类型映射如下:

  • 布尔值转化为 JSON 后还是布尔类型。
  • 浮点数和整型会被转化为 JSON 里边的常规数字。
  • 字符串将以 UTF-8 编码转化输出为 Unicode 字符集的字符串,特殊字符比如 “<” 将会被转义为 \u003c。
  • 数组和切片会转化为 JSON 里边的数组,但 []byte 类型的值将会被转化为 Base64 编码后的字符串,Slice 类型的零值会被转化为 null。
  • 结构体会转化为 JSON 对象,并且只有结构体里边以大写字母开头的可被导出的字段才会被转化输出,而这些可导出的字段会作为 JSON 对象的字符串索引。
  • 转化一个 map 类型的数据结构时,该数据的类型必须是 map[string]T(T 可以是 encoding/json 包支持的任意数据类型)。
  1. 使用 json.Unmarshal() 函数对 JSON 数据进行解码:
func Unmarshal(data []byte, v interface{}) error

解码时的数据类型映射:

  • JSON 中的布尔值将会转换为 Go 中的 bool 类型。
  • 数值会被转换为 Go 中的 float64 类型。
  • 字符串转换后还是 string 类型。
  • JSON 数组会转换为 []interface{} 类型。
  • JSON 对象会转换为 map[string]interface{} 类型。
  • null 值会转换为 nil。

数据库编程

import "database/sql"

func listTracks(db sql.DB, artist string, minYear, maxYear int) { 
    result, err := db.Exec(
        "SELECT * FROM tracks WHERE artist = ? AND ? <= year AND year <= ?", artist, minYear, maxYear)
    // ...
}

Exec() 方法使用 SQL 字面量替换在查询字符串中的每个 ‘?’。SQL 字面量表示相应参数的值,它有可能是一个布尔值,一个数字,一个字符串,或者 nil 空值。用这种方式构造查询可以帮助避免 SQL 注入攻击。

猜你喜欢

转载自blog.csdn.net/Jmilk/article/details/107290789