mcpField
G
go-web-expert

go-web-expert

github.com/existential-birds开发8585 次安装11 周前更新
代码仓库

关于 go-web-expert

面向 Go Web 开发者的生产实践指南,以五项规则约束处理器、服务和代码审查。重点包括依赖注入、输入验证、错误处理、HTTP 测试与文档。

主要功能

  1. 01将可变依赖注入服务器结构体
  2. 02显式包装并处理错误
  3. 03在请求边界验证 JSON
  4. 04使用 httptest 编写表驱动测试
  5. 05为导出符号编写文档注释
  6. 06按检查门槛审查处理器变更

适用场景

  1. 01实现新的 HTTP 处理器
  2. 02检查请求验证与错误响应
  3. 03为处理器和中间件编写测试
  4. 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. 依赖项(规则 1) — **通过条件:**处理器是结构体的方法,且该结构体持有每个可变依赖项(db、日志记录器、HTTP 客户端、缓存);任何新增的包级 var 都必须属于包级允许项中的白名单。*证据:*构造函数完成依赖项装配;没有新增列表中的禁用全局变量。

  2. 边界(规则 3) — 必须先通过,才能调用服务或领域代码:**通过条件:**请求解码为带标签的结构体,并执行 validate.Struct(或等效操作);对无效 JSON 和验证失败定义了 HTTP 状态码响应体(例如 400/422)。*证据:*处理器中出现解码和 validate.Struct;测试或手动运行显示错误输入会返回 422/400。

  3. 错误(规则 2) — **通过条件:**处理器调用路径中没有用 _ 丢弃错误的情况;处理 json.NewEncoder(w).Encode 的错误;向上返回或记录的错误使用包装(%w)或本规范要求的 AppError 映射。*证据:*检查差异,确认没有被忽略的错误,也没有在需要包装时直接 return err 而不附带上下文。

  4. 测试(规则 4) — **通过条件:**处理器所在包中存在 _test.go 文件,并使用 httptest 调用 ServeHTTP,至少包含一个成功用例和一个非 2xx 用例(验证失败、未找到或领域错误)。*证据:*测试文件路径存在;该包的 go test 通过。

  5. 文档(规则 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:

  • 首次在项目中编写处理程序测试
  • 测试中间件链或身份验证
  • 使用真实数据库设置集成测试
  • 测试文件上传或流式响应

评论

支持 Markdown