← Backend / Go

04_HTTP数据库与测试

使用标准库建立 HTTP 服务,理解 database/sql、接口测试和构建发布。

Go HTTP、数据库与测试

学习目标:用标准库建立小型 HTTP 服务,明确数据库与测试边界。

1. 最小 HTTP 服务

Go 1.22+ 的 ServeMux 支持带 HTTP 方法的路由模式。在独立目录执行 go mod init example.com/notes,将代码保存为 main.go,然后执行 go run .。

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

func routes() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })
    return mux
}

func main() {
    server := &http.Server{
        Addr: ":8080", Handler: routes(),
        ReadHeaderTimeout: 5 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

/health 只证明进程能应答;依赖数据库的就绪检查应单独设计。生产服务还需优雅退出、请求体大小限制、写超时、反向代理与 TLS 配置。不要假设客户端提交的 JSON 已经可信。

2. 数据库边界

database/sql 提供通用接口,但不内置具体数据库驱动。sql.DB 是连接池句柄,通常在应用生命周期内复用;创建后用 PingContext 验证可连接。查询使用 QueryContext/ExecContext 与参数绑定,具体占位符如 ? 或 $1 由数据库驱动决定。

事务流程:开始事务 → 逐步执行 → 遇错回滚 → 全部成功后提交。处理 sql.ErrNoRows 时可以返回业务上的“未找到”,但其他数据库错误应保留内部日志并映射为稳定 API 错误。

3. HTTP 测试

将上面的 routes() 与下面测试放在同一包的 main_test.go 中。

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealth(t *testing.T) {
    request := httptest.NewRequest(http.MethodGet, "/health", nil)
    response := httptest.NewRecorder()
    routes().ServeHTTP(response, request)
    if response.Code != http.StatusOK {
        t.Fatalf("status = %d, want 200", response.Code)
    }
}

运行 go test ./...,并对输入错误、未找到、重复写入、无权限和数据库故障补充用例。依赖数据库的集成测试使用隔离数据库,不要接真实生产实例。

4. 构建与发布

go.mod、go.sum 记录依赖;CI 中运行格式检查、go vet ./...、测试和构建。应用配置从环境变量读取;发布时设超时、健康检查、结构化日志、优雅关闭和回滚方案。共用原则见 测试、CI 与部署。

自测

  1. sql.Open 成功是否证明数据库可连接?不一定,还需 PingContext 或实际操作。
  2. 单元测试与集成测试为什么应分开?前者快速验证规则,后者验证数据库、迁移和驱动的真实行为。

5. 严格处理 JSON 请求

先限制请求体大小,再解码、校验字段并检查是否有多余 JSON 值。默认解码器会忽略未知字段;如果 API 契约要求拒绝拼错的字段,可调用 DisallowUnknownFields()。响应写入前先设置 Content-Type 和状态码;http.Error 有自己的纯文本格式,若契约规定 JSON 错误,应统一封装。

type CreateNote struct { Title string `json:"title"` }

func decodeCreate(w http.ResponseWriter, r *http.Request) (CreateNote, error) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    var input CreateNote
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()
    if err := decoder.Decode(&input); err != nil { return input, err }
    var extra any
    if err := decoder.Decode(&extra); err != io.EOF {
        return input, errors.New("请求体只能有一个 JSON 值")
    }
    if strings.TrimSpace(input.Title) == "" {
        return input, errors.New("标题不能为空")
    }
    return input, nil
}

这是 handler 辅助函数片段,需导入 encoding/json、errors、io、net/http、strings。真实服务还要限制 Content-Type、字段长度和认证身份;解码错误需区分请求过大、非法 JSON 和业务校验失败。

6. 数据库事务的可检验写法

sql.DB 是并发安全的连接池句柄,一般在启动时创建并在关闭服务时释放。事务中的所有 SQL 都通过同一个 *sql.Tx 执行;若混用 db.ExecContext,操作会跑到事务外。回滚可以在成功提交后安全地调用而得到“事务已结束”错误,因此常用 defer tx.Rollback() 作兜底,同时只把 Commit 成功视为完成。

tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
    "INSERT INTO notes(owner_id, title) VALUES (?, ?)", ownerID, title); err != nil {
    return err
}
return tx.Commit()

上段是返回 error 的函数体片段,占位符需按数据库驱动调整。实际创建接口还应校验用户身份、执行唯一约束、处理冲突及返回新 ID。集成测试要在临时数据库上运行迁移,再验证提交与回滚;httptest 只验证 HTTP 处理,不会自动证明持久化正确。

7. 可运行练习与参考

在现有 /health 服务上增加 POST /notes:限制请求体、拒绝空标题、返回 201 和 JSON;用 httptest 验证成功、坏 JSON、未知字段和过大请求体。之后接测试数据库,验证重复标题冲突与事务回滚。