本文主要解决在多租户场景下的模板渲染问题。

正常情况下 Gin 配置的所有模板都属于同一个模板组合,相同名称的模板将相互覆盖。在未通过 define 指定模板名称时,同名模板文件也将相互覆盖。自定义函数中也无法区分租户,这将非常不方便我们进行多租户的模板渲染处理。通过自定义 HTML 渲染器,将一一解决这些问题。

一、Gin 源码分析

Gin 通过 router.LoadHTMLGlobrouter.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 模板通过 ParseGlobParseFiles 函数创建模板实例。

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
}