03. 接口、错误处理、泛型与包管理
这一节开始进入“Go 为什么适合做工程代码”的部分。你会看到接口、错误处理、泛型和包组织是怎么配合起来的。
本节目标
- 理解 Go 接口的隐式实现机制
- 掌握 Go 里最常见的错误处理模式
- 了解泛型在项目中的实际价值
- 能读懂
go.mod、包导入路径和模块组织方式
接口:强调行为,而不是继承
Go 的接口描述的是“一个类型能做什么”,而不是“它继承自谁”。
type Repository interface {
List(ctx context.Context) ([]Repo, error)
FindOne(ctx context.Context, id int) (*Repo, error)
}
实现接口时不需要显式写 implements:
type RepoRepository struct{}
func (r *RepoRepository) List(ctx context.Context) ([]Repo, error) {
return nil, nil
}
func (r *RepoRepository) FindOne(ctx context.Context, id int) (*Repo, error) {
return nil, nil
}
只要方法集匹配,它就自动实现了接口。
这叫隐式实现,也是 Go 的一个核心设计。
为什么这种设计适合后端项目
因为它鼓励我们按“行为边界”拆分代码,比如:
- Handler 依赖 Service 的能力
- Service 依赖 Repository 的能力
- 测试时可以替换成 mock 实现
即使当前项目里很多地方直接依赖具体结构体,理解接口思想仍然非常重要,因为这会影响你如何做后续抽象。
错误处理:Go 不用 try-catch
Go 的主流写法是显式返回错误:
result, err := doWork()
if err != nil {
return err
}
fmt.Println(result)
这会让错误路径非常清晰。你几乎不需要猜“这里抛了什么异常”。
错误包装
工程代码里,推荐在向上返回错误时保留上下文:
if err != nil {
return fmt.Errorf("获取仓库失败: %w", err)
}
这种 %w 的写法在本项目仓储层里大量出现。它的好处是:
- 日志更容易看懂
- 错误链不会丢
- 后续可以用
errors.Is/errors.As继续判断
示例:
if err := repo.Upsert(ctx, item); err != nil {
return fmt.Errorf("upsert 仓库失败: %w", err)
}
什么时候直接返回,什么时候包装
可以用一个简单原则:
- 当前层能补充业务语义时,包装
- 当前层只是透传,直接返回也可以
例如仓储层知道自己在“查仓库”,那就适合包装成“获取仓库失败”;最顶层 HTTP handler 有时只需要把错误转成统一响应。
泛型:让通用代码更好复用
Go 从 1.18 开始支持泛型。本项目里一个非常实用的例子是统一响应结构:
type Response[T any] struct {
Code int `json:"code"`
Data T `json:"data"`
Message string `json:"message"`
}
func Success[T any](data T) Response[T] {
return Response[T]{
Code: 0,
Data: data,
Message: "success",
}
}
这意味着:
- 返回仓库列表时,
T可以是[]Repository - 返回分页数据时,
T可以是PaginationResponse[Repository] - 不需要为每种返回类型都写一份重复结构
分页结构同样用了泛型:
type PaginationResponse[T any] struct {
Data []T
Total int64
Page int
PageSize int
TotalPage int
}
包与模块
每个 Go 项目通常有一个 go.mod,它定义了模块名和依赖:
module sag-reg-server
go 1.25.3
这意味着项目内的导入路径会像这样:
import (
"sag-reg-server/common/response"
"sag-reg-server/app/gitea/repository"
)
可以把模块名理解成“当前仓库对外暴露的根命名空间”。
Go 的包组织习惯
Go 倾向于按目录拆包。目录名通常就是包名,比如:
common/responsecommon/paginationapp/gitea/repositoryinfrastructure/database
这样带来的好处是:
- 目录职责更清晰
- 依赖关系更容易追踪
- 导入路径天然表达层次
一个贴近项目的理解方式
比如这一段处理器代码:
func (h *RepoHandler) ListWithPage(c *fiber.Ctx) error {
var params gitea_req.RepoQueryParams
if err := c.QueryParser(¶ms); err != nil {
return response.BadRequestCtx(c, "参数解析失败")
}
params.Validate()
repos, total, err := h.repoRepo.ListWithPage(c.Context(), params.Page, params.PageSize, params.Keywords)
if err != nil {
return response.InternalServerCtx(c, "查询仓库列表失败")
}
return response.SuccessCtx(c, pagination.NewPaginationResponse(repos, int64(total), params.Page, params.PageSize))
}
你可以看到几件事同时发生:
gitea_req是导入别名RepoQueryParams是结构体Validate()是方法- 仓储层函数返回多个值
response.SuccessCtx(...)利用了泛型响应结构- 错误处理贯穿整个调用链
常见易错点
- 以为接口必须“提前设计好”,其实 Go 很多接口是从使用方倒推出来的
- 包名过长、目录层次过深,会让导入路径变得很重
- 泛型不是为了炫技,优先用在明显重复的通用结构上
- 错误信息不要只写
err.Error(),最好补上当前语义
小结
到这里你应该能看懂 Go 工程代码里的这条主线:
- 用包拆职责
- 用结构体和方法组织代码
- 用接口抽象行为
- 用
error串起失败路径 - 用泛型减少通用模板代码