error 接口与 errors 包

Go语言的错误,不像Java/Python那样默认靠异常传播机制,而是一个普通返回值。Go内置的error类型本质上是一个接口。只要求实现一个方法Error() stringnil通常表示没有错误。

1
2
3
type error interface {
Error() string
}

在Go代码里最常见的模板就是:

1
2
3
4
result,err := doSomething()
if err !=nil{
return err
}

这种模式,就相当于Javatry/catch

创建简单错误

errors.New(text)会创建一个只包含文本信息的error;每次调用errors.New都返回一个不同的error的值,即使文本相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}

func TestErrorNew(t *testing.T) {
v, err := divide(4, 2)
if err != nil {
t.Error(err)
return
}
fmt.Println(v)
}

日常开发中,errors.New适合创建简单固定无须携带上下文的错误。

创建带有格式化信息的错误

如果错误信息需要拼接变量,那就需要使用fmt.Errorf

1
2
3
4
5
6
7
8
9
10
func findUser(id int) error {
return fmt.Errorf("user %d not found", id)
}

func TestErrorFindUser(t *testing.T) {
err := findUser(1)
if err != nil {
t.Error(err)
}
}

错误包装

实际开发中,不止返回底层错误,还需要加上下文。

1
return fmt.Errorf("read config %s failed %w",path,err)

%w的意思就是:包装原始错误。使用%w且参数时error时,返回的错误会实现unwrap,从而可以被errors.Iserrors.As检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func readConfig(path string) error {
_, err := os.Open(path)
if err != nil {
// 注意这里一定是%w,千万不要写成%v,%v 只是把错误转成字符串,原始错误链会丢失。
return fmt.Errorf("read config %s failed : %w", path, err)
}
return nil
}

func TestErrorReadConfig(t *testing.T) {
err := readConfig("config.yaml")
if err != nil {
fmt.Println(err)
if errors.Is(err, os.ErrNotExist) {
fmt.Println("config file does not exist")
}
}
}

// 输出
// read config config.yaml failed : open config.yaml: The system cannot find the file specified.
// config file does not exist

%v%w有啥区别呢?

  • %v:只格式化错误文本。
  • %w:包装error,保留错误链,可以使用errors.Iserrors.As判断。

errors.Is 判断错误链里是否包含某个错误

errors.Is(err,target)用来判断err或者它包装的底层错误里,是否包含指定错误。通常也是推荐使用errors.Is来检查错误链,而不是==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// sentinel error
var ErrUserNotFound = errors.New("user not found")

func getUser(id int) error {
return fmt.Errorf("query user %d failed:%w", id, ErrUserNotFound)
}

func TestErrorGetUser(t *testing.T) {
err := getUser(1)
if errors.Is(err, ErrUserNotFound) {
t.Log("handle user not found")
} else if err != nil {
t.Error(err)
}
}

如果错误可能被包装过,不应该使用err == ErrUserNotFound,而应该使用errors.Is(err,ErrUserNotFound)

errors.As:提取某种具体错误类型

errors.As用来从错误链中找出某种具体类型的错误。errors.As会检查整条错误链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func parseAge(s string) error {
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("parse age failed: %w", err)
}
return nil
}

func TestErrorParseAge(t *testing.T) {
err := parseAge("age")
var numErr *strconv.NumError
// errors.As需要把找到的错误复制到变量里
if errors.As(err, &numErr) {
// invalid number : age
fmt.Println("invalid number :", numErr.Num)
// function : Atoi
fmt.Println("function :", numErr.Func)
}
}

那么errors.Iserrors.As有啥区别呢?

  • errors.Is : 判断是不是某个目标错误,常用于sentinel error
  • errors.As:判断能不能转换成某个错误类型,并提取出来。

errors.Unwrap: 手动拆开错误链

errors.Unwrap(err)会调用错误上的Unwrap() error方法,如果没有,就返回nil

1
2
3
4
5
6
7
8
9
func TestErrorsUnwrap(t *testing.T) {
base := errors.New("base error")
wrapped := fmt.Errorf("service failed: %w", base)
// service failed: base error
fmt.Println(wrapped)
inner := errors.Unwrap(wrapped)
// base error
fmt.Println(inner)
}

通常来说,更推荐使用errors.Iserrors.As

1
2
errors.Is(err, target)
errors.As(err, &target)

errors.Join 合并多个错误

errors.Join可以把多个error合并成一个errorJoin会丢弃nil,如果全部都是nil,则返回nil,非nilJoin结果可以通过errors.Iserrors.As检查。

例如:批量处理时,不想遇到第一个错误就返回,而是收集所有错误。

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
func validateName(name string) error {
if name == "" {
return errors.New("name is empty")
}
return nil
}

func validateAge(age int) error {
if age <= 0 {
return errors.New("age must be greater than zero")
}
return nil
}

func validateUser(name string, age int) error {
var errs []error
if err := validateName(name); err != nil {
errs = append(errs, err)
}
if err := validateAge(age); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}

func TestErrorsJoin(t *testing.T) {
err := validateUser("", -1)
if err != nil {
// error_test.go:131: name is empty
// age must be greater than zero
t.Log(err)
}
}

适合场景:

  • 批量校验
  • 批量删除
  • 批量导入
  • 多个资源清理失败

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
2
3
func (e MyError) Error() string{
return "xxxxx"
}

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type UserNotFound struct {
UserID int
}

func (e UserNotFound) Error() string {
return fmt.Sprintf("user %d not found", e.UserID)
}

func findUserByUserId(id int) error {
return UserNotFound{UserID: id}
}

func TestError1(t *testing.T) {
err := findUserByUserId(1)
if err != nil {
t.Log(err)
}
}

自定义错误类型适合如下场景:

  1. 错误需要携带结构化字段
  2. 调用方需要根据字段做判断
  3. 需要保留底层错误
  4. 业务系统有错误码
  5. API层需要把内部错误映射为HTTP状态码

例如:把业务是错误转为HTTP响应

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
type AppError struct {
Code string
Message string
HTTPStatus int
}

func (e *AppError) Error() string {
return e.Code + " : " + e.Message
}

var ErrUserNotFound2 = &AppError{
Code: "USER_NOT_FOUND",
Message: "user not found",
HTTPStatus: http.StatusNotFound,
}

func getUser1(id int) error {
if id == 0 {
return ErrUserNotFound2
}
return nil
}

func handle() {
err := getUser1(0)
if err == nil {
return
}

var appErr *AppError
if errors.As(err, &appErr) {
fmt.Println("status: ", appErr.HTTPStatus)
fmt.Println("code: ", appErr.Code)
return
}

fmt.Println("status: ", http.StatusInternalServerError)
}

在Go中,自定义错误类型只要实现Error() string就实现了error接口。如果错误需要携带结构化信息,比如错误码、资源ID、路径、HTTP状态码,就适合使用自定义错误类型。如果自定义错误类内部包装了底层错误,应实现Unwrap(),这样调用方仍然可以使用errors.Iserror.As检查错误链。

go mod

Go的现代项目基本都是用module。一个modulego.mod文件定义,go.mod会描述当前模块路径、Go版本和依赖模块等信息。相当于Javapom.xml/build.gradle

go mod init

初始化模块,go mod init会在当前目录初始化并写入新的go.mod文件。

1
2
3
mkdir learn-go
cd learn-go
go mod init dev.net.cn/learn-go

此时就会在learn-go目录下生成一个go.mod文件,内容为:

1
2
3
module dev.net.cn/learn-go

go 1.26.3

其中module dev.net.cn/learn-go就是模块路径,真实目录通常写仓库地址:

1
2
3
4
github.com/xxx/xxx
// 例如本站dev.net.cn/learn-go
// 和Java不同的是,这里的域名不需要倒着写,因为Go 的依赖管理工具会根据模块路径去真实的服务器拉取代码
company.com/xxx/xxx

如果没有自己的域名,那就可以使用github仓库来命名(这种比较推荐),很多Go的模块都是如此。

1
2
# 例如我想做个gin-json的模块,就可以使用如下方式,然后在github创建仓库名为gin-json即可。
go mod init github.com/idevly/gin-json

尽可能不要随便写模块名:

1
go mod init test

因为后续包导入路径会基于module path

初始体验,个人觉得不如maven。

module、package、repository的关系

Go程序由package组织;一个package是同一目录下共同编译的一组GO源文件。一个repository可以包含一个或多个module,而一个module是一起发布的一组相关package。通过下面一个结构就能理解:

1
2
3
repository 仓库
└── module 模块,go.mod 所在目录
└── package 包,一个目录通常一个 package

例如:

1
2
3
4
5
6
7
8
gin-json/
go.mod module dev.net.cn/gin-json
main.go package main
internal/
service/
todo_service.go package service
repository/
todo_repo.go package repository

如果要导入路径:

1
import "dev.net.cn/gin-json/internal/service"

go.sum

添加完以来后,Go会生成go.sum,里面记录依赖模块的校验和,用来验证下载的模块文件完整性。

1
go get github.com/google/uuid

在代码里使用:

1
2
3
4
5
6
7
8
9
10
11
12
package day7

import (
"testing"

"github.com/google/uuid"
)

func TestGoMod(t *testing.T) {
t.Log(uuid.NewString())
}

此时,项目根目录下面会存在两个文件go.modgo.sum,这两个文件都需要提交到git仓库。

go mod tidy

整理依赖:go mod tidy会让go.mod和源码实际使用的依赖保持一致。他会添加缺失的依赖,删除不再提供相关包的依赖,并更新go.sum

1
2
3
4
go get github.com/gin-gonic/gin
# 如果代码里没有任何使用gin的地方,但是它存在在go.mod中,那么执行此命令就会删掉这个模块,并且更新go.sum
go mod tidy
# 反之,如果代码里使用了某个模块,但没有下载这个模块,执行go mod tidy也会自动下载。

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
2
3
4
5
6
7
8
9
go mod vendor


d---- 2026/6/3 10:21  day7
d---- 2026/6/3 10:33  vendor
-a--- 2026/6/3 10:27 65  go.mod
-a--- 2026/6/3 10:27 163  go.sum
# vendor里面就是如下结构
D:\GolandProjects\hellogo\vendor\github.com\google\uuid

vendor/modules.txt内容为:

1
2
3
# github.com/google/uuid v1.6.0
## explicit
github.com/google/uuid

然后使用vendor构建:

1
2
3
4
5
6
# 显示声明
go build -mod=vendor ./...
go test -mod=vendor ./...
# go 1.14以后 默认即可,它会自动使用vendor
go build ./...
go test ./...

需要使用vendor的场景,基本上就是如下:

  1. 内网环境,不能访问外网(to G)
  2. 构建环境要求所有依赖在仓库里
  3. 对供应链安全又强约束
  4. 老项目
  5. 希望构建时不访问网络。

记得以前在生产使用jenkins构建部署前端项目时,直接将npm的node_modules上传到服务器上,Maven也是要将.m2仓库上传到指定服务器上。就这意思~~

replace 本地调试依赖

如果有两个项目

1
2
user-service
common-lib

user-service/go.mod

1
2
3
4
module dev.net.cn/user-service
require dev.net.cn/common-lib v1.0.0

replace dev.net.cn/common-lib => ./common-lib

这样本地开发时,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
2
3
4
5
6
7
8
my_project/
├── go.mod
├── cmd/
│ └── my_app/
│ └── main.go // package main(单独的目录)
└── internal/ (或直接在根目录)
└── service/
└── todo_service.go // package service

如果一个目录要编译成可执行程序,包名必须是package main。并且通常有func main(){}方法。例如:

1
2
3
4
my-api/
cmd/
server/
main.go

main.go

1
2
3
4
5
6
7
package main

import "fmt"

func main(){
fmt.Println("Hello World")
}

运行:

1
go run ./cmd/server

正如上面提到的,通常建议把入口放在cmd目录下(尤其是仓库中有多个命令时):

1
2
3
cmd/server/main.go
cmd/migrate/main.go
cmd/worker/main.go

大写导出,小写私有

这是Go可见性的核心。

Go规范规定:一个标识符如果首字母是Unicode大写字母,并且它声明在package block中,或者是字段名/方法名,那么他就是exported;其他标识符都不是exported

通俗讲就是

  • 大写开头,包外可访问
  • 小写开头,只能在当前package内可访问。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package user

type User struct {
// 大写
ID int
Name string
// 小写
password string
}

// 大写
func NewUser(name string) User {
return User{
ID: 1,
Name: name,
password: "123456",
}
}

// 小写
func validateName(name string) bool {
return name != ""
}

其他包可以访问:

1
2
3
4
user.User
user.NewUser
u.ID
u.Name

但不能访问

1
2
u.password
user.validateName

struct字段大小写也影响JSON

1
2
3
4
5
6
7
type User struct {
// 大写
ID int `json:"id"`
Name string `json:"name"`
// 小写 结构体字段 'password' 具有 'json' 标记,但未被导出
password string `json:"password"`
}

虽然有了json标记,但小谢字段passwordencoding/json也是不可导出的,外部包不能通过反射正常访问它,所以不会被正常序列化。当然,将他改成大写就可以了,但如果不想将敏感字段进行序列化,最好是不要将其放入response struct

1
2
3
4
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
}

应该和Java差不多,Go也会分modelDTO之类的分层。

包名应该短小且不重复

Go包名一般短小、全小写

例如:

1
2
3
4
5
package user
package service
package repository
package dto
package http

不推荐

1
2
3
package userService
package userservice
package user_service

Go常见风格是:

1
2
3
4
user.NewService()
// 反例 userservice.NewUserService()
repo.New()
config.Load()

internal目录:限制包导入范围

internal是Go非常重要的工程化机制,位于internal目录或者其子目录下的代码,只能被internal父目录树内的代码导入。

1
2
3
4
5
6
7
8
todo-api/
go.mod
internal/
service/
todo_service.go
cmd/
server/
main.go

cmd/server/main.go可以导入

1
import "dev.net.cn/todo-api/internal/service"

但另一个外部项目不能导入

1
2
// use of internal package not allowed
// import "dev.net.cn/todo-api/internal/service"

日常建议:

  • internal/ 放项目内部实现
  • pkg/ 放希望别人import的公共库
  • cmd/ 放可执行程序入口

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
todo-api/
go.mod
cmd/
server/
main.go
internal/
handler/
todo_handler.go
service/
todo_service.go
repository/
todo_repository.go
model/
todo.go
pkg/
response/
response.go

含义:

1
2
3
4
5
6
cmd/server:程序入口
internal/handler:HTTP handler
internal/service:业务逻辑
internal/repository:数据库访问
internal/model:内部模型
pkg/response:可复用公共包

但也要注意,Go 不是 Java,不需要为了“架构感”建很多包。