Go语言动手写Web框架 - Gee第六天 模版(HTML Template)

77 阅读11分钟

写在前面:本项目参考 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")

这里传入了一个 ./staticStaticStatic 收到之后对其做了一个操作: 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 新增了两个属性:htmlTemplatesfuncMap。它们分别都是用来干嘛的呢?

首先我们看 SetFuncMap,该方法传入一个 template.FuncMap 类型的参数并存入 engine.funcMap,那么这个所谓的 funcMap 是什么呢?追溯源码,我们可以看到下面的内容:

type FuncMap = template.FuncMap

type FuncMap map[string]any

所以可以看出,funcMap 本质上就是一个 map[string]any。具体来说,它的 key 用于存储模板中可以调用的函数名,而 value 用于存储 Go 中的真是函数。比如,在模板中有这样一个语句:

{{ .now | FormatAsDate }}

模板引擎执行时会做:

  1. 先拿到 .now(一个 time.Time
  2. 找到 FuncMap["FormatAsDate"]
  3. .now 作为参数调用那个 Go 函数
  4. 把返回的字符串插回 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 查看效果: