go学习笔记: 工程化

本文详细介绍了Go语言项目的标准目录结构,包括各部分的功能和用途,以及Go语言中的包管理和日志处理方法。此外,还深入探讨了错误处理的最佳实践,包括错误的定义、断言和处理方式。

学习资料

go官方文档:https://golang.google.cn/doc/

go语言中文网:https://studygolang.com/

《Effective Go》:https://golang.google.cn/doc/effective_go

《The Go Programming Language》

​ 英文版:https://github.com/adonovan/gopl.io/

​ 中文版:https://github.com/gopl-zh/gopl-zh.github.com

《go语言原本》:https://golang.design/under-the-hood/

《go语言设计与实现》:https://draveness.me/golang/

《Go语言101》:https://gfw.go101.org/article/101.html

《go语言问题集》:https://www.bookstack.cn/read/qcrao-Go-Questions/README.md

工程项目结构

参考该文章
https://github.com/golang-standards/project-layout/blob/master/README_zh.md

/cmd

本项目的主干。

每个应用程序的目录名应该与想要的可执行文件的名称相匹配(例如,/cmd/myapp)。

不要在这个目录中放置太多代码,通常有一个小的 main 函数,从 /internal/pkg 目录导入和调用代码。

/internal

私有应用程序和库代码,不希望其他人在其应用程序或库中导入的代码。

这个布局模式是由 Go 编译器本身执行的。Go语言1.4版本增加了 Internal packages 特征用于控制包的导入,即internal package只能被特定的包导入。内部包的规范约定:导出路径包含internal关键字的包,只允许internal的父级目录及父级目录的子包导入,其它包无法导入。

并不局限于顶级 internal 目录,在项目树的任何级别上都可以有多个内部目录。

应用程序共享的代码可以放在 /internal/pkg 目录下(例如 /internal/pkg/myprivlib)。

实际应用程序代码可以放在 /internal/app 目录下(例如 /internal/app/myapp),其下还可以分出

../model../dao../service../dto 等子目录。

/pkg

外部应用程序可以使用的库代码。

其他项目会导入这些库,希望它们能正常工作,所以在这里放东西之前要三思

/internla/pkg 一般用于项目内的跨多个模块的公共共享代码,但其作用域仅在单个项目工程内。

/api

OpenAPI 规范,JSON 模式文件,协议定义文件。

如果有多版本可以使用/api/v1/myapi

/config

配置文件模板或默认配置。

/web

特定于 Web 应用程序的组件: 静态 Web 资产、服务器端模板和 SPAs。

/scripts

执行各种构建、安装、分析等操作的脚本。

/test

额外的外部测试应用程序和测试数据。

对于较大的项目,有一个数据子目录是有意义的。例如/test/data/test/testdata

不应该包含:/src 目录,容易与 GOPATH 的 src 目录产生歧义

包管理

Golang 官方包管理工具的发展历史
  • 在 1.5 版本之前,所有的依赖包都是存放在 GOPATH 下,没有版本控制。这种方式的最大的弊端就是无法实现包的多版本控制,比如项目 A 和项目 B 依赖于不同版本的 package,如果 package 没有做到完全的向前兼容,往往会导致一些问题。
  • 1.5 版本推出了 vendor 机制。所谓 vendor 机制,就是每个项目的根目录下可以有一个 vendor 目录,里面存放了该项目的依赖的 package。go build 的时候会先去 vendor 目录查找依赖,如果没有找到会再去 GOPATH 目录下查找。
  • 1.9 版本推出了实验性质的包管理工具 dep。
  • 1.11 版本推出 modules 机制,简称 mod。
go mod 的使用
  1. 环境变量 GO111MODULE

    • GO111MODULE=off: 不使用 modules 功能,查找vendor和GOPATH目录

    • GO111MODULE=on: 使用 modules 功能,不会去 GOPATH 下面查找依赖包。

    • GO111MODULE=auto: Golang 自己检测是不是使用 modules 功能,如果当前目录不在$GOPATH 并且当前目录(或者父目录)下有go.mod文件,则使用 GO111MODULE, 否则仍旧使用 GOPATH mode

      set GO111MODULE=on    //windows
      export GO111MODULE=on //linux
      

    modules 在 Go 1.13 的版本后是默认开启的

  2. 初始化

    go mod init test-demo
    go run main.go
    

    执行 init 暂时还没有将所有的依赖管理起来。需要将程序 run 起来(比如执行 go run/test),或者 build(执行命令 go build)的时候,才会触发依赖的解析。

    同时项目目录下多了一个 go.sum 用来记录每个 package 的版本和哈希值。

    这些 package 并不是直接存储到 GOPATH/src,而是存储到 GOPATH/pkg/mod 下面,不同版本并存的方式。

  3. go.mod

    module test-demo
    
    go 1.16
    
    require (
        github.com/astaxie/beego v1.12.0 // indirect
        github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 // indirect
    )
    
    replace gopkg.in/yaml.v2 v2.2.1 => github.com/yaml v2.2.1 
    

    go.mod 文件正常情况会包含 module 和 require 模块,除此之外还可以包含 replace 和 exclude 模块。

    有indirect注释的代表间接依赖,没有的代表直接依赖,这里是版本号+时间戳+hash

  4. 依赖的升级和降级

    //查看当前项目依赖的所有包
    go list -m -u all
    //升级(降级)某个 package 则只需要 go get 即可,比如:
    go get package@version
    
  5. go proxy

    有些 Golang 的 package 在国内是无法直接 go get 的,可以通过设置 http_proxy/https_proxy 来解决。

    GoProxy 相当于官方提供了一种 proxy 的方式让用户来进行包下载。

    要使用 GoProxy 只需要设置环境变量 GOPROXY 即可。目前公开的 GOPROXY 有:

    • http://goproxy.io
    • http://goproxy.cn :由七牛云提供
    • https://proxy.golang.org :Go 1.13 版本后 GOPROXY 的默认值,这个对于国内的开发者是无法直接使用的。所以一定要把 GOPROXY 手动改掉。
    go env -w GOPROXY=https://goproxy.cn,direct
    
  6. go mod 常用命令

    go mod init:初始化modules
    go test    执行一下,自动导包
    
    go mod download:下载modules到本地cache
    go mod edit:编辑go.mod文件,选项有-json、-require和-exclude,可以使用帮助go help mod edit
    go mod graph:以文本模式打印模块需求图
    go mod tidy:检查,删除错误或者不使用的modules,下载没download的package
    go mod verify:验证依赖是否正确
    go mod why:查找依赖
    go list -m  主模块的打印路径
    go list -m -f={{.Dir}}  print主模块的根目录
    go list -m all  查看当前的依赖和版本信息
    

日志处理

go的log包
  1. 控制台输出日志

    package main
    
    import (
        "errors"
        "fmt"
        "log"
    )
    
    func division(x float32, y float32) (float32, error) {
        if y == 0 {
            return 0, errors.New("can't divide by zero")
        }
        return x / y, nil
    }
    
    func main() {
        var x float32 = 11
        var y float32
        res, err := division(x, y)
    
        if err != nil {
            log.Print(err)
        }
    
        fmt.Println(res)
    }
    
  2. 文件输出日志

    func main() {
    	file, err := os.OpenFile("info.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
    	if err != nil {
    		log.Fatal(err)
    	}
    	defer file.Close()
    	log.SetOutput(file)
    	
    	var x float32 = 11
    	var y float32 
    	res, exception := division(x, y)
    	if exception != nil {
    		log.Print(exception)
    	}
    	fmt.Println(res)
    }
    
glog 日志库

​ github:https://github.com/golang/glog

logrus日志库

​ github:https://github.com/sirupsen/logrus

日志的记录原则
  1. 错误要被日志记录
  2. 程序中处理错误要保证100%的完整性,要么往上层抛,要么处理,如果要吞掉error,应该对value的值负责任
  3. 错误只记录一次,如果当前对错误进行了处理并记录了日志,那么抛到上层之后不再记录当前值

错误处理

go的errors包和error接口
package errors

func New(text string) error {
	return &errorString{text}
}

type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

go的 errors 包定义了 New()函数,其返回值是内部 errorString 对象的指针,这样可以避免在进行error类型断言时误将字符串值相等但是类型不同的两个error判断为相等

errorString 对象实现了 error 接口,重写了 Error() 方法,返回值是其内部定义的string类型的变量 s

package builtin

type error interface {
   Error() string
}

func panic(v interface{})

func recover() interface{}

go支持多参数返回,所以可以在函数的返回值中带上实现了 error 接口的对象,交由调用者处理,让调用者知道什么时候出了问题。

对于程序运行时的异常,go保留了 panic 机制。panic 意味着代码不能继续运行,程序挂了,不能假设调用者来解决 panic

关于panic
  1. 不要在业务代码中主动制造panic,只能用来表示那些不可恢复的程序错误,比如索引越界、栈溢出、不可恢复的环境问题等。

  2. 对于初始化的代码,比如强依赖配置类的代码,可以panic。以便我们看到错误信息,及时做出修改

  3. 野生的goroutine是recover不住的,应该在代码中避免。

    func main() {
    	defer func() {
    		if err := recover(); err != nil{
    			fmt.Println(err)
    		}
    	}()
    
    	go func() {
    		fmt.Println("Hello world!")
    		panic("我来啦!")
    	}()
    }
    

    可以在项目的基础库里自定义一个包,开goroutine时默认自动 recover

    func Go(x func()){
    	go func() {
    		defer func() {
    			if err := recover(); err != nil {
    				fmt.Println(err)
    			}
    		}()
    		x()
    	}()
    }
    
    func main(){
    	Go(func(){
    		fmt.Println("Hello world!")
    		panic("我来啦!")
    	})
    }
    
error的定义方式

参考下列文章
https://blog.golang.org/error-handling-and-go

https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

https://ethancai.github.io/2017/12/29/Error-Handling-in-Go/

  1. errors.New()

    用特定的值来表示 error,这样在处理时需要进行字符串判等,在两个包之间创建了依赖,应该尽量避免这样使用

    var MyError = errors.New("xxxx")
    
  2. fmt.Errorf()

    可以通过 fmt.Errorf() 扩展 error,为 error 携带更多的上下文

    func readfile(path string) error {
        err := openfile(path)
        if err != nil {
            return fmt.Errorf("cannot open file: %v", err)
        }
        //...
    }
    
    func main() {
        err := readfile(".bashrc")
        //这种断言方式是不应该出现的
        if strings.Contains(error.Error(), "not found") {
            // handle error
        }
    }
    

    error interface 的 Error() 的输出是给人看的,不是给机器看的。我们通常会把Error方法返回的字符串打印到日志中,或者显示在控制台上。

    永远不要通过判断 Error 方法返回的字符串是否包含特定字符串,来决定错误处理的方式。

  3. error type

    与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文

    type MyError stuct{
    	Code int
    	Msg  string
    	File string
    	Line string
    }
    
    func (e *MyError) Error() string {
    	return fmt.Sprintf("%d, %s, %s, %s,", e.Code, e.Msg, e.File, e.Line)
    }
    

    自定义的错误类型因为要进行类型断言,所以需要将其设置为public的。

    这种错误定义方式也是有包级依赖关系的

  4. 不透明 error

    不透明错误处理的全部功能,只需返回错误而不假设其内容,就是不返回错误的具体内容。

    type temporary interface {
        Temporary() bool    // IsTemporary returns true if err is temporary.
    }
    
    //对外暴露的调用方法
    func IsTemporary(err error) bool {
        te, ok := err.(temporary)
        return ok && te.Temporary()
    }
    

    定义一个包级私有的接口 temporary,接口中定义一个方法 Temporary。对外暴露一个包级别的公开方法 IsTemporary,在这个方法中断言传入的error类型是否实现了temporary 接口,然后确定是否调用业务方法。

    这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的第三方package

    对于底层代码,如果想更换一个实现更好的error,也不用担心影响上层代码逻辑。

    对于上层代码的开发者,只需要关注error是否实现了特定行为,不用担心引用的第三方package升级后,程序逻辑失败。

    这是最灵活的错误处理策略,因为它要求代码和调用者之间的耦合最少。

error的断言方法
  1. 字符串判等

    //这种直接断言字符串的做法是不应该的
    err := test()
    if err.Error() == "xxx" {
    	fmt.Printf("error: %s", err)
    }
    
    //应该按照error的类型去断言
    if err, ok := err.(*MyError); ok {
         fmt.Printf("%d, %s", err.Code, err.Msg)
    }
    
    if err == io.EOF {
    	fmt.Printf("error:%s", err)
    }
    
  2. switch断言

    err := test()
    swith err := err.(type){
    case nil:
    	//successed nothing to do
    case *MyError:
    	fmt.Println("error:", err.Msg)
    default:
    	//unknown error
    } 
    
  3. errors.Is() 将错误与值进行比较

    https://blog.golang.org/go1.13-errors

    当多层调用返回的错误被一次次地包装起来,我们在调用链上游拿到的错误判断是否是底层的某个错误。
    errors.Is() 递归调用 Unwrap() 并判断每一层的 err 是否相等,如果有任何一层 err 和传入的目标错误相等,则返回 true。

    if errors.Is(err, io.EOF){
        //...
    }
    
  4. errors.As() 测试错误是否为特定类型。

    这个和上面的 errors.Is 大体上是一样的,区别在于 Is 是严格判断相等,即两个 error 是否相等。
    As 则是判断类型是否相同,并提取第一个符合目标类型的错误,用来统一处理某一类错误。

    // Similar to:
    //   if e, ok := err.(*QueryError); ok { … }
    var e *QueryError
    // Note: *QueryError is the type of the error.
    if errors.As(err, &e) {
     // err is a *QueryError, and e is set to the error's value
    }
    
error的处理方式

在代码中不能对于error做任何正确的假定,就是所有的error都需要判断处理,但是我们应该做到优雅的错误处理,增强程序的可读性

  1. 缩进,把业务逻辑不要写在 if 里面

    f, err := os.Open(path)
    if err != nil {
        // handle error
    }
    // do stuff
    

    不要像下面这样写

    f, err := os.Open(path)
    if err == nil {
        // do stuff
    }
    // handle error
    
  2. 对于中间层代码,不需要显式处理错误时,不要再判断 if err != nil

    func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
            return err  // No such file or directory
        }
        return nil
    }
    

    可以改写成以下这样

    func AuthenticateRequest(r *Request) error {
        return authenticate(r.User)
    }
    
  3. 如果同一个方法中重复调用同一个函数或者方法,将重复的逻辑进行了封装,然后把 error 暂存,就只需要在最后判断一下 error 就行了

    示例原文:https://blog.golang.org/errors-are-values

    重复的逻辑

    var fd io.Writer
    _, err = fd.Write(p0[a:b])
    if err != nil {
        return err
    }
    _, err = fd.Write(p1[c:d])
    if err != nil {
        return err
    }
    _, err = fd.Write(p2[e:f])
    if err != nil {
        return err
    }
    // and so on
    

    优雅的实现方式

    type errWriter struct {
        w   io.Writer
        err error
    }
    
    func (ew *errWriter) write(buf []byte) {
        if ew.err != nil {
            return
        }
        _, ew.err = ew.w.Write(buf)
    }
    
    // 使用时
    ew := &errWriter{w: fd}
    ew.write(p0[a:b])
    ew.write(p1[c:d])
    ew.write(p2[e:f])
    // and so on
    if ew.err != nil {
        return ew.err
    }
    
  4. 对于堆栈信息的携带

    github:https://github.com/pkg/errors

    errors.wrap

    使用 pkg/errors 的 Wrap 或者 Wrapf 函数对堆栈信息进行封装

    package main
    
    import (
        "errors"
        "fmt"
    
        xerrors "github.com/pkg/errors"
    )
    
    type ErrPath struct {
    	msg string
    }
    
    func (p *ErrPath) Error() string {
    	return p.msg
    }
    
    func main() {
    	err := test2()
    	// var e *ErrPath
    	e:= &ErrPath{}
    	if errors.As(err, &e) {
    		fmt.Println("err is ErrPath")
    	}
    	fmt.Printf("main: %+v\n", err)
    }
    
    //从这一层开始,每一次向上抛error时,堆栈信息会被包装一次
    func test0() error {
    	return xerrors.Wrap(&ErrPath{msg: "path not exists"}, "test0 failed")
    }
    
    func test1() error {
    	return test0()
    }
    
    func test2() error {
    	return test1()
    }
    

    在需要打印堆栈信息时,使用谓词%+v输出堆栈信息

    fmt.Printf("main: %+v\n", err)
    

    使用 errors.Cause()可以获取 root error

    switch err := errors.Cause(err).(type) {
    case *MyError:
    	// handle specifically
    default:
    	// unknown error
    }
    

    此外pkg/errors包还提供withStack()函数包装堆栈信息,WithMessage()函数包装上下文信息

    go1.13 后还提供了errors.Unwrap()对包装的error上下文和堆栈信息进行解包,可以根据需要对这些方法进行结合使用

  5. 关于error处理的一些规则总结

    • 在应用代码中使用errors.New 或者 errors.Errorf 返回错误
    • 如果只调用项目中其他包内的函数,没有更多业务逻辑,通常直接返回
    • 直接返回错误,而不是产生错误的地方到处打日志
    • 根据场景进行判断是否需要将其他库的原始错误吞掉,例如可以把 dao层的数据库相关错误吞掉,返回业务错误码,避免后续更换库时需要去修改上层代码
    • 和其他库(项目公共库或第三方库,类如与数据库操作的库),使用errors.warp 或者errors.warpf保存堆栈信息
    • 在我们项目中定义的基础库中不要使用 errors.warp,避免堆栈信息重复
    • 在程序的顶部或者是工作的goroutine顶部(请求入口),使用 %+v 把堆栈详情记录
    • 使用 errors.Cause() 获取 root error,再进行和 sentinel error 判定
    • 对于业务错误,在一个统一的地方创建一个错误字典,错误字典里面应该包含错误的code,并且在日志中作为独立字段打印,方便做业务告警的判断,错误应该有清晰的错误文档
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值