关于 go-web-expert
面向 Go Web 开发者的生产实践指南,以五项规则约束处理器、服务和代码审查。重点包括依赖注入、输入验证、错误处理、HTTP 测试与文档。
主要功能
- 01将可变依赖注入服务器结构体
- 02显式包装并处理错误
- 03在请求边界验证 JSON
- 04使用 httptest 编写表驱动测试
- 05为导出符号编写文档注释
- 06按检查门槛审查处理器变更
适用场景
- 01实现新的 HTTP 处理器
- 02检查请求验证与错误响应
- 03为处理器和中间件编写测试
- 04审查 Go Web 代码是否满足约定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill go-web-expert安装后在 Claude Code 中输入/go-web-expert确认已加载。
附带文件references/testing-handlers.mdreferences/validation.md
详细介绍
Go Web Expert System
构建高质量生产级 Go Web 应用的五条不可妥协规则。每个处理器、每个服务、每行代码都必须满足全部五条规则。
快速参考
| 主题 | 参考资料 |
|---|---|
| 验证标签、自定义验证器、嵌套结构体、错误格式化 | references/validation.md |
| httptest 模式、中间件测试、集成测试、测试夹具 | references/testing-handlers.md |
行动规则
| # | 规则 | 一句话说明 |
|---|---|---|
| 1 | 零全局状态 | 所有处理器都是结构体的方法;不要使用包级 var 保存可变状态 |
| 2 | 显式处理错误 | 检查每个错误,并使用 fmt.Errorf("doing X: %w", err) 包装 |
| 3 | 优先验证 | 在边界处使用 go-playground/validator 验证所有传入的 JSON |
| 4 | 可测试性 | 每个处理器都有一个使用 httptest 的 _test.go 文件,并采用表驱动测试 |
| 5 | 文档 | 每个导出符号都有 Go 文档注释,且注释以其名称开头 |
硬性门槛(新增 HTTP 处理器)
按顺序执行。在当前步骤满足通过条件之前,不要将下一步视为已完成(必须有磁盘上的客观证据或测试输出,而不是“我已经在脑中检查过了”)。
-
依赖项(规则 1) — **通过条件:**处理器是结构体的方法,且该结构体持有每个可变依赖项(
db、日志记录器、HTTP 客户端、缓存);任何新增的包级var都必须属于包级允许项中的白名单。*证据:*构造函数完成依赖项装配;没有新增列表中的禁用全局变量。 -
边界(规则 3) — 必须先通过,才能调用服务或领域代码:**通过条件:**请求解码为带标签的结构体,并执行
validate.Struct(或等效操作);对无效 JSON 和验证失败定义了 HTTP 状态码响应体(例如 400/422)。*证据:*处理器中出现解码和validate.Struct;测试或手动运行显示错误输入会返回 422/400。 -
错误(规则 2) — **通过条件:**处理器调用路径中没有用
_丢弃错误的情况;处理json.NewEncoder(w).Encode的错误;向上返回或记录的错误使用包装(%w)或本规范要求的AppError映射。*证据:*检查差异,确认没有被忽略的错误,也没有在需要包装时直接return err而不附带上下文。 -
测试(规则 4) — **通过条件:**处理器所在包中存在
_test.go文件,并使用httptest调用ServeHTTP,至少包含一个成功用例和一个非 2xx 用例(验证失败、未找到或领域错误)。*证据:*测试文件路径存在;该包的go test通过。 -
文档(规则 5) — 通过条件:改动中每个新增或变更的导出标识符都有文档注释,且首行以该标识符的名称开头。证据:
go doc <pkg>或 IDE/文档预览中显示新导出标识符的摘要。
规则 1:零全局状态
所有处理器都必须是服务端结构体的方法。不要使用包级 var 保存数据库、日志记录器、客户端或任何可变状态。
// FORBIDDEN
var db *sql.DB
var logger *slog.Logger
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := db.QueryRow(...) // global state -- untestable, unsafe
}
// REQUIRED
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := s.db.QueryRow(...) // explicit dependency
}
包级允许项
- 常量 --
const maxPageSize = 100 - 纯函数 -- 没有副作用且只依赖其参数的函数
- 哨兵错误 --
var ErrNotFound = errors.New("not found") - 验证器实例 --
var validate = validator.New()(初始化后无状态)
包级禁用项
- 数据库连接(
*sql.DB、*pgxpool.Pool) - 日志记录器(
*slog.Logger) - 配置了超时或传输层的 HTTP 客户端
- 从环境中读取的配置结构体
- 缓存、速率限制器或任何可变共享资源
构造函数模式
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
规则 2:显式处理错误
绝不要忽略错误。每个错误都必须附带上下文进行包装,说明发生错误时正在尝试执行什么操作。
// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data) // error ignored
// REQUIRED
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something for user %s: %w", userID, err)
}
if err := json.NewEncoder(w).Encode(data); err != nil {
s.logger.Error("encoding response", "err", err, "request_id", reqID)
}
错误包装约定
格式:"<verb>ing <noun>: %w"——小写,无句号,提供调用链上下文。
// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)
// Bad wrapping
return fmt.Errorf("error: %w", err) // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err // no wrapping at all
HTTP API 的结构化错误类型
type AppError struct {
Code int `json:"-"`
Message string `json:"error"`
Detail string `json:"detail,omitempty"`
}
func (e *AppError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
writeJSON(w, appErr.Code, appErr)
return
}
slog.Error("unhandled error",
"err", err,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
常见错误
// MISTAKE: not checking Close errors on writers
defer f.Close() // at minimum, log Close errors for writable resources
// BETTER for writable resources:
defer func() {
if err := f.Close(); err != nil {
s.logger.Error("closing file", "err", err)
}
}()
// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()
规则 3:先验证
对所有传入的 JSON 使用 go-playground/validator。在边界处进行验证,并信任内部数据。
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,gte=0,lte=150"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
}
if err := validate.Struct(req); err != nil {
return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
}
// From here, req is trusted
user, err := s.userService.Create(r.Context(), req.Name, req.Email)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
writeJSON(w, http.StatusCreated, user)
return nil
}
验证错误格式
func formatValidationErrors(err error) string {
var msgs []string
for _, e := range err.(validator.ValidationErrors) {
msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
}
return strings.Join(msgs, "; ")
}
验证边界规则
- 在边界处验证——HTTP 处理程序、消息消费者、CLI 输入
- 信任内部数据——服务层接收已验证的类型
- 绝不重复验证——如果处理程序已验证,服务就不应再次验证相同字段
有关自定义验证器、嵌套结构体验证、切片验证和跨字段验证,请参阅 references/validation.md。
规则 4:可测试性
每个处理程序都必须有对应的 _test.go 文件,并使用 httptest。通过 HTTP 层测试,而不是直接调用处理程序方法。
func TestServer_handleGetUser(t *testing.T) {
mockStore := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
srv := NewServer(mockStore, slog.Default())
tests := []struct {
name string
path string
wantStatus int
wantBody string
}{
{
name: "existing user",
path: "/api/users/123",
wantStatus: http.StatusOK,
wantBody: `"name":"Alice"`,
},
{
name: "not found",
path: "/api/users/999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
}
})
}
}
测试原则
- 通过 HTTP 测试——使用
httptest.NewRequest和httptest.NewRecorder,调用srv.ServeHTTP - 基于接口的模拟对象——在调用方定义精简接口,并为测试创建模拟实现
- 表驱动测试——使用一个包含测试用例的
[]struct,并通过一个t.Run循环运行 - 错误路径同样重要——测试 400、404、422 和 500 响应,而不只是 200
- 不使用全局测试状态——每个测试都创建自己的服务及其模拟对象
有关中间件测试、使用真实数据库的集成测试、文件上传测试和流式响应测试,请参阅 references/testing-handlers.md。
规则 5:文档
每个导出的函数、类型、方法和常量都必须有遵循标准约定的 Go 文档注释。
// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
// ...
}
// Server handles HTTP requests for the user API.
type Server struct {
// ...
}
// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
// ...
}
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")
文档注释约定
- 以名称开头——使用
// CreateUser creates...,而不是// This function creates... - 第一句是摘要——显示在
go doc列表和 IDE 工具提示中 - 提及重要的错误返回值——调用方需要知道应检查哪些错误
- 不要记录显而易见的内容——
// SetName sets the name没有任何价值 - 说明原因,而非内容——行为不明显时,解释其缘由
包文档
// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user
跨领域关注点
这五条规则彼此强化。以下是它们之间的相互作用。
无全局状态有助于提高可测试性
由于所有依赖项都在结构体上,测试可以注入模拟对象:
// Production
srv := NewServer(realDB, prodLogger)
// Test
srv := NewServer(mockStore, slog.Default())
如果 db 是全局 var,测试就需要修改包状态,从而导致并行测试中的竞态条件。
先验证可简化错误处理
当处理程序在边界处进行验证时,服务层就可以假设输入有效。这意味着服务层的错误总是意外情况(数据库故障、网络问题),错误处理也会更简单:
func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
// No need to check if name is empty -- handler already validated
user := &User{Name: name, Email: email}
if err := s.store.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
return user, nil
}
文档让错误处理一目了然
提及错误返回值的文档注释可以告知调用方需要处理哪些错误:
// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {
自查清单
在认为任何处理程序或服务已完成之前,请确认这五条规则都已遵守:
无全局状态
- 没有用于可变状态(数据库、日志记录器、客户端)的包级
var - 所有处理程序都是结构体的方法
- 通过构造函数注入依赖项
显式错误处理
- 没有使用
_忽略返回的错误 - 所有错误都通过
fmt.Errorf("doing X: %w", err)包装 - 检查或记录
json.NewEncoder(w).Encode(...)错误 - HTTP 错误响应使用结构化的
AppError
先验证
- 所有请求结构体都有
validate标签 - 在任何业务逻辑执行之前调用
validate.Struct(req) - 验证错误以 422 响应返回,并提供字段级详细信息
- 服务层不重复验证处理程序已验证的数据
可测试性
- 每个处理程序文件都存在对应的
_test.go文件 - 测试使用
httptest.NewRequest和httptest.NewRecorder - 表驱动测试覆盖成功路径和错误路径
- Mock 实现精简的接口,而非具体类型
文档
- 每个导出的函数都有以其名称开头的文档注释
- 文档说明错误返回值
- 包含包级文档注释
何时加载参考文档
在以下情况下加载 validation.md:
- 添加带有验证标签的新请求类型
- 创建自定义验证器
- 验证嵌套结构体、切片或映射
- 为 API 响应格式化验证错误
在以下情况下加载 testing-handlers.md:
- 首次在项目中编写处理程序测试
- 测试中间件链或身份验证
- 使用真实数据库设置集成测试
- 测试文件上传或流式响应