Go 基础体系 · 第 40/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go Chi 路由器实战:保持 net/http 语义的轻量 Web 方案
本文基于 Go 1.26.4 与 Chi v5。Chi 在标准库 HTTP 之上补充路由、参数、分组与 middleware。标准库 handler 和测试工具可直接组合,项目则需明确 JSON 绑定、校验和错误响应规范。
1. Chi 在请求链中的位置
Chi 的 Mux 实现了 http.Handler。服务器收到请求后调用最外层 middleware,middleware 再调用路由器;路由器匹配 method 与 path,将路径参数放入请求上下文,最后执行 endpoint。响应仍由 handler 写到 http.ResponseWriter。
http.Server
-> Recoverer -> RequestID -> AccessLog
-> chi.Mux 匹配 METHOD + PATH
-> 路由级 middleware
-> http.HandlerFunc
-> ResponseWriter
Chi 不管理数据库连接、后台 worker 或进程信号。它也不自动绑定结构体、校验字段、形成错误 envelope。保持这条边界,业务服务就可以接受普通 context.Context 和 DTO,而无需依赖 chi.Router。
2. Router、Route、Mount 与 Group
chi.NewRouter() 创建路由器;Get、Post 等方法注册 method-specific handler;Method 和 MethodFunc 可处理其他方法。Route("/api", fn) 创建内联子路由并带上前缀;Mount("/debug", handler) 把任意 http.Handler 挂载到路径;Group(fn) 创建继承当前配置的内联组,但不增加路径前缀。
r := chi.NewRouter()
r.Get("/healthz", health)
r.Route("/api/v1", func(api chi.Router) {
api.Use(requireJSON)
api.Get("/articles/{articleID}", getArticle)
api.Post("/articles", createArticle)
})
r.Mount("/debug", http.DefaultServeMux)
没有匹配 path 时走 NotFound,path 存在但 method 不支持时走 MethodNotAllowed。路由只在启动阶段注册。
3. 路径参数、通配符与 URL 语义
参数用 {name} 声明并通过 chi.URLParam 读取;正则可写 {id:[0-9]+},* 匹配尾部。参数仍是不可信输入,授权要显式检查。
r.Get("/files/*", func(w http.ResponseWriter, r *http.Request) {
relative := chi.URLParam(r, "*")
// relative 不能未经清理就拼到本地文件路径。
fmt.Fprintln(w, relative)
})
不要用路径参数直接构造 SQL、文件路径或重定向地址。代理可能改变转义和前缀,签名 URL 必须做集成测试。
4. Middleware 的包裹顺序与作用域
Chi middleware 是 func(http.Handler) http.Handler。Use(A, B) 可理解为 A(B(router));顺序会改变故障观测。
func responseHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
With 用于单条路由,Group 用于多条共享策略。拒绝请求时写响应并返回;调用 next 后再改 header 通常已太晚。
5. Request Context 的所有权
Chi 把路由上下文挂到 *http.Request.Context(),路径参数因此跟随请求。业务调用应传 r.Context(),让客户端断开、服务器关闭或上游 deadline 能取消数据库与外部调用。context 的值只放请求范围、跨 API 边界的少量元数据,不放可选参数,更不能把数据库连接池塞进去作为 service locator。
后台任务不能继承请求 context;可靠任务应持久化到队列。请求对象和 ResponseWriter 都不得在 handler 返回后继续使用。
6. JSON 解码必须建立硬边界
Chi 没有内置 Binder,通常直接使用 encoding/json。安全的边界至少包括 Content-Type 检查、请求体上限、未知字段策略、单个 JSON 值和错误分类。http.MaxBytesReader 在 handler 内限制解压后的读取量;反向代理还应设置独立上限。
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return errors.New("body must contain one JSON value")
}
return nil
}
DTO 与数据库模型分开,避免客户端绑定 IsAdmin 等内部字段。语法解码成功不代表业务合法:长度、范围可在边界验证,唯一性和状态迁移留给领域服务及数据库约束。
7. 响应提交与统一错误映射
第一次调用 WriteHeader 或 Write 后,状态和 header 就已提交;重复写错误常导致“200 状态带错误 JSON”。响应 helper 应先编码到内存,再设置 header 和状态,避免编码到一半失败。对很大或流式响应则需要另一套策略。
领域错误在 HTTP 边界映射为稳定状态和机器码;内部错误只进日志。路由与资源 404 使用不同 code。
type problem struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
不要向客户端返回未经审查的 err.Error()。
8. 404、405、OPTIONS 与路由诊断
通过 r.NotFound 和 r.MethodNotAllowed 安装一致的 JSON 响应。浏览器跨域预检是 OPTIONS 请求,是否自动处理取决于采用的 CORS middleware;不能把所有 OPTIONS 无条件放行,因为响应需与允许的 origin、method 和 header 对应。
匹配失败时依次检查 method、挂载前缀、尾斜杠、代理重写和参数正则。chi.Walk 可在测试中枚举路由;不要公开内部路由表。
9. 超时、限流和恢复各解决什么
Chi 提供 middleware.Timeout,服务器和下游仍要独立设置 deadline。handler 必须尊重 context;middleware 无法停止忽略取消的函数,流式接口也不适用短超时。
Recoverer 记录 handler panic,但不是正常错误处理机制。限流需明确按实例、用户还是全局计数,不能盲信任 X-Forwarded-For。
10. 可运行的文章 API
下面是完整单文件程序。它包含内存仓库、严格 JSON、错误映射、路由分组和优雅关闭;为保持示例可运行,ID 使用递增整数。把代码放入 main.go,模块依赖为 github.com/go-chi/chi/v5。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
var errNotFound = errors.New("article not found")
type article struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type store struct {
mu sync.RWMutex
nextID int64
items map[int64]article
}
func (s *store) create(title string) article {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
a := article{ID: s.nextID, Title: title}
s.items[a.ID] = a
return a
}
func (s *store) get(id int64) (article, error) {
s.mu.RLock()
defer s.mu.RUnlock()
a, ok := s.items[id]
if !ok {
return article{}, errNotFound
}
return a, nil
}
type api struct{ store *store }
func (a api) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Route("/api/v1/articles", func(r chi.Router) {
r.Post("/", a.create)
r.Get("/{articleID:[0-9]+}", a.get)
})
r.NotFound(func(w http.ResponseWriter, _ *http.Request) {
writeProblem(w, http.StatusNotFound, "ROUTE_NOT_FOUND", "route not found")
})
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
writeProblem(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
})
return r
}
func (a api) create(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
}
if err := decodeJSON(w, r, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
return
}
if len(input.Title) < 1 || len(input.Title) > 80 {
writeProblem(w, http.StatusUnprocessableEntity, "INVALID_TITLE", "title length must be 1..80 bytes")
return
}
writeJSON(w, http.StatusCreated, a.store.create(input.Title))
}
func (a api) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "articleID"), 10, 64)
if err != nil || id < 1 {
writeProblem(w, http.StatusBadRequest, "INVALID_ID", "invalid article id")
return
}
item, err := a.store.get(id)
if errors.Is(err, errNotFound) {
writeProblem(w, http.StatusNotFound, "ARTICLE_NOT_FOUND", "article not found")
return
}
writeJSON(w, http.StatusOK, item)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return fmt.Errorf("expected one JSON value")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeProblem(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]string{"code": code, "message": message})
}
func main() {
handler := api{store: &store{items: make(map[int64]article)}}.routes()
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown", "error", err)
}
}()
slog.Info("listening", "address", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("serve", "error", err)
os.Exit(1)
}
}
运行后可验证成功与错误分支:
go mod init example.com/chi-api
go get github.com/go-chi/chi/v5@v5.2.5
gofmt -w main.go
go run .
curl -i -X POST http://127.0.0.1:8080/api/v1/articles/ \
-H 'Content-Type: application/json' -d '{"title":"Chi lifecycle"}'
curl -i http://127.0.0.1:8080/api/v1/articles/1
11. 使用 httptest 做路由级测试
因为整个 router 是 http.Handler,无需监听端口即可测试。测试应断言状态、Content-Type 和稳定错误码,不要只比较一段易变消息。共享 store 的并发测试还应运行 race detector。
func TestCreateAndGet(t *testing.T) {
h := api{store: &store{items: make(map[int64]article)}}.routes()
create := httptest.NewRequest(http.MethodPost, "/api/v1/articles/", strings.NewReader(`{"title":"tested"}`))
create.Header.Set("Content-Type", "application/json")
created := httptest.NewRecorder()
h.ServeHTTP(created, create)
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", created.Code, created.Body.String())
}
got := httptest.NewRecorder()
h.ServeHTTP(got, httptest.NewRequest(http.MethodGet, "/api/v1/articles/1", nil))
if got.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", got.Code, got.Body.String())
}
}
server timeout、代理重写和客户端断开需用 httptest.Server 或集成环境验证。
12. 性能判断与分配热点
用完整 handler 做基准,再以 CPU、heap 和 mutex profile 定位 JSON、日志、锁或下游调用。不得跨请求缓存 Request、路由 context 或 ResponseWriter。
高并发列表接口还要关注响应大小、序列化峰值和背压;示例内存 store 不代表生产数据层。
13. 常见错误模式及排查
- 在 handler 返回后由 goroutine 写 ResponseWriter,会产生竞态、截断或复用错误;流式处理必须留在 handler 生命周期内。
- middleware 写了错误却仍调用
next,会让业务执行并产生双响应;拒绝后立即返回。 - 把
RealIP放在未限定的公网代理环境,会信任伪造 header;只信任部署拓扑中明确的代理。 - 只配置 Chi timeout 而下游忽略 context,会看到请求已超时但 goroutine 和查询继续运行;查看 goroutine dump 与数据库活动。
排查先确认 method、path、匹配 pattern、响应是否提交和 context 错误。日志和指标使用路由模板,避免高基数 ID。
14. 生产部署清单与适用边界
显式构造 router、service 和 store;限制 header、body 与关闭期限;下游继承请求 context。日志使用路由模板且不记录敏感 header 与 body。
Chi 适合希望保留标准库语义、已有 http.Handler 生态且愿意维护边界规范的团队。如果项目最需要的是开箱即用的绑定、校验和丰富上下文助手,Gin 或 Echo 可能减少样板;如果需要非 net/http 的特定性能模型,则要单独验证兼容性。选择 Chi 的合理理由是组合边界清晰,而不只是“轻量”两个字。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 生态技术选型地图:框架、中间件、工具、GUI 与 AI
- 下一篇:Go Gin 完整入门:路由、参数绑定、中间件与优雅关闭
- 延伸:Go net/http 基础:Server、Handler、Middleware 与 Client 超时
- 延伸:Go OpenAPI 与 Swagger:契约优先、代码生成和接口文档治理
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论