本文主要解决在多租户场景下的模板渲染问题。
正常情况下 Gin 配置的所有模板都属于同一个模板组合,相同名称的模板将相互覆盖。在未通过 define
指定模板名称时,同名模板文件也将相互覆盖。自定义函数中也无法区分租户,这将非常不方便我们进行多租户的模板渲染处理。通过自定义 HTML 渲染器,将一一解决这些问题。
一、Gin 源码分析
Gin 通过 router.LoadHTMLGlob
或 router.LoadHTMLFiles
函数初始化 HTML 模板,这两个函数的源码如下。
// LoadHTMLGlob loads HTML files identified by glob pattern
// and associates the result with HTML renderer.
func (engine *Engine) LoadHTMLGlob(pattern string) {
left := engine.delims.Left
right := engine.delims.Right
// 初始化模板
templ := template.Must(template.New("").Delims(left, right).Funcs(engine.FuncMap).ParseGlob(pattern))
if IsDebugging() {
debugPrintLoadTemplate(templ)
engine.HTMLRender = render.HTMLDebug{Glob: pattern, FuncMap: engine.FuncMap, Delims: engine.delims}
return
}
engine.SetHTMLTemplate(templ)
}
// LoadHTMLFiles loads a slice of HTML files
// and associates the result with HTML renderer.
func (engine *Engine) LoadHTMLFiles(files ...string) {
if IsDebugging() {
engine.HTMLRender = render.HTMLDebug{Files: files, FuncMap: engine.FuncMap, Delims: engine.delims}
return
}
// 初始化模板
templ := template.Must(template.New("").Delims(engine.delims.Left, engine.delims.Right).Funcs(engine.FuncMap).ParseFiles(files...))
engine.SetHTMLTemplate(templ)
}
可以看到,这里面区分了 DEBUG
模式,DEBUG 模式的渲染器是 render.HTMLDebug
,他将在每次渲染是重新创建模板,从而使模板修改能够实时生效。
DEBUG 渲染器:
HTMLDebug
渲染器与生产渲染器没有本质不同,只是将创建 template
模板的步骤放在了执行渲染时。执行渲染的接口源码如下:
// Instance (HTMLDebug) returns an HTML instance which it realizes Render interface.
func (r HTMLDebug) Instance(name string, data any) Render {
return HTML{
// 重新创建模板
Template: r.loadTemplate(),
Name: name,
Data: data,
}
}
生产渲染器:
生产渲染器通过 engine.SetHTMLTemplate(templ)
函数初始化渲染器,初始化时将 template
模板作为参数传入。
// SetHTMLTemplate associate a template with HTML renderer.
func (engine *Engine) SetHTMLTemplate(templ *template.Template) {
if len(engine.trees) > 0 {
debugPrintWARNINGSetHTMLTemplate()
}
engine.HTMLRender = render.HTMLProduction{Template: templ.Funcs(engine.FuncMap)}
}
在执行渲染时将取出模板,重复使用。
// Instance (HTMLProduction) returns an HTML instance which it realizes Render interface.
func (r HTMLProduction) Instance(name string, data any) Render {
return HTML{
Template: r.Template,
Name: name,
Data: data,
}
}
通过 Gin 源码可知,Gin 封装了渲染器 engine.HTMLRender
,实际上并未对 template
模板做太多的功能封装,直接使用 template
模板的接口进行模板渲染。
所以,进行多租户设计时,我们自定义 engine.HTMLRender
渲染工具,内部创建多个 template
模板,即可解决多租户模板混合的问题。
自定义函数无法区分租户问题也只需要在初始化模板前,给函数传入租户编号即可。
但要进一步解决同名模板文件也将相互覆盖,就必须看 template
的源码了。
二、Template 源码分析
通过 Gin 源码的分析,我们可知 template
模板通过 ParseGlob
或 ParseFiles
函数创建模板实例。
func (t *Template) ParseGlob(pattern string) (*Template, error) {
return parseGlob(t, pattern)
}
// parseGlob is the implementation of the function and method ParseGlob.
func parseGlob(t *Template, pattern string) (*Template, error) {
if err := t.checkCanParse(); err != nil {
return nil, err
}
// 读取文件
filenames, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
if len(filenames) == 0 {
return nil, fmt.Errorf("html/template: pattern matches no files: %#q", pattern)
}
// 渲染这些文件
return parseFiles(t, readFileOS, filenames...)
}
func (t *Template) ParseFiles(filenames ...string) (*Template, error) {
return parseFiles(t, readFileOS, filenames...)
}
// parseFiles is the helper for the method and function. If the argument
// template is nil, it is created from the first file.
func parseFiles(t *Template, readFile func(string) (string, []byte, error), filenames ...string) (*Template, error) {
if err := t.checkCanParse(); err != nil {
return nil, err
}
if len(filenames) == 0 {
// Not really a problem, but be consistent.
return nil, fmt.Errorf("html/template: no files named in call to ParseFiles")
}
for _, filename := range filenames {
// 读取文件名和文件内容
name, b, err := readFile(filename)
if err != nil {
return nil, err
}
s := string(b)
// First template becomes return value if not already defined,
// and we use that one for subsequent New calls to associate
// all the templates together. Also, if this file has the same name
// as t, this file becomes the contents of t, so
// t, err := New(name).Funcs(xxx).ParseFiles(name)
// works. Otherwise we create a new template associated with t.
// 为该模板文件新建模板空间(define),此处指定名称为文件名
// 同名文件互相覆盖的原因
var tmpl *Template
if t == nil {
t = New(name)
}
if name == t.Name() {
tmpl = t
} else {
tmpl = t.New(name)
}
// 初始化模板
_, err = tmpl.Parse(s)
if err != nil {
return nil, err
}
}
return t, nil
}
从上述源码可见, ParseGlob
只是比 ParseFiles
函数多了个读取模板文件的步骤,实际上都是通过 parseFiles
函数进行模板初始化。
通过 parseFiles
函数可知,初始化模板空间时将模板文件名做为名称,这也是模板文件相互覆盖的原因。
三、问题解决
自定义 template
模板初始化流程,解决函数无法获取租户信息问题、同名文件互相覆盖问题。
// 用于存储租户信息的自定义函数结构体
type BaseFunc struct {
Site *table.Site
}
// 为租户创建模板
func loadHTMLGlob(site *table.Site, delimLeft, delimRight string) *template.Template {
// 创建函数结构体,传入租户参数 site,所有函数都可以获取到该租户参数
// 解决函数无法获取租户信息问题
Func := _func.BaseFunc{Site: site}
funcMap := template.FuncMap{
"MenuTree": Func.MenuTree,
"TimeFormat": Func.TimeFormat,
"TimeAgo": Func.TimeAgo,
"CategoryByParentId": Func.CategoryByParentId,
"FormSchema": Func.FormSchema,
"PostByLatest": Func.PostByLatest,
"Pagination": Func.Pagination,
"Add": Func.Add,
"Html": Func.Html,
"Br": Func.Br,
"Default": Func.Default,
"Switch": Func.Switch,
}
templ := template.New("").Delims(delimLeft, delimRight).Funcs(funcMap)
// 取得租户的模板文件路径
themeTemplatePath, _ := filepath.Abs(utils.GetThemePath(site.Id, site.ThemeId) + "/templates")
// 读取该租户路径下的文件,取得一个map,key为文件全路径,value为文件在 themeTemplatePath 的子路径
themeFileMap := FilePathList(themeTemplatePath)
for file, name := range themeFileMap {
b, _ := os.ReadFile(file)
// name 用作模板空间的名称,带上了模板路径,避免同名文件互相覆盖问题
template.Must(templ.New(name).Parse(string(b)))
}
return templ
}
新建结构体,实现 engine.HTMLRender
接口,为每一个租户指定一个 template
模板,避免多租户模板混合。
type SiteHtmlRender struct {
// 一个租户id对应一个模板
templateMap map[int64]*template.Template
delimLeft string
delimRight string
}
// 指定渲染函数的类型为 response.HtmlResponse,从中取得租户信息,选择渲染模板
func (s *SiteHtmlRender) Instance(name string, data any) render.Render {
resp := data.(*response.HtmlResponse)
return render.HTML{
Template: s.templateMap[resp.Site.Id],
Name: name,
Data: data,
}
}
// 初始化渲染器
func HtmlRenderAndDelims(engine *gin.Engine, delimLeft, delimRight string) {
// 为 global.SiteMap 集合中的每个租户初始化一个模板
templateMap := make(map[int64]*template.Template, len(global.SiteMap))
for _, site := range global.SiteMap {
templateMap[site.Id] = loadHTMLGlob(site, delimLeft, delimRight)
}
htmlRender := &SiteHtmlRender{
templateMap: templateMap,
delimLeft: delimLeft,
delimRight: delimRight,
}
// 将模板设置到 engine.HTMLRender,使其生效
engine.HTMLRender = htmlRender
}
四、更多扩展
关键代码如上述章节,但其实通过自定义 SiteHtmlRender
函数还可以实现更多的功能定制。
如,实现某个租户的模板刷新:
func (s *SiteHtmlRender) Refresh(site *table.Site) {
s.templateMap[site.Id] = loadHTMLGlob(site, s.delimLeft, s.delimRight)
}
如,自定义一个模板渲染接口,方便自己后续使用:
func (s *SiteHtmlRender) Render(siteId int64, templateName string, param any) (string, error) {
buf := new(bytes.Buffer)
err := s.templateMap[siteId].ExecuteTemplate(buf, templateName, param)
if err != nil {
return "", err
}
return buf.String(), nil
}