Featured image of post Go templates: customize your output using templates

Go templates: customize your output using templates

Go templates are a powerful tool to customize output the way you want it. It’s a builtin package implements data-driven templates. Templates are executed by applying them to a data structure.

While there are articles covering the basics, I had a hard time findings material on more advanced use-cases, such as looping over complex structs or using a function in the template. This post aims to distill these advanced use-cases with examples.

If you’re unfamiliar with templates, this blog post covers a great introduction to it.

This is our first use-case: I need to provide configuration to Thanos about the sidecars. It’s basically a list of servers Thanos needs to communicate with. I have hundreds of servers. If you’re not familiar with Thanos, that’s okay. It’s not important for this post. I use it just to show a real-world example.

The template and data

We start by creating a template, then the data it’s gonna execute with. Our output would be a YAML configuration file.

Here is an example of how to iterate a slice and use it in a template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
	"os"
	"text/template"
)

type data struct {
	Locations []string
}

var mydata = &data{
	Locations: []string{"NY", "London", "Tokyo"},
}

var tmplSrc = `---
- targets:
{{- range .Locations }}
  - {{ . }}
{{- end }}
`

func main() {
	tmpl := template.Must(template.New("test").Parse(tmplSrc))
	tmpl.Execute(os.Stdout, mydata)
}

# https://go.dev/play/p/hlZhU40wc0C
# output
# ---
# - targets:
#  - NY
#  - London
#  - Tokyo

Using the range action we iterate an object. In my case, it’s a slice of strings. Inside the {{ range .. }} block we refer to items as .

Passing data to the template

In the previous example, we could simply pass in the slice to the template. But I used a struct. This allows me to extend my template with minimal changes to the code.

At the time of writing, the template engine supports up to 1 argument. If you need to provide multiple values, use a struct.

Another (more complex) alternative is this answer on Stackoverflow.

Let’s say instead of templating the location names, we want their respective IPs. This requires a few changes in our code. We would need to update our data struct to a map, of locations to IPs, and the template code too:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type data struct {
    Locations []string
    IPAddrs map[string]string
}

var mydata = &data{
	Locations: []string{"NY", "London", "Tokyo"},
	IPAddrs: map[string]string{
		"NY": "10.0.0.1",
		"London": "20.0.0.1",
		"Tokyo": "30.0.0.1",
	},
}

var tmplSrc = `---
- targets:
{{- range $location, $ip := .IPAddrs }}
  - {{ $ip }}  # {{ $location }}
{{- end }}
`

# output
# ---
# - targets:
# - 20.0.0.1  # London
# - 10.0.0.1  # NY
# - 30.0.0.1  # Tokyo

Here I loop over a map and extract the relevant values. Cool.

Important thing to note is, we define $location and $ip variables in the {{ range .. }} statement. They will be available in the template only in the context of the range block. You can’t use them outside this scope:

A variable’s scope extends to the “end” action of the control structure (“if”, “with”, or “range”) in which it is declared, or to the end of the template if there is no such control structure.

A template invocation does not inherit variables from the point of its invocation.

When execution begins, $ is set to the data argument passed to Execute, that is, to the starting value of dot.

Access multiple fields of a data structure

Our requirements have changed. Now we don’t want to template all the locations of the IPAddrs map, but only the selected ones (enabled).

We need to modify our data. Our data struct contains all the locations (slice) and their relevant IP addresses (map). A better name for our Locations field would be EnabledLocations. We change that accordingly.

Now we want to template an IP address only if the location it resides is enabled. We would need to:

  • Iterate a slice (EnabledLocations)
  • Fetch relevant data from the map (IPAddrs)
  • Template the data in our template
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type data struct {
	EnabledLocations []string
	IPAddrs          map[string]string
}

var mydata = &data{
	EnabledLocations: []string{"NY", "Tokyo"},
	IPAddrs: map[string]string{
		"NY":     "10.0.0.1",
		"London": "20.0.0.1",
		"Tokyo":  "30.0.0.1",
	},
}

var tmplSrc = `---
- targets:
{{- range $location := .EnabledLocations }}
{{- $ip := index $.IPAddrs $location }}
  - {{ $ip }}  # {{ $location }}
{{- end }}
`

# https://go.dev/play/p/zOhRXGxoOMV
# output
# ---
# - targets:
#  - 10.0.0.1  # NY
#  - 30.0.0.1  # Tokyo

I have done a few things here:

  • Remove London from the EnabledLocations slice
  • Iterate .EnabledLocations slice with range action and save current element to a variable $location
  • Define a new variable $ip with the value from a map with a key $location
    • Inside the {{ range .. }} clause the scope is changed. . cursor now reference the current item in the loop (in this example, it is equal to $location)
    • To access the outer scope, I use $. — that way I can access the IPAddrs map
    • index function returns the result of indexing its first argument by the following arguments. In other words, the first argument is the key, and the latter is a map, slice, or array

Let’s complicate things a little more. Let’s say our setup grew with more servers per location. We need our template to support multiple IPs per location. This requires changes to our data structure and template once again.

How can we use a dynamic key to fetch values from our map?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type data struct {
	EnabledLocations []string
	IPAddrs          map[string][]string
}

var mydata = &data{
	EnabledLocations: []string{"NY", "Tokyo"},
	IPAddrs: map[string][]string{
		"NY":     []string{"10.0.0.1", "10.0.0.2"},
		"London": []string{"20.0.0.1", "20.0.0.2"},
		"Tokyo":  []string{"30.0.0.1", "30.0.0.2"},
	},
}

var tmplSrc = `---
- targets:
{{- range $location := .EnabledLocations }}
{{- $ipList := index $.IPAddrs $location }}
  {{- range $ip := $ipList }}
  - {{ $ip }}  # {{ $location }}
  {{- end }}
{{- end }}
`

# output
# ---
# - targets:
#   - 10.0.0.1  # NY
#   - 10.0.0.2  # NY
#   - 30.0.0.1  # Tokyo
#   - 30.0.0.2  # Tokyo

Here is what changed:

  • IPAddrs is a map of string to slice of strings
  • Define the $ipList variable which contains the relevant string slice (using the index function)
  • I got 2 range loops now: one loop the locations, the other loop each location’s IP list

You can also call methods inside a template, here’s a clear example from StackOverflow how to do it.

Summary

There are multiple ways to template data. Go provides a rich builtin library worth exploring. Make sure the read the docs before banging your head over syntax, or other builtin capabilities such as index, range, and many more I haven’t covered in this post. Actually, the library is much richer than what I present here. Here are the takeaways:

  • range action is used to iterate data structures
  • index function is used to extract data from a map, slice, or array
  • $var := is used to define a variable and use it inside a template
  • functions can be executed inside templates