7天用Go从零实现Web框架Gee:从http.Handler到分组中间件与模板引擎的完整实战
2026/9/21 15:17:09 网站建设 项目流程
  • 示例工程

【免费下载链接】7days-golang

7 days golang programs from scratch (web framework Gee, distributed cache GeeCache, object relational mapping ORM framework GeeORM, rpc framework GeeRPC etc) 7天用Go动手写/从零实现系列

项目地址:https://gitcode.com/gh_mirrors/7d/7days-golang
点击查看免费下载

导读

本文以 7days-golang 仓库中 gee-web/README.md 为主线,系统梳理 7 天从零实现一个 Go Web 框架 Gee 的完整过程:从最朴素的http.Handler接口出发,逐步演进出上下文(Context)封装、Trie 树动态路由、分组控制、中间件、HTML 模板渲染与错误恢复(Panic Recover)等核心能力。文中所有示例均可在仓库gee-web/目录下的 day1 至 day7 各子目录中直接运行验证,读完你不仅能跑通全部示例,还能从源码层面理解每个特性背后的设计取舍,并具备把 Gee 作为教学骨架扩展成生产级框架的能力。

项目结构与运行方式

Gee 的全部代码位于 gee-web 目录,按 7 天的学习节奏组织为day1-http-baseday7-panic-recover共 7 个独立子模块,每个子模块都是一个独立的 Go Module,内部gee/子目录是当天的框架核心代码,顶层main.go是配套的演示程序:

gee-web/ ├── day1-http-base/ # 前置知识:http.Handler 接口 ├── day2-context/ # 上下文设计(Context) ├── day3-router/ # Trie 树路由(动态路由) ├── day4-group/ # 分组控制(Group) ├── day5-middleware/ # 中间件(Middleware) ├── day6-template/ # HTML 模板渲染与静态资源 └── day7-panic-recover # 错误恢复(Panic Recover)

运行某个示例(以 day2 为例):

cd gee-web/day2-context go run .

启动后访问http://localhost:9999,或配合 curl 测试各接口。仓库根目录下的 run_test.sh 展示了类似的批量测试思路(本文聚焦 Gee 本身,不再展开)。

Day 1:前置知识 —— 从 http.Handler 接口出发

Web 框架的第一步不是发明新协议,而是先理解 Go 标准库的net/http是如何工作的。Day 1 的目标就是亲手体验标准库的路由分发方式。

标准库版:handler 函数注册

仓库 day1-http-base/base1 中展示了最原始的实现——直接用标准库注册 handler:

http.HandleFunc("/", indexHandler) http.HandleFunc("/hello", helloHandler) http.ListenAndServe(":9999", nil)

每个 handler 都必须满足http.Handler接口,即实现ServeHTTP(w http.ResponseWriter, req *http.Request)方法。http.HandleFunc会将其包装为HandlerFunc类型并注册到默认的DefaultServeMux上。这种方式的问题在于:路由规则完全交给标准库,且 handler 签名冗长,每次都要手动处理wreq两个参数。

框架版:Engine 实现 ServeHTTP

Day 1 的核心转变是让框架自己实现ServeHTTP,从而接管全部路由逻辑。以 day2-context/gee/gee.go 中定型的设计为例:

// HandlerFunc defines the request handler used by gee type HandlerFunc func(*Context) // Engine implement the interface of ServeHTTP type Engine struct { router *router } func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { c := newContext(w, req) engine.router.handle(c) } func (engine *Engine) Run(addr string) (err error) { return http.ListenAndServe(addr, engine) }

此时Engine本身就是合法的http.Handler,可以整体传给http.ListenAndServe。README 中 Day 1 的完整示例:

func main() { r := gee.New() r.GET("/", func(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path) }) r.GET("/hello", func(w http.ResponseWriter, req *http.Request) { for k, v := range req.Header { fmt.Fprintf(w, "Header[%q] = %q\n", k, v) } }) r.Run(":9999") }

注意这里的 handler 签名仍是func(w, req)——这是 Day 1 的中间形态,到 Day 2 引入 Context 后才会彻底告别裸参数。可以对照 day1-http-base/base2 与 day1-http-base/base3 体会从"标准库 handler"到"框架 handler"的过渡:base3 中EngineServeHTTP已按method + "-" + pattern拼接 key 查找 handler,这便是后续路由表的雏形。

Day 2:上下文设计 —— 封装请求与响应

Day 2 引入的Context是 Gee 的灵魂结构,此后所有 handler 都统一接收*Context,不再直接操作wreq

Context 的字段与构造

以 day2-context/gee/context.go 为例:

type Context struct { // origin objects Writer http.ResponseWriter Req *http.Request // request info Path string Method string // response info StatusCode int } func newContext(w http.ResponseWriter, req *http.Request) *Context { return &Context{ Writer: w, Req: req, Path: req.URL.Path, Method: req.Method, } }
  • Writer/Req:保留标准库原始对象,方便需要底层能力时透传;
  • Path/Method:请求的核心信息,构造时即从req中提取,handler 无需再解析;
  • StatusCode:记录响应状态码,供日志中间件等后续读取。

快捷响应方法

Context 提供了一组链式易用的响应方法(同样位于 context.go):

方法功能Content-Type
String(code, format, values...)返回格式化文本text/plain
JSON(code, obj)返回 JSON 序列化结果application/json
Data(code, data)返回原始字节由调用方自行设置
HTML(code, html)返回 HTML 字符串text/html
Status(code)仅写入状态码
SetHeader(key, value)设置响应头

其内部实现非常直白,例如StringJSON

func (c *Context) String(code int, format string, values ...interface{}) { c.SetHeader("Content-Type", "text/plain") c.Status(code) c.Writer.Write([]byte(fmt.Sprintf(format, values...))) } func (c *Context) JSON(code int, obj interface{}) { c.SetHeader("Content-Type", "application/json") c.Status(code) encoder := json.NewEncoder(c.Writer) if err := encoder.Encode(obj); err != nil { http.Error(c.Writer, err.Error(), 500) } }

同时提供Query(key)读取 URL 查询参数、PostForm(key)读取表单参数,分别封装了req.URL.Query().Getreq.FormValue

Day 2 完整示例

func main() { r := gee.New() r.GET("/", func(c *gee.Context) { c.HTML(http.StatusOK, "<h1>Hello Gee</h1>") }) r.GET("/hello", func(c *gee.Context) { // expect /hello?name=geektutu c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path) }) r.POST("/login", func(c *gee.Context) { c.JSON(http.StatusOK, &map[string]string{ "username": c.PostForm("username"), "password": c.PostForm("password"), }) }) r.Run(":9999") }

验证方式:

curl "http://localhost:9999/hello?name=geektutu" curl -X POST "http://localhost:9999/login" -d "username=geektutu&password=1234"

此时的路由仍为精确匹配(见 day2-context/gee/router.go:key := method + "-" + pattern,直接查 map,未命中返回404 NOT FOUND)。但 Context 的统一抽象已为后续的动态路由、中间件链打下了数据结构基础。

Day 3:Trie 树路由 —— 支持动态参数

Day 3 是路由能力的质变:引入前缀树(Trie)实现动态路由,支持:name路径参数与*filepath通配符。

Trie 树节点设计

day3-router/gee/trie.go 中的节点结构:

type node struct { pattern string // 待匹配路由,例如 /p/:lang part string // 路由中的一部分,例如 :lang children []*node // 子节点 isWild bool // 是否精确匹配,part 含有 : 或 * 时为 true }
  • pattern:仅在该节点是某条完整路由的终点时非空,用于判断匹配是否成功;
  • isWild:记录该节点是否为通配节点(part[0] == ':' || part[0] == '*'),是matchChild/matchChildren判定"模糊匹配"的依据。

插入与查询

insert按路由的每一段递归插入;search则逐层匹配,命中*节点后直接按剩余部分整体吞掉。配套的两个子节点选择函数是关键:

func (n *node) matchChild(part string) *node { // 插入时使用:只找一个精确或通配的匹配子节点 for _, child := range n.children { if child.part == part || child.isWild { return child } } return nil } func (n *node) matchChildren(part string) []*node { // 查询时使用:收集所有可能匹配的子节点(精确 + 通配) nodes := make([]*node, 0) for _, child := range n.children { if child.part == part || child.isWild { nodes = append(nodes, child) } } return nodes }

插入只能选一个子节点继续建树,而查询需要同时尝试精确与通配分支,这正是 Trie 路由能正确处理/hello/:name/hello/geektutu共存的原因。

parsePattern 与参数提取

day3-router/gee/router.go 中:

// Only one * is allowed func parsePattern(pattern string) []string { vs := strings.Split(pattern, "/") parts := make([]string, 0) for _, item := range vs { if item != "" { parts = append(parts, item) if item[0] == '*' { break // 只允许一个 *,且必须位于末尾 } } } return parts }

getRoute在搜索到节点后,对照 pattern 的每一段提取参数::段把part[1:]作为 key、实际路径段作为 value 存入params*段则把剩余所有段用/拼接后整体作为 value:

if part[0] == ':' { params[part[1:]] = searchParts[index] } if part[0] == '*' && len(part) > 1 { params[part[1:]] = strings.Join(searchParts[index:], "/") break }

Context 也相应新增了Params map[string]string字段与Param(key)方法(见 day3-router/gee/context.go 及 day5 版本中的同名方法)。

Day 3 完整示例

func main() { r := gee.New() r.GET("/hello/:name", func(c *gee.Context) { // expect /hello/geektutu c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path) }) r.GET("/assets/*filepath", func(c *gee.Context) { c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")}) }) r.Run(":9999") }
  • GET /hello/:name:访问/hello/geektutuc.Param("name")返回geektutu
  • GET /assets/*filepath:访问/assets/css/geektutu.cssc.Param("filepath")返回css/geektutu.css(注意不包含前导/assets/)。

gee.Hmap[string]interface{}的类型别名(定义于 day2-context/gee/context.go),专用于便捷构造 JSON 响应数据。仓库中 day3-router/gee/router_test.go 提供了 Trie 树与动态路由的单元测试,覆盖了通配符、参数提取、404 等场景,可直接go test ./...验证。

Day 4:分组控制 —— 前缀分组与嵌套

随着路由增多,需要按前缀将接口划分到不同业务模块,并给不同分组附加不同能力。Day 4 引入RouterGroup

分组结构

day4-group/gee/gee.go 中,EngineRouterGroup组合设计:

type ( RouterGroup struct { prefix string middlewares []HandlerFunc // support middleware parent *RouterGroup // support nesting engine *Engine // all groups share a Engine instance } Engine struct { *RouterGroup router *router groups []*RouterGroup // store all groups } )

关键设计:

  • Engine内嵌*RouterGroup,因此r.GET(...)可以直接调用,Engine等价于前缀为/的根分组;
  • 所有分组共享同一个engine实例,路由最终都注册到engine.router
  • groups保存全部分组,为 Day 5 中间件的按前缀收集做准备。

分组创建:前缀拼接实现嵌套

func (group *RouterGroup) Group(prefix string) *RouterGroup { engine := group.engine newGroup := &RouterGroup{ prefix: group.prefix + prefix, // 父前缀 + 当前前缀,天然支持嵌套 parent: group, engine: engine, } engine.groups = append(engine.groups, newGroup) return newGroup }

addRoute注册路由时使用pattern := group.prefix + comp,即分组前缀与组内相对路径拼接成完整路由。

Day 4 完整示例

func main() { r := gee.New() v1 := r.Group("/v1") { v1.GET("/", func(c *gee.Context) { c.HTML(http.StatusOK, "<h1>Hello Gee</h1>") }) v1.GET("/hello", func(c *gee.Context) { // expect /hello?name=geektutu c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path) }) } v2 := r.Group("/v2") { v2.GET("/hello/:name", func(c *gee.Context) { // expect /hello/geektutu c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path) }) v2.POST("/login", func(c *gee.Context) { c.JSON(http.StatusOK, &map[string]string{ "username": c.PostForm("username"), "password": c.PostForm("password"), }) }) } r.Run(":9999") }

此时/v1/hello/v2/hello/geektutu可同时生效,分组互不干扰。仓库中 day4-group/gee/gee_test.go 对分组注册与路由命中做了断言测试。由于前缀采用拼接而非记录层级,v1.Group("/admin")会得到前缀/v1/admin,因此嵌套分组也是开箱即用的(README 标题即 "Nesting Group Control")。

Day 5:中间件 —— 洋葱模型与 Next 链

Day 5 让 Gee 具备横切能力:日志、鉴权、限流等逻辑以中间件形式插入请求处理链。

中间件的注册与收集

RouterGroup新增Use方法(day5-middleware/gee/gee.go):

func (group *RouterGroup) Use(middlewares ...HandlerFunc) { group.middlewares = append(group.middlewares, middlewares...) }

ServeHTTP在处理请求时按前缀匹配收集分组中间件:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { var middlewares []HandlerFunc for _, group := range engine.groups { if strings.HasPrefix(req.URL.Path, group.prefix) { middlewares = append(middlewares, group.middlewares...) } } c := newContext(w, req) c.handlers = middlewares engine.router.handle(c) }

r.Use(gee.Logger())注册到根分组(前缀/),对一切路径生效;v2.Use(onlyForV2())则只对/v2前缀生效。

Context 的处理链与 Next

day5-middleware/gee/context.go 为 Context 增加handlers []HandlerFuncindex int两个字段:

func (c *Context) Next() { c.index++ s := len(c.handlers) for ; c.index < s; c.index++ { c.handlersc.index } }

路由处理时,router.handle 会把最终 handler 追加到c.handlers末尾(未命中则追加 404 handler),然后调用c.Next()从 index=-1 开始顺序执行整条链。中间件可以在c.Next()前后分别放置"前置逻辑"与"后置逻辑",形成经典的洋葱模型:

func Logger() HandlerFunc { return func(c *Context) { // Start timer t := time.Now() // Process request c.Next() // Calculate resolution time log.Printf("[%d] %s in %v", c.StatusCode, c.Req.RequestURI, time.Since(t)) } }

中断处理链:Fail

Fail通过把index直接跳到链尾来截断后续处理:

func (c *Context) Fail(code int, err string) { c.index = len(c.handlers) c.JSON(code, H{"message": err}) }

调用c.Fail(500, "Internal Server Error")后,当前中间件之后的 handler 不再执行,但更外层中间件(如果已经通过Next进入内层)的Next()返回后仍会继续执行其后续逻辑。

Day 5 完整示例

func onlyForV2() gee.HandlerFunc { return func(c *gee.Context) { // Start timer t := time.Now() // if a server error occurred c.Fail(500, "Internal Server Error") // Calculate resolution time log.Printf("[%d] %s in %v for group v2", c.StatusCode, c.Req.RequestURI, time.Since(t)) } } func main() { r := gee.New() r.Use(gee.Logger()) // global middleware r.GET("/", func(c *gee.Context) { c.HTML(http.StatusOK, "<h1>Hello Gee</h1>") }) v2 := r.Group("/v2") v2.Use(onlyForV2()) // v2 group middleware { v2.GET("/hello/:name", func(c *gee.Context) { // expect /hello/geektutu c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path) }) } r.Run(":9999") }

访问/时只经过全局 Logger;访问/v2/hello/geektutu时会先执行onlyForV2,其内部c.Fail后日志仍能读取到c.StatusCode(此处为 500)。day5-middleware/gee/gee_test.go 对中间件执行顺序与分组作用域做了覆盖测试。

Day 6:HTML 模板渲染与静态资源

Day 6 补齐 Web 框架的页面能力:动态模板渲染、自定义模板函数与静态文件服务。

模板引擎接入

day6-template/gee/gee.go 中Engine新增两个字段,并提供两个 API:

htmlTemplates *template.Template // for html render funcMap template.FuncMap // for html render func (engine *Engine) SetFuncMap(funcMap template.FuncMap) { engine.funcMap = funcMap } func (engine *Engine) LoadHTMLGlob(pattern string) { engine.htmlTemplates = template.Must(template.New("").Funcs(engine.funcMap).ParseGlob(pattern)) }
  • SetFuncMap必须在LoadHTMLGlob之前调用,因为模板解析时就需要把自定义函数注入进去;
  • LoadHTMLGlob使用template.Must包裹,解析失败会直接 panic,适合启动期快速暴露配置错误。

Context 的HTML方法也升级为支持模板名:

func (c *Context) HTML(code int, name string, data interface{}) { c.SetHeader("Content-Type", "text/html") c.Status(code) if err := c.engine.htmlTemplates.ExecuteTemplate(c.Writer, name, data); err != nil { c.Fail(500, err.Error()) } }

静态资源服务

Static方法把本地目录映射为 URL 前缀,底层借助http.FileServer实现(day6-template/gee/gee.go):

func (group *RouterGroup) Static(relativePath string, root string) { handler := group.createStaticHandler(relativePath, http.Dir(root)) urlPattern := path.Join(relativePath, "/*filepath") group.GET(urlPattern, handler) } func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc { absolutePath := path.Join(group.prefix, relativePath) fileServer := http.StripPrefix(absolutePath, http.FileServer(fs)) return func(c *Context) { file := c.Param("filepath") if _, err := fs.Open(file); err != nil { c.Status(http.StatusNotFound) return } fileServer.ServeHTTP(c.Writer, c.Req) } }

它复用了 Day 3 的/*filepath通配路由:r.Static("/assets", "./static")即注册GET /assets/*filepath,文件不存在时返回 404。

Day 6 完整示例

type student struct { Name string Age int8 } func FormatAsDate(t time.Time) string { year, month, day := t.Date() return fmt.Sprintf("%d-%02d-%02d", year, month, day) } func main() { r := gee.New() r.Use(gee.Logger()) r.SetFuncMap(template.FuncMap{ "FormatAsDate": FormatAsDate, }) r.LoadHTMLGlob("templates/*") r.Static("/assets", "./static") stu1 := &student{Name: "Geektutu", Age: 20} stu2 := &student{Name: "Jack", Age: 22} r.GET("/", func(c *gee.Context) { c.HTML(http.StatusOK, "css.tmpl", nil) }) r.GET("/students", func(c *gee.Context) { c.HTML(http.StatusOK, "arr.tmpl", gee.H{ "title": "gee", "stuArr": [2]*student{stu1, stu2}, }) }) r.GET("/date", func(c *gee.Context) { c.HTML(http.StatusOK, "custom_func.tmpl", gee.H{ "title": "gee", "now": time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC), }) }) r.Run(":9999") }

配套模板位于 gee-web/day6-template/templates(arr.tmpl遍历学生数组、css.tmpl引用静态样式、custom_func.tmpl调用FormatAsDate自定义函数),静态文件位于 gee-web/day6-template/static(含css/geektutu.cssfile1.txt)。r.LoadHTMLGlob("templates/*")中的模式是相对go run .执行目录的,运行前请确认工作目录在day6-template下。

Day 7:错误恢复 —— 让框架不因 panic 崩溃

最后一个主题是健壮性:当 handler 中发生 panic(如数组越界)时,不能让整个 HTTP 服务进程崩溃,而要捕获异常、记录堆栈并返回 500。

Recovery 中间件与 trace

day7-panic-recover/gee/recovery.go 实现核心逻辑:

func Recovery() HandlerFunc { return func(c *Context) { defer func() { if err := recover(); err != nil { message := fmt.Sprintf("%s", err) log.Printf("%s\n\n", trace(message)) c.Fail(http.StatusInternalServerError, "Internal Server Error") } }() c.Next() } }
  • 利用defer + recover捕获c.Next()执行链中抛出的任何 panic;
  • trace函数通过runtime.Callers采集调用栈,把出错的文件名与行号逐行写入日志,方便定位问题;
  • 恢复后调用c.Fail(500, "Internal Server Error")返回统一错误响应,同时利用 Day 5 的Fail机制中断处理链。

注意Recovery()是作为中间件挂载的,其defer包裹着c.Next(),因此能捕获链上所有后续 handler 的 panic。

gee.Default 与示例

day7-panic-recover/gee/gee.go 提供Default()工厂方法,内置了日志与恢复两个基础中间件:

func Default() *Engine { engine := New() engine.Use(Logger(), Recovery()) return engine }

README 中的 Day 7 示例用gee.Default()一键获得带日志和 panic 恢复能力的框架:

func main() { r := gee.Default() r.GET("/", func(c *gee.Context) { c.String(http.StatusOK, "Hello Geektutu\n") }) // index out of range for testing Recovery() r.GET("/panic", func(c *gee.Context) { names := []string{"geektutu"} c.String(http.StatusOK, names[100]) }) r.Run(":9999") }

访问/panic触发数组越界 panic 后,服务不会崩溃:控制台会打印包含Traceback与具体文件行号的堆栈(如.../recovery.go:31附近),浏览器收到500 Internal Server Error的 JSON 响应,而/仍可正常访问。

总结:7 天构建 Gee 的技术演进脉络

回看整个 7 天历程,Gee 的每次演进都解决一个具体问题:

Day主题核心成果关键源码位置
1静态路由理解http.HandlerEngine实现ServeHTTPday1-http-base/base3/gee/gee.go
2上下文设计Context统一封装请求/响应,提供String/JSON/HTML/Dataday2-context/gee/context.go
3动态路由Trie 树支持:param*filepathday3-router/gee/trie.go、router.go
4分组控制RouterGroup前缀分组,支持嵌套day4-group/gee/gee.go
5中间件Next()洋葱模型,分组级Useday5-middleware/gee/context.go
6模板渲染自定义模板函数、HTML 渲染、静态资源day6-template/gee/gee.go
7错误恢复Recovery捕获 panic 并返回 500day7-panic-recover/gee/recovery.go

从源码结构看,Gee 有意保持了教学框架的极简性:路由表、Context、分组、中间件各自聚焦单一职责,RouterGroup通过内嵌与共享engine让 API 简洁;Default()则把"日志 + 恢复"固化为开箱即用的标配。如果你想在此基础上继续深入,可以自然延伸的方向包括:Context增加超时控制与数据绑定、路由增加正则参数校验、中间件支持Abort/Next嵌套语义、模板支持布局继承等——这些都可以在现有骨架上逐步叠加,而这正是从零实现一个 Web 框架最有价值的收获。

# 快速体验完整项目(建议依次运行每个 day 目录) cd gee-web/day7-panic-recover go run .
  • 示例工程

【免费下载链接】7days-golang

7 days golang programs from scratch (web framework Gee, distributed cache GeeCache, object relational mapping ORM framework GeeORM, rpc framework GeeRPC etc) 7天用Go动手写/从零实现系列

项目地址:https://gitcode.com/gh_mirrors/7d/7days-golang
点击查看免费下载
上一篇:极限多轮对话Qwen3-235B-A22B-Thinking-2507-FP8:历史思维处理
下一篇:超省成本!SGLang大模型资源优化与成本控制实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询