Skip to content

Instantly share code, notes, and snippets.

@jonathaningram
Created May 4, 2015 23:39
Show Gist options
  • Save jonathaningram/9e7a6e18863e04eea398 to your computer and use it in GitHub Desktop.
Save jonathaningram/9e7a6e18863e04eea398 to your computer and use it in GitHub Desktop.
One way in Go to build a template engine/renderer.
{{template "layout" .}}
{{define `htmlAttrs`}} ng-app="myApp"{{end}}
{{define "headTitle"}}Contact us<!-- can't include "parent" like in Jinja/Twig so these "blocks" are not 100% flexible, but still usable -->{{end}}
{{define "headExtraScripts"}}
<!-- assuming this is not in an asset pipeline where it normally would be -->
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js"></script>
{{end}}
{{define "bodyClass"}}contact{{end}}
{{define "bodyAttrs"}}ng-cloak ng-controller="ContactCtrl"{{end}}
{{define "content"}}
<main>
<h1>Contact us</h1>
</main>
{{end}}
package template
import (
"fmt"
htmltemplate "html/template"
"log"
"path/filepath"
"strings"
texttemplate "text/template"
)
type Engine interface {
LoadHTMLTemplate(templatePath string, funcs []htmltemplate.FuncMap) (*htmltemplate.Template, error)
LoadTextTemplate(templatePath string, funcs []texttemplate.FuncMap) (*texttemplate.Template, error)
}
// funcs that the Engine uses. could be made more flexible
var templateFuncs = []htmltemplate.FuncMap{
htmltemplate.FuncMap{
"add": add,
"safeHTML": safeHTML,
"safeHTMLAttr": safeHTMLAttr,
"safeJS": safeJS,
"safeJSStr": safeJSStr,
"safeURL": safeURL,
"toLower": strings.ToLower,
"ng": ngFilter,
"requestScheme": requestScheme,
"nl2br": nl2br,
},
}
func NewEngine(
sharedTemplates []string,
autoReload bool,
) Engine {
return &engine{
templateFuncs: templateFuncs,
sharedTemplates: sharedTemplates,
autoReload: autoReload,
}
}
type engine struct {
htmlTemplates map[string]*htmltemplate.Template
textTemplates map[string]*texttemplate.Template
templateFuncs []htmltemplate.FuncMap
sharedTemplates []string
autoReload bool
}
func (e *engine) LoadHTMLTemplate(templatePath string, funcs []htmltemplate.FuncMap) (*htmltemplate.Template, error) {
if e.htmlTemplates == nil {
e.htmlTemplates = make(map[string]*htmltemplate.Template)
}
fullTemplatePath := templatePath
t, ok := e.htmlTemplates[fullTemplatePath]
if e.autoReload || !ok {
templateName := filepath.Base(fullTemplatePath)
t = htmltemplate.New(templateName)
for _, f := range e.templateFuncs {
t.Funcs(f)
}
for _, f := range funcs {
t.Funcs(f)
}
templates := []string{
fullTemplatePath,
}
templates = append(templates, e.sharedTemplates...)
if _, err := t.ParseFiles(templates...); err != nil {
return nil, err
}
e.htmlTemplates[fullTemplatePath] = t
log.Printf("compiled template %q", fullTemplatePath)
}
return t, nil
}
func (e *engine) LoadTextTemplate(templatePath string, funcs []texttemplate.FuncMap) (*texttemplate.Template, error) {
if e.textTemplates == nil {
e.textTemplates = make(map[string]*texttemplate.Template)
}
fullTemplatePath := templatePath
t, ok := e.textTemplates[fullTemplatePath]
if e.autoReload || !ok {
templateName := filepath.Base(fullTemplatePath)
t = texttemplate.New(templateName)
for _, f := range funcs {
t.Funcs(f)
}
if _, err := t.ParseFiles(fullTemplatePath); err != nil {
return nil, err
}
e.textTemplates[fullTemplatePath] = t
log.Printf("compiled text template %q", fullTemplatePath)
}
return t, nil
}
package main
func main() {
templateEngine := template.NewEngine(
[]string{
"app/templates/layout.html",
},
appConfig.Env == "dev",
)
// ...
router.NewRoute().Methods("HEAD", "GET").PathPrefix("/sitemap.xml").Handler(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
sitemapHandler(
res,
req,
template.NewRenderer(
res,
req,
templateEngine,
appConfig.TemplatesDir,
templateGlobals(req, appConfig),
),
)
}))
router.NewRoute().Methods("HEAD", "GET").PathPrefix("/contact").Handler(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
contactHandler(
res,
req,
template.NewRenderer(
res,
req,
templateEngine,
appConfig.TemplatesDir,
templateGlobals(req, appConfig),
),
)
}))
}
func templateGlobals(req *http.Request, appConfig AppConfig) map[string]interface{} {
data := map[string]interface{}{
// maybe the renderer should pass request instead of doing it via globals, not sure
"request": req,
"env": appConfig.Env,
"debug": appConfig.Debug,
"now": time.Now(),
}
return data
}
func sitemapHandler(
res http.ResponseWriter,
req *http.Request,
render template.Renderer,
) {
entries := []map[string]interface{}{
map[string]interface{}{
"path": "/contact",
},
}
res.Header().Set("Content-Type", "text/xml; charset=utf-8")
if err := render.Text("sitemap.xml", map[string]interface{}{
"entries": entries,
}); err != nil {
res.WriteHeader(http.StatusInternalServerError)
return
}
}
func contactHandler(
res http.ResponseWriter,
req *http.Request,
render template.Renderer,
) {
if err := render.HTML("contact.html", map[string]interface{}{}); err != nil {
// ...
return
}
}
package template
import (
htmltemplate "html/template"
"net/http"
"strings"
)
// example funcs that the Engine uses. could be made more flexible
func add(a, b int) int {
return a + b
}
func safeHTML(arg interface{}) htmltemplate.HTML {
return htmltemplate.HTML(arg.(string))
}
func safeHTMLAttr(arg interface{}) htmltemplate.HTMLAttr {
return htmltemplate.HTMLAttr(arg.(string))
}
func safeJS(arg interface{}) htmltemplate.JS {
return htmltemplate.JS(arg.(string))
}
func safeJSStr(arg interface{}) htmltemplate.JSStr {
return htmltemplate.JSStr(arg.(string))
}
func safeURL(arg interface{}) htmltemplate.URL {
return htmltemplate.URL(arg.(string))
}
func requestScheme(req *http.Request) string {
if req.TLS != nil {
return "https"
}
return "http"
}
func ngFilter(arg string) string {
return "{{" + arg + "}}"
}
func nl2br(arg string) htmltemplate.HTML {
return htmltemplate.HTML(strings.Replace(arg, "\n", "<br>", -1))
}
{{define "layout"}}<!doctype html>
<html lang="en" {{template `htmlAttrs` .}}>
<head>
<meta charset="utf-8">
<title>{{template "headTitle" .}}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{template "metaRobots" .}}
{{template "headExtraScripts" .}}
</head>
<body class="{{template `bodyClass` .}}" {{template `bodyAttrs` .}}>
<header class="container"></header>
{{template "content" .}}
<footer></footer>
</body>
</html>
{{end}}
{{define `htmlAttrs`}}{{end}}
{{define "metaRobots"}}{{end}}
{{define "headExtraScripts"}}{{end}}
{{define "bodyClass"}}{{end}}
{{define "bodyAttrs"}}{{end}}
package template
import (
"bytes"
"fmt"
htmltemplate "html/template"
"net/http"
"strings"
texttemplate "text/template"
"github.com/oxtoacart/bpool"
)
var bufpool *bpool.BufferPool
func init() {
bufpool = bpool.NewBufferPool(64)
}
type Renderer interface {
HTML(templatePath string, data map[string]interface{}) error
Text(templatePath string, data map[string]interface{}) error
}
func NewRenderer(
res http.ResponseWriter,
req *http.Request,
te Engine,
appTemplatesDir string,
globals map[string]interface{},
) Renderer {
return &renderer{
response: res,
request: req,
engine: te,
appTemplatesDir: appTemplatesDir,
globals: globals,
}
}
type renderer struct {
response http.ResponseWriter
request *http.Request
engine Engine
appTemplatesDir string
globals map[string]interface{}
}
func (r *renderer) HTML(templatePath string, data map[string]interface{}) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
switch t := r.(type) {
case error:
err = t
default:
panic(r)
}
}()
templatePath = fmt.Sprintf("%s/%s", r.appTemplatesDir, templatePath)
t, err := r.engine.LoadHTMLTemplate(templatePath, r.funcs())
if err != nil {
return err
}
for key, value := range r.globals {
if _, ok := data[key]; !ok {
data[key] = value
}
}
b := bufpool.Get()
defer bufpool.Put(b)
if err := t.Execute(b, data); err != nil {
return err
}
r.response.Header().Set("Content-Type", "text/html; charset=utf-8")
b.WriteTo(r.response)
return nil
}
func (r *renderer) Text(templatePath string, data map[string]interface{}) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
switch t := r.(type) {
case error:
err = t
default:
panic(r)
}
}()
templatePath = fmt.Sprintf("%s/%s", r.appTemplatesDir, templatePath)
t, err := r.engine.LoadTextTemplate(templatePath, r.textFuncs())
if err != nil {
panic(err)
}
for key, value := range r.globals {
if _, ok := data[key]; !ok {
data[key] = value
}
}
b := bufpool.Get()
defer bufpool.Put(b)
if err := t.Execute(b, data); err != nil {
return err
}
b.WriteTo(r.response)
return nil
}
func (r *renderer) funcs() []htmltemplate.FuncMap {
return []htmltemplate.FuncMap{
htmltemplate.FuncMap{
"trimSpace": func(arg string) string {
return strings.TrimSpace(arg)
},
"truncate": func(length int, separator, arg string) string {
if length >= len(arg) {
return arg
}
b := &bytes.Buffer{}
b.Write([]byte(arg))
b.Truncate(length)
return b.String() + separator
},
},
}
}
func (r *renderer) textFuncs() []texttemplate.FuncMap {
funcMap := texttemplate.FuncMap{}
for _, funcs := range r.funcs() {
for name, f := range funcs {
funcMap[name] = f
}
}
return []texttemplate.FuncMap{funcMap}
}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{$request := .request}}
{{range $key, $entry := .entries}}
<url>
<loc>http://{{$request.Host}}{{$entry.path}}</loc>
</url>
{{end}}
</urlset>
@elithrar
Copy link

elithrar commented May 5, 2015

This looks great! Some general comments from browsing the source:

  • I would move the panic recovery out of the Text and HTML functions. It infers a performance hit and template rendering is already a bottleneck, and you can usually count on middleware on the router/handler to deal with panic recovery.
  • Building the templates and funcs in the Text and HTML functions is also going to be a big bottleneck. We should be able to do it once in NewEngine (typically on program start-up) but if "dev mode" is turned on call a helper function - e.g. (pseudocode):
if r.mode == "dev" {
    t, err := reload()
    if err != nil {
       // Handle it
    }
}
  • Other than that it looks good. I like the use of the buffer pools to catch rendering errors ;)

@jonathaningram
Copy link
Author

Thanks for the feedback, will look into it.

  • The reason for catching the panic in the Text and HTML functions is so that I can return an error from the render call. The specific reason being that I want to be able to handle that error case in the handler. True a higher level or middleware could catch the panic and render a generic 500 page (plus a stack in dev), but I originally just felt like the flexibility of knowing that the template render failed was good. May be time to review though. BTW, what's the performance hit? Is it the actual call to recover that must be done every time? From http://blog.golang.org/defer-panic-and-recover During normal execution, a call to recover will return nil and have no other effect. Although I admit that that quote may be out of context performance-wise.
  • You're right about the funcs, it needs to be organised better too. Also, I need to find the use case for allowing funcs to be defined on the engine and on the renderer. I think one reason was that you could have dynamic funcs added to a renderer on a handler by handler basis (i.e. needed for some handlers but not all). Again, needs a review.
  • I think the buffer pool use came of your article or maybe an SO answer, can't take the credit.

@elithrar
Copy link

elithrar commented May 6, 2015

  • I can't seem to find the source, but my understanding was that the defer call was the (unavoidable) and slow part. I could be wrong here.
  • What's the overhead of passing (say) 20 funcs vs. 5? I'd be curious to know if there's a noticeable response-time bottleneck. Template processing can be crazy slow so you might be right in the value of per-handler funcs (i.e. don't call what you don't need). If you can divorce that from the template loading (which you never want to do in a handler - hits disk!) it'd be ideal, but not sure there's a clear way to do that without a lot of copying (so that the funcmap doesn't mutate the original (clean) template.Template map)
  • I think it might be one of mine - I used that exact library. Works well!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment