- 示例工程
【免费下载链接】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动手写/从零实现系列
导读
本文以 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 中展示了最原始的实现——直接用标准库注册 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 签名冗长,每次都要手动处理w、req两个参数。
框架版: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 中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-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) | 设置响应头 | — |
其内部实现非常直白,例如String与JSON:
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().Get与req.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/geektutu时c.Param("name")返回geektutu;GET /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.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 []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)) } }中断处理链: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.css与file1.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.Handler,Engine实现ServeHTTP | day1-http-base/base3/gee/gee.go |
| 2 | 上下文设计 | Context统一封装请求/响应,提供String/JSON/HTML/Data | day2-context/gee/context.go |
| 3 | 动态路由 | Trie 树支持:param与*filepath | day3-router/gee/trie.go、router.go |
| 4 | 分组控制 | RouterGroup前缀分组,支持嵌套 | day4-group/gee/gee.go |
| 5 | 中间件 | Next()洋葱模型,分组级Use | day5-middleware/gee/context.go |
| 6 | 模板渲染 | 自定义模板函数、HTML 渲染、静态资源 | day6-template/gee/gee.go |
| 7 | 错误恢复 | Recovery捕获 panic 并返回 500 | day7-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动手写/从零实现系列
相关推荐
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
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考