Go语言学习笔记(七)- 错误处理与包管理
error 接口与 errors 包
Go语言的错误,不像Java/Python那样默认靠异常传播机制,而是一个普通返回值。Go内置的error类型本质上是一个接口。只要求实现一个方法Error() string;nil通常表示没有错误。
1 | type error interface { |
在Go代码里最常见的模板就是:
1 | result,err := doSomething() |
这种模式,就相当于Java的try/catch。
创建简单错误
errors.New(text)会创建一个只包含文本信息的error;每次调用errors.New都返回一个不同的error的值,即使文本相同。
1 | func divide(a, b int) (int, error) { |
日常开发中,errors.New适合创建简单、固定、无须携带上下文的错误。
创建带有格式化信息的错误
如果错误信息需要拼接变量,那就需要使用fmt.Errorf。
1 | func findUser(id int) error { |
错误包装
实际开发中,不止返回底层错误,还需要加上下文。
1 | return fmt.Errorf("read config %s failed %w",path,err) |
%w的意思就是:包装原始错误。使用%w且参数时error时,返回的错误会实现unwrap,从而可以被errors.Is和errors.As检查。
1 | func readConfig(path string) error { |
%v和%w有啥区别呢?
%v:只格式化错误文本。%w:包装error,保留错误链,可以使用errors.Is和errors.As判断。
errors.Is 判断错误链里是否包含某个错误
errors.Is(err,target)用来判断err或者它包装的底层错误里,是否包含指定错误。通常也是推荐使用errors.Is来检查错误链,而不是==。
1 | // sentinel error |
如果错误可能被包装过,不应该使用err == ErrUserNotFound,而应该使用errors.Is(err,ErrUserNotFound)。
errors.As:提取某种具体错误类型
errors.As用来从错误链中找出某种具体类型的错误。errors.As会检查整条错误链。
1 | func parseAge(s string) error { |
那么errors.Is和errors.As有啥区别呢?
errors.Is: 判断是不是某个目标错误,常用于sentinel errorerrors.As:判断能不能转换成某个错误类型,并提取出来。
errors.Unwrap: 手动拆开错误链
errors.Unwrap(err)会调用错误上的Unwrap() error方法,如果没有,就返回nil。
1 | func TestErrorsUnwrap(t *testing.T) { |
通常来说,更推荐使用errors.Is和errors.As。
1 | errors.Is(err, target) |
errors.Join 合并多个错误
errors.Join可以把多个error合并成一个error:Join会丢弃nil,如果全部都是nil,则返回nil,非nil的Join结果可以通过errors.Is和errors.As检查。
例如:批量处理时,不想遇到第一个错误就返回,而是收集所有错误。
1 | func validateName(name string) error { |
适合场景:
- 批量校验
- 批量删除
- 批量导入
- 多个资源清理失败
Go的error是一个内置接口,只有Error() string方法。Go通过多返回值显示返回错误,通常使用if nil != nil处理。简单错误可以用errors.New,带上下文的错误可以用fmt.Errorf。如果要保留底层错误链,应该使用%w包装,再通过errors.Is判断sentinel Error,通过errors.As提取具体错误类型。
自定义错误类型
自定义错误类型的核心是:只要事先Error() string方法,就实现了error接口。
对于Java来说
1 | class MyException extends Exception |
如果是Go,则只需要:
1 | func (e MyError) Error() string{ |
一个简单的例子:
1 | type UserNotFound struct { |
自定义错误类型适合如下场景:
- 错误需要携带结构化字段
- 调用方需要根据字段做判断
- 需要保留底层错误
- 业务系统有错误码
- API层需要把内部错误映射为HTTP状态码
例如:把业务是错误转为HTTP响应
1 | type AppError struct { |
在Go中,自定义错误类型只要实现Error() string就实现了error接口。如果错误需要携带结构化信息,比如错误码、资源ID、路径、HTTP状态码,就适合使用自定义错误类型。如果自定义错误类内部包装了底层错误,应实现Unwrap(),这样调用方仍然可以使用errors.Is和error.As检查错误链。
go mod
Go的现代项目基本都是用module。一个module有go.mod文件定义,go.mod会描述当前模块路径、Go版本和依赖模块等信息。相当于Java的pom.xml/build.gradle。
go mod init
初始化模块,go mod init会在当前目录初始化并写入新的go.mod文件。
1 | mkdir learn-go |
此时就会在learn-go目录下生成一个go.mod文件,内容为:
1 | module dev.net.cn/learn-go |
其中module dev.net.cn/learn-go就是模块路径,真实目录通常写仓库地址:
1 | github.com/xxx/xxx |
如果没有自己的域名,那就可以使用github仓库来命名(这种比较推荐),很多Go的模块都是如此。
1 | # 例如我想做个gin-json的模块,就可以使用如下方式,然后在github创建仓库名为gin-json即可。 |
尽可能不要随便写模块名:
1 | go mod init test |
因为后续包导入路径会基于module path。
初始体验,个人觉得不如maven。
module、package、repository的关系
Go程序由package组织;一个package是同一目录下共同编译的一组GO源文件。一个repository可以包含一个或多个module,而一个module是一起发布的一组相关package。通过下面一个结构就能理解:
1 | repository 仓库 |
例如:
1 | gin-json/ |
如果要导入路径:
1 | import "dev.net.cn/gin-json/internal/service" |
go.sum
添加完以来后,Go会生成go.sum,里面记录依赖模块的校验和,用来验证下载的模块文件完整性。
1 | go get github.com/google/uuid |
在代码里使用:
1 | package day7 |
此时,项目根目录下面会存在两个文件go.mod和go.sum,这两个文件都需要提交到git仓库。
go mod tidy
整理依赖:go mod tidy会让go.mod和源码实际使用的依赖保持一致。他会添加缺失的依赖,删除不再提供相关包的依赖,并更新go.sum。
1 | go get github.com/gin-gonic/gin |
indirect依赖
在go.mod里可以看到
1 | require github.com/some/lib v1.2.3 // indirect |
这个意思就是:当前项目代码没有直接import它,但它是你得直接依赖的依赖。//indirect表示这个目录没有提供主模块中包直接导入的package。 类比就是Java第三方库依赖的其他依赖。
这个不需要自己维护,所以无需上心,知道是啥意思就行。
go mod vendor
把依赖复制到vendor目录:go mod vendor会在主模块根目录创建vendor目录,复制构建和测试当前模块所需的依赖包,他会生成vendor/modules.txt,记录依赖包和版本。
1 | go mod vendor |
vendor/modules.txt内容为:
1 | # github.com/google/uuid v1.6.0 |
然后使用vendor构建:
1 | # 显示声明 |
需要使用vendor的场景,基本上就是如下:
- 内网环境,不能访问外网(to G)
- 构建环境要求所有依赖在仓库里
- 对供应链安全又强约束
- 老项目
- 希望构建时不访问网络。
记得以前在生产使用jenkins构建部署前端项目时,直接将npm的node_modules上传到服务器上,Maven也是要将.m2仓库上传到指定服务器上。就这意思~~
replace 本地调试依赖
如果有两个项目
1 | user-service |
user-service/go.mod:
1 | module dev.net.cn/user-service |
这样本地开发时,user-service就会使用本地的common-lib。
注意,提交代码的时候,replace是否需要提交,自己要特别做一下判断。理论上都不应该提交。
包的组织与可见性(大小写)
对于Java来说,Java包往往都比较深:
1 | src/main/java/cn/net/dev/user-cms/user/service/UserService.java |
而Go更偏向:
1 | internal/service/user.go |
Go的基本单位是package,一个package是同一个目录中,一起编译的一组Go源文件,同一个package的不同文件可以互相访问不辞定义的函数、类型、变量和常量。
还有一个特别要注意的:
同一个文件夹(package)下,所有源文件必须属于同一个 package(包)
例如在service这个包或者是目录下面,所有的.go源码中,package都必须是service,不能有些是package service有些是package main。当然,_test.go包是个例外。
所以,这里也是建议,往后的go项目结构应当是如下:
1 | my_project/ |
如果一个目录要编译成可执行程序,包名必须是package main。并且通常有func main(){}方法。例如:
1 | my-api/ |
main.go
1 | package main |
运行:
1 | go run ./cmd/server |
正如上面提到的,通常建议把入口放在cmd目录下(尤其是仓库中有多个命令时):
1 | cmd/server/main.go |
大写导出,小写私有
这是Go可见性的核心。
Go规范规定:一个标识符如果首字母是Unicode大写字母,并且它声明在package block中,或者是字段名/方法名,那么他就是exported;其他标识符都不是exported。
通俗讲就是
- 大写开头,包外可访问
- 小写开头,只能在当前package内可访问。
例如:
1 | package user |
其他包可以访问:
1 | user.User |
但不能访问
1 | u.password |
struct字段大小写也影响JSON
1 | type User struct { |
虽然有了json标记,但小谢字段password对encoding/json也是不可导出的,外部包不能通过反射正常访问它,所以不会被正常序列化。当然,将他改成大写就可以了,但如果不想将敏感字段进行序列化,最好是不要将其放入response struct
1 | type UserResponse struct { |
应该和Java差不多,Go也会分model、DTO之类的分层。
包名应该短小且不重复
Go包名一般短小、全小写
例如:
1 | package user |
不推荐
1 | package userService |
Go常见风格是:
1 | user.NewService() |
internal目录:限制包导入范围
internal是Go非常重要的工程化机制,位于internal目录或者其子目录下的代码,只能被internal父目录树内的代码导入。
1 | todo-api/ |
cmd/server/main.go可以导入
1 | import "dev.net.cn/todo-api/internal/service" |
但另一个外部项目不能导入
1 | // use of internal package not allowed |
日常建议:
- internal/ 放项目内部实现
- pkg/ 放希望别人import的公共库
- cmd/ 放可执行程序入口
例如:
1 | todo-api/ |
含义:
1 | cmd/server:程序入口 |
但也要注意,Go 不是 Java,不需要为了“架构感”建很多包。



