03. 接口、错误处理、泛型与包管理

3 阅读4分钟

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/response
  • common/pagination
  • app/gitea/repository
  • infrastructure/database

这样带来的好处是:

  • 目录职责更清晰
  • 依赖关系更容易追踪
  • 导入路径天然表达层次

一个贴近项目的理解方式

比如这一段处理器代码:

func (h *RepoHandler) ListWithPage(c *fiber.Ctx) error {
    var params gitea_req.RepoQueryParams
    if err := c.QueryParser(&params); 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 工程代码里的这条主线:

  1. 用包拆职责
  2. 用结构体和方法组织代码
  3. 用接口抽象行为
  4. error 串起失败路径
  5. 用泛型减少通用模板代码