本文主要解决在多租户场景下的模板渲染问题。
正常情况下 Gin 配置的所有模板都属于同一个模板组合,相同名称的模板将相互覆盖。在未通过 define
指定模板名称时,同名模板文件也将相互覆盖。自定义函数中也无法区分租户,这将非常不方便我们进行多租户的模板渲染处理。通过自定义 HTML 渲染器,将一一解决这些问题。
一、Gin 源码分析 {#一-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 源码分析 {#二-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
}