写在前面:本项目参考 7天用Go从零实现Web框架Gee教程,下面的内容全部参考自该网站。主要是记录我在学习过程中的所思所想,如有存在问题还请多多指教。
现在越来越流行前后端分离的开发模式,即 Web 后端提供 RESTful 接口,返回结构化的数据(通常为 JSON 或者 XML)。前端使用 AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。Vue/React 等前端框架持续火热,这种开发模式前后端解耦,优势非常突出。后端童鞋专心解决资源利用,并发,数据库等问题,只需要考虑数据如何生成;前端童鞋专注于界面设计实现,只需要考虑拿到数据后如何渲染即可。使用 JSP 写过网站的童鞋,应该能感受到前后端耦合的痛苦。JSP 的表现力肯定是远不如 Vue/React 等专业做前端渲染的框架的。而且前后端分离在当前还有另外一个不可忽视的优势。因为后端只关注于数据,接口返回值是结构化的,与前端解耦。同一套后端服务能够同时支撑小程序、移动APP、PC端 Web 页面,以及对外提供的接口。随着前端工程化的不断地发展,Webpack,gulp 等工具层出不穷,前端技术越来越自成体系了。
但前后分离的一大问题在于,页面是在客户端渲染的,比如浏览器,这对于爬虫并不友好。Google 爬虫已经能够爬取渲染后的网页,但是短期内爬取服务端直接渲染的 HTML 页面仍是主流。
今天的内容便是介绍 Web 框架如何支持服务端渲染的场景。
先看一下完成今天的任务后我们可以做到的事情:
r := gee.New()
r.Static("/assets", "./static")
r.Run(":9999")
这样设置之后,我们希望能够做到:当一个请求 /assets/golang.js 发送过来之后,会自动将 ./static/golang.js 的文件内容返回。
我们需要实现这样一个 Static 方法,完成上述需求。
Static 方法的实现
实现代码如下:
// createStaticHandler 从 URL 中拿到 filepath,然后映射到磁盘中存储静态文件的位置,校验无误后用 FileServer 发回去
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)
}
}
// Static 这里的传参的 relativePath 就是访问的 URL,root 就是存放静态文件的目录地址
func (group *RouterGroup) Static(relativePath string, root string) {
handler := group.createStaticHandler(relativePath, http.Dir(root))
urlPattern := path.Join(relativePath, "/*filepath")
group.GET(urlPattern, handler)
}
先看 Static,这个方法传入一个 URL 和一个存放静态文件的路径(注意这里的两个传参都是 String 类型),然后会根据这两个参数自动注册一个路由,最终实现前面讲的解析请求的地址,映射到服务器上文件的真实地址并返回。
Static 的实现借助了 createStaticHandler 方法,我们先来分析一下这个方法的两个传参。该方法传入一个 relativePath 和一个 fs,这里的两个参数都从 Static 中传入,因此我们回看 Static 中的这两个参数分别怎么来的。
可以看到,relativePath 实际上就是我们需要转化的 URL,比如我们希望 /assets 下的所有请求都返回静态目录下的文件,那么这里的 relativePath 就是 /assets(以前面的例子 r.Static("/assets", "./static")来看)。那么fs 是什么呢?我们可以看到,fs 实际上是一个 http.FileSystem 类型的参数,那么 http.FileSystem 是什么呢?我们来看看源码:
type FileSystem interface {
Open(name string) (File, error)
}
所以其实这是一个接口,只要一个结构实现了 Open 方法,那么就符合这个接口,就可以作为传参传入。那么我们回到最开始的我们希望实现的功能:
r := gee.New()
r.Static("/assets", "./static")
r.Run(":9999")
这里传入了一个 ./static 到 Static,Static 收到之后对其做了一个操作: http.Dir(root),其中 root 是一个 String 类型,而经过 http.Dir 的处理之后,就将其包装成了一个 http.FileSystem,也就是说:http.Dir("./static") 不是字符串,而是一个实现了 Open() 的对象,它满足 http.FileSystem 接口,因此后面才可以被 http.FileServer 处理。
至此我们就清楚了 createStaticHandler 这个接口的两个参数了,接下来我们继续看其中的代码。
首先,absolutePath := path.Join(group.prefix, relativePath)是将 relativePath 的前缀路径全部删掉。什么意思呢?假设说你有分组:
v1 := r.Group("/v1")
v1.Static("/assets", "./static")
那么真实的 URL 前缀就是:
/v1/static
而在 absolutePath := path.Join(group.prefix, relativePath) 操作之后,absolutePath 就变成了 v1/static。
接下来继续看 fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))。这一步的 fileServer 实际上就是将刚刚得到的前缀 absolutePath 删去,然后将 fs 拼接到前面。什么意思呢?我们来看,此时 fs 应该是一个 http.FileSystem,对吧,具体来说就是把传入的 ./static这个路径包装成了一个 http.FileSystem。然后,回想一下我们最终希望实现的功能:当一个请求 /assets/css/golang.js 发送过来之后,会自动将./static/css/golang.js 的文件内容返回。为了实现这个功能,这里的处理就是:先把 /assets/css/golang.js 的 /assets 去掉,然后把 ./static 拼接到前面,最后注册一个 ./static/css/golang.js 的路由。
因此,最后的 fileServer 可以这样理解:fileServer := http.StripPrefix(absolutePath, http.FileServer(fs)) 的本质就是把“带路由前缀的 URL 世界”和“真实磁盘路径的文件世界”对齐:路由层需要 /assets/... 来做匹配,但磁盘上只有 ./static/...,所以 StripPrefix 先把请求里的 /assets 剪掉,再把剩下的路径交给 http.FileServer 去磁盘读取文件,从而让 /assets/css/golang.css 正好映射到 ./static/css/golang.css。
最后我们来看返回的内容:
return func(c *Context) {
file := c.Param("filepath")
// Check if file exists and/or if we have permission to access it
if _, err := fs.Open(file); err != nil {
c.Status(http.StatusNotFound)
return
}
fileServer.ServeHTTP(c.Writer, c.Req)
}
}
这一段的关键在于,它把“静态资源请求”拆成了两个阶段:先由 Gee 做路由和安全检查,再交给 net/http 做文件输出。 首先,c.Param("filepath") 取得的是路由 /assets/*filepath 在匹配 URL 时自动解析出来的路径,比如访问 /assets/css/golang.css,这里的 file 就是 css/golang.css,也就是磁盘中相对于 ./static 的真实文件路径。接着用 fs.Open(file) 先尝试打开这个文件,这一步并不是为了读取内容,而是为了做一次“探测”:如果文件不存在或没有权限,就直接返回 404,这样可以避免 http.FileServer 自己乱跳转或返回目录列表,保证框架对错误行为的可控性。只有当这个文件确实存在时,才调用 fileServer.ServeHTTP(c.Writer, c.Req),把当前的响应写入权完全交给 net/http 内置的文件服务器,由它负责设置响应头、读取文件内容并写回给浏览器。换句话说,Gee 在这里并不亲自去读文件,而是只负责“把路径算对、把错误挡住”,真正的 IO 工作全部委托给了标准库。
这段代码的美感在于它像一个理性又克制的中介:框架负责理解 URL 的语义,标准库负责跟磁盘打交道,两者通过 StripPrefix + FileServer 这条小小的管道精准对齐。
HTML 模板渲染
上面实现了 Static 之后,就可以做到:用户访问 localhost:9999/assets/js/way2top.js,最终返回 ./static/js/way2top.js。
但是接下来还有一个问题,比如我们返回的 js 文件中有需要渲染的内容,该如何处理?目前的情况是直接返回原本的内容,没有任何渲染处理。
Go 语言内置的 html/template 为 HTML 提供了较为完整的支持,报错普通变量渲染、列表渲染、对象渲染。gee 框架的模板直接使用了 html/template 提供的能力:
type Engine struct {
*RouterGroup
router *router
groups []*RouterGroup // 存储所有 groups
htmlTemplates *template.Template // 用于 html 渲染
funcMap template.FuncMap // 用于 html 渲染
}
// SetFuncMap 用于渲染,将静态文件中的函数注册到 engine.funcMap;
// 例如,有一个 html 文件内容为 <p>今天是 {{ FormatAsDate .now }}</p>,这里的 FormatAsDate 是一个函数,但是模板引擎本身是不知道的,如果我们不将其注册到 engine.funcMap 中,模版在执行的时候就会报错
func (engine *Engine) SetFuncMap(funcMap template.FuncMap) {
engine.funcMap = funcMap
}
// LoadHTMLGlob 解析指定路径下的所有模板文件 (*.tmpl)
// 并将它们编译成可执行的模板集合,存储在 engine.htmlTemplates
func (engine *Engine) LoadHTMLGlob(pattern string) {
engine.htmlTemplates = template.Must(template.New("").Funcs(engine.funcMap).ParseGlob(pattern))
}
这里我们给 Engine 新增了两个属性:htmlTemplates 和 funcMap。它们分别都是用来干嘛的呢?
首先我们看 SetFuncMap,该方法传入一个 template.FuncMap 类型的参数并存入 engine.funcMap,那么这个所谓的 funcMap 是什么呢?追溯源码,我们可以看到下面的内容:
type FuncMap = template.FuncMap
type FuncMap map[string]any
所以可以看出,funcMap 本质上就是一个 map[string]any。具体来说,它的 key 用于存储模板中可以调用的函数名,而 value 用于存储 Go 中的真是函数。比如,在模板中有这样一个语句:
{{ .now | FormatAsDate }}
模板引擎执行时会做:
- 先拿到
.now(一个time.Time) - 找到
FuncMap["FormatAsDate"] - 用
.now作为参数调用那个 Go 函数 - 把返回的字符串插回 HTML
所以 FuncMap 实际上是在说:
“这个模板语言并不懂日期格式化,但我可以把 Go 的能力借给它。”
接下来是 LoadHTMLGlob 函数,这个函数可以简单理解为:它把磁盘上的一堆 .tmpl 文件,一次性编译成一个“可执行的模板集合”,挂到 Engine 上。或者说是在服务器启动的时候,把一堆模板文件预编译进 内存 ,连同自定义函数一起,变成一个可以被反复 Execute 的模板库。
之后每一个 HTTP 请求,只是在用这个已经编译好的模板去灌数据,而不是再去读文件、再去解析语法。这就是它存在的根本原因:性能 + 正确性 + 结构清晰。
接下来,对原来的 (*Context).HTML()方法做了些小修改,使之支持根据模板文件名选择模板进行渲染。
type Context struct {
// ...
// engine pointer
engine *Engine
}
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())
}
}
func (c *Context) Fail(code int, err string) {
c.index = len(c.handlers)
c.JSON(code, H{"message": err})
}
我们在 Context 中添加了成员变量 engine *Engine,这样就能够通过 Context 访问 Engine 中的 HTML 模板。实例化 Context 时,还需要给 c.engine 赋值。
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// ...
c := newContext(w, req)
c.handlers = middlewares
c.engine = engine
engine.router.handle(c)
}
这里有必要展开讲讲为啥要在 Context 中添加一个指向 Engine 的指针,先来看一个事实:
真正执行 c.HTML(...) 的地方在 Context 里,而真正持有 htmlTemplates 的地方在 Engine 里。如果 Context 里面没有 engine *Engine,那么在处理请求的时候会遇到一个结构性断裂:
Context 知道“我要渲染 HTML”,
但它根本不知道“模板在哪里”。
这就像你手里拿着遥控器,却不知道电视在哪个房间。
为什么不直接用全局变量放模板?
因为框架要支持多个 Engine 实例、要支持测试、要支持隔离。
一旦你把模板放进全局变量,所有服务、所有测试、所有实例就绑死在一起了,框架会立刻变成不可组合的怪物。
所以 Gee 选择了一条更干净的路:
模板是 Engine 的资源 Context 是某一次请求的上下文 那就让 Context 持有一个指向 Engine 的指针
在 ServeHTTP 里:
c := newContext(w, req)
c.engine = engine
这一句的物理意义是:
把“这个请求属于哪个 Engine”,刻进 Context。
于是当你在 handler 里写:
c.HTML(200, "css.tmpl", data)
它内部就可以安全地走:
c.engine.htmlTemplates.ExecuteTemplate(...)
模板从 Engine 来,但使用发生在 Context 中。
所以这个设计的真正目的可以用一句话说清:
让每一个请求,都能优雅地访问它所属 Engine 的全局能力,而不靠全局变量,也不破坏 解耦 。
这是 Web 框架的一个深层原则: 请求是短命的,但它必须能摸到世界。
使用 Demo 进行测试
项目结构:
.
├── gee
│ ├── context.go
│ ├── gee.go
│ ├── go.mod
│ ├── logger.go
│ ├── router.go
│ └── trie.go
├── go.mod
├── main.go
├── static
│ └── css
│ └── way2top.css
└── templates
└── css.tmpl
其中,css.tmpl 的内容为:
<html>
<link rel="stylesheet" href="/assets/css/way2top.css">
<p>way2top.css is loaded</p>
</html>
way2top.css 内容为空,也可以自己添加内容查看效果。
然后是 main.go 的测试代码:
package main
import (
"fmt"
"gee"
"html/template"
"net/http"
"time"
)
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: "Way2top", 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")
}
运行后,打开浏览器输入localhost:9999 查看效果: