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() 创建路由器;GetPost 等方法注册 method-specific handler;MethodMethodFunc 可处理其他方法。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.HandlerUse(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. 响应提交与统一错误映射

第一次调用 WriteHeaderWrite 后,状态和 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.NotFoundr.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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。