http — Web 层与路由
没有框架,只有标准库 net/http 加一层约定:handler 签名是
func(ctx, *http.Request) (any, error),永远不碰 ResponseWriter。
这个示例把公开路由、受保护路由、路径参数、请求体解析、错误映射与逃生舱
HandleRaw 都放在一个 6 行左右的 BuildRouter 里,
看完你能照它搭出自己的服务骨架。
运行
go run ./examples/http # = go run ./examples/http -conf examples/http/config.yaml
没有外部依赖,不需要 make dev。监听 :8080
(config.yaml 里的 http.addr),-conf
默认指向 examples/http/config.yaml。同端口还挂着一批内置端点:
/healthz、/readyz、/metrics、/version。
它演示了什么
- 路由语法:
srv.Handle("GET", "/api/v1/echo/{name}", …),路径参数用r.PathValue("name")读。 - 返回值的固定映射:
data, nil→ 200 封套;nil, nil→ 204;nil, errs.…→ 对应状态码;普通 error → 500。 - 两个中间件槽:
Use在封套内部(能读 ctx、能返回 errs),UseOuter在 mux 外部(能改响应头、覆盖探针路由)。 Use只护它之后注册的路由:公开与受保护的边界就是书写顺序。- ctx 传递:中间件往 ctx 里塞的值,路由 handler 里读得到。
HandleRaw逃生舱:需要自定义状态码、响应头或流式输出时不走封套。- JSON 请求体解析:
httpx.ReadJSON(r, &req)。
路由注册:公开在前,中间件在后
下面是 examples/http/main.go 的 BuildRouter 全文。
注意中间的注释分界线——srv.Use(withTag, s.requireAPIKey)
之后注册的两条路由才需要 API key,之前的三条加上 raw 路由都是公开的。
func (s *httpService) BuildRouter(srv *httpx.Server) error {
// Outer middleware wraps the mux itself, so its header lands on every
// response — including HandleRaw routes and the builtin probes.
srv.UseOuter(serverHeader("matex/http-demo"))
// Public routes: registered before any Use, so nothing guards them.
srv.Handle("GET", "/api/v1/echo/{name}", s.echo)
srv.Handle("GET", "/api/v1/nothing", nothing)
srv.Handle("GET", "/api/v1/errors/{kind}", fail)
// Escape hatch: full control over status/headers/body.
srv.HandleRaw("GET /raw/plain", rawPlain)
// Everything below this line runs through both middleware, outermost
// first: tag the request, then demand the API key.
srv.Use(withTag, s.requireAPIKey)
srv.Handle("POST", "/api/v1/users", createUser)
srv.Handle("GET", "/api/v1/admin/whoami", whoami)
return nil
}
两个中间件写着不一样的样子
同一份文件里两种中间件的对照。重点是签名与它们能拿到的东西:
Use 那侧没有 ResponseWriter,拒绝请求就是返回一个
errs 值;UseOuter 那侧是原生 http.Handler,
所以能改响应头,也因此能覆盖探针路由。
// serverHeader is an outer middleware: it needs the ResponseWriter, so it
// lives outside the mux rather than in the envelope chain.
func serverHeader(v string) httpx.OuterMiddleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Served-By", v)
next.ServeHTTP(w, r)
})
}
}
// withTag is an envelope middleware: it enriches the request context so
// the handler can read the value. No ResponseWriter in sight.
func withTag(next httpx.HandlerFunc) httpx.HandlerFunc {
return func(ctx context.Context, r *http.Request) (any, error) {
return next(context.WithValue(ctx, tagKey{}, r.Header.Get("X-Tag")), r)
}
}
守卫中间件同样只有几行,它把「未配置即关闭」当作约定:
配置里 api_key 为空字符串时检查被跳过。
func (s *httpService) requireAPIKey(next httpx.HandlerFunc) httpx.HandlerFunc {
return func(ctx context.Context, r *http.Request) (any, error) {
if s.cfg.APIKey != "" && r.Header.Get("X-API-Key") != s.cfg.APIKey {
return nil, errs.Unauthorized(40101, "missing or invalid X-API-Key")
}
return next(ctx, r)
}
}
请求在链上的位置
flowchart LR
REQ([请求]) --> G["guard
panic 恢复 · max_body"]
G --> SH["SecureHeaders
默认安全响应头"]
SH --> OUT["UseOuter
X-Served-By"]
OUT --> MUX{{ServeMux 路由匹配}}
MUX --> W["wrapper
trace id · 超时 · 封套"]
W --> MW["Use
withTag → requireAPIKey"]
MW --> H["handler
echo / createUser / whoami"]
H --> RESP([封套响应])
MUX -.-> RAW["HandleRaw
不走封套"]
OUT -.-> P["/healthz /readyz /metrics /version"]
classDef slot fill:#fdf6f0,stroke:#c8641f,color:#2b2722
class OUT,MW slot
X-Served-By 挂在 UseOuter 上,
所以连 /healthz 和 /raw/plain 都带它;
withTag/requireAPIKey 在封套内,只管它们之后注册的业务路由。
验证
示例仓库里给出的 curl 与预期可以一一对上:
curl -i localhost:8080/api/v1/echo/world
# 200 {"code":0,"msg":"ok","data":{"greeting":"Hello, world!"}}
curl -i localhost:8080/api/v1/nothing
# 204,无响应体
curl -i localhost:8080/api/v1/errors/notfound
# 404 {"code":40401,"msg":"user 42 not found","data":null}
curl -i -X POST localhost:8080/api/v1/users -d '{"name":"alice"}'
# 401 {"code":40101,"msg":"missing or invalid X-API-Key",...}
curl -i -X POST localhost:8080/api/v1/users -H 'X-API-Key: demo-key' -d '{"name":"alice"}'
# 200 {"code":0,"msg":"ok","data":{"email":"","id":1,"name":"alice"}}
curl -i localhost:8080/api/v1/admin/whoami -H 'X-API-Key: demo-key' -H 'X-Tag: alice'
# 200 data 里有 "tag":"alice" —— 中间件塞进 ctx 的值被 handler 读到了
curl -i localhost:8080/raw/plain
# 202 text/plain,"raw handler: full control over status/headers/body"
/api/v1/errors/{kind} 接受的 kind 值就是 errs 的那些类别:
invalid、unauthorized、forbidden、notfound、
conflict、ratelimited、internal、unavailable、
timeout;其他值返回 400 {"code":40000}。
另外请求头 X-Request-ID 会被透传并写回响应头,不传则自动生成。
测试
main_test.go 用 srv.Handler() 拿到底层 http.Handler,
走完整 wrapper 但不占端口。它钉住的具体行为:
- 表驱动的
TestRoutes覆盖 17 个用例,包括:echo 用路径参数得到"greeting":"hi world!"(测试里句柄前缀是"hi ");/api/v1/nothing返回 204;带错 key 与不带 key 都是401 "code":40101;坏 JSON body 是400 "code":40002; 缺name是400 "code":40001;notfound/conflict/unavailable/timeout分别是 404/409/503/504 且带各自的 code;未知 kind 是400 "code":40000。 - 每个用例都断言
X-Served-By == "matex/http-demo", 证明UseOuter覆盖了全部路由。 - 用例里有一个
noTrace标记:HandleRaw路由与 ServeMux 直接应答的未匹配路由(/nope404、错误方法 405) 没有X-Request-ID,因为它们绕过了 wrapper。 TestTraceIDIsEchoed:请求头带X-Request-ID: trace-abc, 响应头原样回trace-abc。TestBuiltinProbes:/healthz与/readyz都是 200。TestOuterMiddlewareCoversProbes:给一个裸httpx.New挂上 outer 中间件后,/healthz也带X-Served-By。
go test ./examples/http
相关
相关示例
- examples/errors:Kind 与业务 code 的取值规则。
- examples/auth:用
Use的累积语义做分级授权。 - examples/observability:trace id、访问日志与指标从哪来。
- examples/resilience:限流与幂等中间件挂进同一个槽。