资讯详情

7天用Go从零实现Web框架Gee:从http.Handler到分组中间件与模板引擎的完整实战

📅 2026/9/21 15:17:24 | 华诺云谱 👁 阅读
7天用Go从零实现Web框架Gee:从http.Handler到分组中间件与模板引擎的完整实战
示例工程【免费下载链接】7days-golang7 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-base至day7-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 中展示了最原始的实现——直接用标准库注册 handlerhttp.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 签名冗长每次都要手动处理w、req两个参数。框架版Engine 实现 ServeHTTPDay 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 中Engine的ServeHTTP已按method - pattern拼接 key 查找 handler这便是后续路由表的雏形。Day 2上下文设计 —— 封装请求与响应Day 2 引入的Context是 Gee 的灵魂结构此后所有 handler 都统一接收*Context不再直接操作w和req。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-TypeString(code, format, values...)返回格式化文本text/plainJSON(code, obj)返回 JSON 序列化结果application/jsonData(code, data)返回原始字节由调用方自行设置HTML(code, html)返回 HTML 字符串text/htmlStatus(code)仅写入状态码—SetHeader(key, value)设置响应头—其内部实现非常直白例如String与JSONfunc (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().Get与req.FormValue。Day 2 完整示例func main() { r : gee.New() r.GET(/, func(c *gee.Context) { c.HTML(http.StatusOK, h1Hello Gee/h1) }) r.GET(/hello, func(c *gee.Context) { // expect /hello?namegeektutu c.String(http.StatusOK, hello %s, youre 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?namegeektutu curl -X POST http://localhost:9999/login -d usernamegeektutupassword1234此时的路由仍为精确匹配见 day2-context/gee/router.gokey : method - pattern直接查 map未命中返回404 NOT FOUND。但 Context 的统一抽象已为后续的动态路由、中间件链打下了数据结构基础。Day 3Trie 树路由 —— 支持动态参数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*段则把剩余所有段用/拼接后整体作为 valueif 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, youre 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/geektutu时c.Param(name)返回geektutuGET /assets/*filepath访问/assets/css/geektutu.css时c.Param(filepath)返回css/geektutu.css注意不包含前导/assets/。gee.H是map[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 中Engine与RouterGroup组合设计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.routergroups保存全部分组为 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, h1Hello Gee/h1) }) v1.GET(/hello, func(c *gee.Context) { // expect /hello?namegeektutu c.String(http.StatusOK, hello %s, youre 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, youre 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.gofunc (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 的处理链与 Nextday5-middleware/gee/context.go 为 Context 增加handlers []HandlerFunc与index 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)) } }中断处理链FailFail通过把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, h1Hello 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, youre 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 6HTML 模板渲染与静态资源Day 6 补齐 Web 框架的页面能力动态模板渲染、自定义模板函数与静态文件服务。模板引擎接入day6-template/gee/gee.go 中Engine新增两个字段并提供两个 APIhtmlTemplates *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.gofunc (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/templatesarr.tmpl遍历学生数组、css.tmpl引用静态样式、custom_func.tmpl调用FormatAsDate自定义函数静态文件位于 gee-web/day6-template/static含css/geektutu.css与file1.txt。r.LoadHTMLGlob(templates/*)中的模式是相对go run .执行目录的运行前请确认工作目录在day6-template下。Day 7错误恢复 —— 让框架不因 panic 崩溃最后一个主题是健壮性当 handler 中发生 panic如数组越界时不能让整个 HTTP 服务进程崩溃而要捕获异常、记录堆栈并返回 500。Recovery 中间件与 traceday7-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()执行链中抛出的任何 panictrace函数通过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.go2上下文设计Context统一封装请求/响应提供String/JSON/HTML/Dataday2-context/gee/context.go3动态路由Trie 树支持:param与*filepathday3-router/gee/trie.go、router.go4分组控制RouterGroup前缀分组支持嵌套day4-group/gee/gee.go5中间件Next()洋葱模型分组级Useday5-middleware/gee/context.go6模板渲染自定义模板函数、HTML 渲染、静态资源day6-template/gee/gee.go7错误恢复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-golang7 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点击查看免费下载相关推荐7天从零实现Go语言Web框架Gee快速掌握http.Handler核心原理7天从零实现Go语言Web框架Gee快速掌握http.Handler核心原理 本文是7天用Go从零实现Web框架Gee教程系列的第一篇。通过本文你将简单了解示例工程7天用Go从零实现Web框架Gee教程解析7天用Go从零实现Web框架Gee教程解析 为什么要自己实现Web框架 在Go语言开发Web应用时标准库 net/http 提供了基础功能但实际开发中我们常示例工程AsNumpy数据转换CPU与NPU间高效数据迁移的3种策略AsNumpy数据转换CPU与NPU间高效数据迁移的3种策略 AsNumpy是哈尔滨工业大学计算学部与华为CANN团队联合开发的昇腾NPU原生Numpy仓库科学计算高性能计算Ascend上一篇极限多轮对话Qwen3-235B-A22B-Thinking-2507-FP8历史思维处理下一篇超省成本SGLang大模型资源优化与成本控制实战指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。