Embarquer des templates avec pkger

Les outils d’inclusion de ressources comme pkger permettent d’embarquer dans l’exécutable aussi bien les assets statiques que des fichiers non publiés, comme les templates.

Voyons comment.

Pourquoi utiliser un “bundler” comme markbates/pkger ?

Une des différences entre le déploiement d’applications Web classiques utilisant le stack LAMP ou des technologies similaires comme Node.JS, Python, ou Ruby on Rails, est le fait que les applications Go sont compilées en un programme exécutable.

Pour ce qui est de la logique métier, cela n’a que peu d’impact, mais les applications Web utilisent aussi des fichiers autres que Go et le contenu des bases de données. Ce sont en particulier les assets statiques transférés directement au navigateur. Ceux-ci peuvent être aussi bien servis directement par l’application, sur une route associée au Handler natif http.FileServer, que servis par un serveur Web placé devant l’application Go, voire par un service de fichiers Cloud totalement indépendant comme Amazon S3, Google Cloud Storage, ou Azure Files.

Un cas quelque peu différent est celui des templates : non seulement, leur organisation sur disque est souvent hiérarchique et totalement décorrélée de la structure des URLs de l’applications - à la différence des assets - mais ils ne sont pas transférés au navigateur client, et n’ont donc pas de raison d’exister séparément du programme lui-même1. Le fait qu’ils n’existent pas en-dehors du binaire peut même être souhaité pour accroître leur sécurité.

Pour tous ces cas, comme pour toute autre ressource à intégrer à un exécutable, la technique éprouvée consiste, lors d’une étape de construction du programme, à les transformer en code source qui sera compilé et lié comme n’importe quel autre fragment de code.

De multiples outils Go existent à cette fin, à tel point que l’inclusion d’une solution standardisée dans Go est à l’étude dans le ticket Github logo35950 , utilisant des méthodes variées.

Jusqu’à l’automne 2019, l’outil packr du framework web Buffalo se détachait du lot, ne fût-ce qu’en raison de l’usage important dudit framework. Mais il présentait divers inconvénients et son concepteur, Mark Bates, lui a créé un successeur, le module markbates/pkger (cf. Ressources additionnelles en bas d’article).

Malheureusement, les exemples fournis utilisent le moteur de templates plush, spécifiques à Buffalo. Cet article montre comment construire un exemple indépendant de Buffalo, avec des templates Go traditionnels, situés dans deux répertoires différents.

Comment utiliser pkger dans son projet

Préparer les dépendances

Durant le développement:

  • Il est nécessaire d’utiliser Go 1.13 ou plus récent.
  • Le projet doit utiliser les modules VGO, dont pkger dépend pour la résolution des chemins, donc penser à initialiser le système de modules. Le nom du module est facultatif si le répertoire de travail est dans le GOPATH.
$ go mod init [module]
  • La commande pkger doit être installée sur le poste de développement, afin de pouvoir être exécutée durant le développement.
$ go get github.com/markbates/pkger/cmd/pkger

Le projet n’a aucune dépendance à l’exécution.

Travailler en mode développement

Durant le développement pkger accède aux ressources au travers du système de fichier, en ne les embarquant pas dans l’exécutable.

Pour cela, il faut s’assurer que le fichier pkged.go est bien absent du répertoire racine du projet. N’hésitez pas à la supprimer, il sera régénéré à chaque construction en mode déploiement.

Le fait que pkger accède aux fichiers au moyen du système de fichiers permet, en particulier dans le cas des templates, de ne pas avoir besoin de recompiler à chaque changement des templates, jusqu’à la préparation d’une version à déployer.

La commande pkger list produit une liste des fichiers identifiés comme devant être inclus dans le bundle de déploiement. Elle a en outre comme effet de bord de supprimer le fichier pkged.go s’il est présent2. Ceci permet d’identifier ce que pkger va embarquer, en particulier pour ajouter les éléments qu’il n’aurait pas choisi d’inclure.

Préparer une compilation pour déploiement

Si la liste d’inclusion est correcte, il est possible de créer le fichier pkged.go avec la commande pkger sans arguments additionnels. La commande n’affiche aucun résultat, mais crée le fichier pkged.go à la racine du module.

$ ls -F ; pkger ; ls -F
LICENSE		README.md	go.mod		go.sum		main.go		templates/
LICENSE		README.md	go.mod		go.sum		main.go		pkged.go	templates/

Il est possible d’utiliser la commande go generate si le fichier source a été rédigé en conséquence, pour obtenir un processus de construction plus idiomatique:

$ ls -F ; go generate ; ls -F
LICENSE		README.md	go.mod		go.sum		main.go		templates/
LICENSE		README.md	go.mod		go.sum		main.go		pkged.go	templates/

Afin de permettre le déploiement sur un poste qui n’aurait pas la commande pkger, comme un serveur d’intégration, il est préférable de commettre le fichier pkged.go dans le dépôt avec le reste des sources.

À ce stade il ne reste plus qu’à compiler comme vous le souhaitez (par exemple un simple go build) : la seule particularité introduite par pkger est le fichier additionnel pkged.go qui est ajouté au projet.

Comment intégrer ses ressources avec pkger

Pour permettre à pkger d’inclure les ressources dans le bundle, les sources du module doivent être rédigés en conséquence. Voyons comment.

Les exemples illustrés dans cet article sont extraites du mini-projet https://github.com/fgm/pkger_demo qui est prêt à compiler: vous pouvez le récupérer pour suivre plus facilement dans votre IDE:

$ go get github.com/fgm/pkger_demo

Rendre le projet compatible avec go generate

Pour permettre l’utilisation de go generate, une instruction de génération doit être incluse dans un fichier. Dans cet exemple, ce sera dans le fichier principal main.go à la racine du projet:

1
2
3
4
//go:generate pkger

package main
// ...snip...

De cette façon, en développement, la commande standard go generate aura pour effet de créer le fichier pkged.go, prêt à compiler.

L’API de pkger: les principes

Pour identifier les ressources qu’il doit embarquer, pkger procède par une analyse syntaxique du code Go, au cours de laquelle il identifie les appels à ses fonctions propres, donc il extrait les arguments déclarés comme correspondant à des chemins. Lorsque ce sont des chaînes constantes, ces arguments sont résolus par rapport à l’emplacement du fichier, en considérant les chemins absolus ("/foo") comme étant relatifs au répertoire racine du module. Ainsi :

  • pour la démo, extraite dans
/Users/fgm/go/src/github.com/fgm/pkger_demo
  • …un argument passé comme
"/templates/layout.gohtml"
  • …est traduit par pkger en
/Users/fgm/go/src/github.com/fgm/pkger_demo/templates/layout.gohtml

En mode développement, c’est ce chemin absolu qui est utilisé pour les appels aux fonctions de fichier standard3.

En mode déploiement, les chemins sur disque ne sont plus utilisés, et les fonctions de pkger résolvent les arguments à partir des données compilées dans l’exécutable, permettant au programme d’être complètement autonome.

L’API de pkger: les fonctions

La grande idée de l’API de pkger consiste à renvoyer des données très similaires à l’API native de Go pour les entrées/sorties, pour un sous-ensemble des paquets os et filepath. Elle se résume à la liste suivante.

Runtime Gopkger
os.Create(name string) (*os.File, error).Create(name string) (pkging.File, error)
os.MkdirAll(path string, perm os.FileMode) error.MkdirAll(path string, perm os.FileMode) error
os.Open(name string) (*os.File, error).Open(name string) (pkging.File, error)
os.Remove(name string) error.Remove(name string) error
os.RemoveAll(path string) error.RemoveAll(path string) error
os.Stat(name string) (os.FileInfo, error).Stat(name string) (os.FileInfo, error)
filepath.Walk(root string, walkFn filepath.WalkFunc) error.Walk(root string, walkFn filepath.WalkFunc) error

Quelques fonctions plus avancées existent pour manipuler les calculs d’inclusion :

  • Include(name string) string est une fonction identité n’opérant aucun traitement.
  • Current(), Info(string), et Parse(string) servent à manipuler les chemins et implémentations pkger pour des opérations avancées.

La fonction pkger.Include est de loin la plus utile : elle se contente de renvoyer le chemin qui lui est passé comme argument, sans aucun traitement, mais permet à la commande pkger d’identifier que la chaîne qui lui est passée correspond à un chemin de ressource à inclure dans le bundle.

Comme le montre le tableau ci-dessus, la principale différence entre les fonctions de pkger et celles du runtime Go est le fait qu’elles renvoient une valeur de type pkging.File et non *os.File. Cette différence est en pratique peu significative: la plupart des fonctions du runtime ne dépendent pas du type concret os.File mais des interfaces du paquet io qu’il implémente ; et le type interface pkging.File en implémente la vaste majorité:

Interfaceos.Filepkging.FileInterfaceos.Filepkging.File
io.CloserXXio.SeekerXX
io.ReadCloserXXio.StringWriterX
io.ReaderXXio.WriteCloserXX
io.ReaderAtXio.WriterXX
io.ReadSeekerXXio.WriterAtX
io.ReadWriteCloserXXio.WriteSeekerXX
io.ReadWriterXX
io.ReadWriteSeekerXX

Chargement de templates dans une arborescence

Le programme de démo pkger_demo est une application Web basique, renvoyant une page mise en form par deux templates, reflétant une disposition courante.

templates/page.gohtml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{{ define "page" }}
{{- /*gotype: github.com/fgm/pkger_demo.PageData*/ -}}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ .Path }} callback</title>
</head>
<body>
  <p>Called on {{ .Path }}</p>
  {{ template "layout/footer" . }}
</body>
</html>
{{ end }}

templates/layout/footer.gohtml

1
2
3
4
5
6
{{define "layout/footer"}}
  {{- /*gotype: github.com/fgm/pkger_demo.PageData*/ -}}
  <footer>
    &copy; {{ .Year }} Frederic G. MARAND for OSInet
  </footer>
{{end}}

Examinons le source du fichier main.go point par point. Les lignes 1 à 22 contiennent le préambule du programme (package, import) et la définition du type PageData qui sera passé aux templates lors de leur exécution. Rien de particulier sur ce point.

Le programme utilise les templates présents dans le répertoire templates et tous ses sous-répertoires. Il va donc parcourir l’arborescence située sous /templates, et ouvrir les fichiers templates qu’il y trouvera:

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func compileTemplates(dir string) (*template.Template, error) {
	const fun = "compileTemplates"
	tpl := template.New("")
	// Since Walk receives a dynamic value, pkger won't be able to find the
	// actual directory to package from the next line, which is why we used
	// pkger.Include() in main().
	err := pkger.Walk(dir, func(path string, info os.FileInfo, _ error) error {
		// Skip non-templates.
		if info.IsDir() || !strings.HasSuffix(path, ".gohtml") {
			return nil
		}
		// Load file from pkpger virtual file, or real file if pkged.go has not
		// yet been generated, during development.
		f, _ := pkger.Open(path)
		// Now read it.
		sl, _ := ioutil.ReadAll(f)
		// It can now be parsed as a string.
		tpl.Parse(string(sl))
		return nil
	})
	return tpl, err
}
  • ligne 29: le fonctionnement est identique à celui de filepath.Walk: la fonction Walk parcourt l’arborescence et invoque une fonction de rappel walkFn pour chaque fichier et sous-répertoire rencontré. Dans notre exemple, c’est la fonction anonyme des lignes 29-42.
  • lignes 30-33: la fonction ignore les fichiers ne correspondant pas au format des noms de templates attendus.
  • ligne 36, elle ouvre le fichier avec pkger.Open.
  • ligne 37, elle en lit le contenu avec la fonction ioutil.ReadAll du runtime.
  • ligne 40, elle compile la chaîne ainsi obtenu en tant que template Go.

En fin d’exécution, le résultat est un jeu de templates compilés valide, ou une erreur.

Lors de la construction du programme, la commande pkger va identifier la présence de l’appel à pkger.Open et tenter d’en déduire le nom de la ressource à ajouter au bundle. Mais il y a un problème…

En effet, pkger est un analyseur statique, mais l’argument de pkger.Open est une variable et non une constante. De ce fait, sa valeur ne peut pas être déterminée avant l’exécution, et cet appel est donc ignoré. Si le programme se limitait à ce mécanisme, les templates ne seraient pas encodés dans le fichier pkged.go et le programme échouerait à les exécuter.

Il est donc nécessaire d’utiliser un autre moyen pour informer pkger, et ce moyen est la fonction identité pkger.Include, à la ligne 50:

46
47
48
49
50
51
52
func main() {
	const addr = ":8080"

	// Tell pkger that it has to package that directory.
	dir := pkger.Include("/templates")
    // Only compile templates on startup.
	tpl, _ := compileTemplates(dir)

Lorsqu’il rencontre cette fonction lors de l’analyse statique du code, pkger constante que son argument est une chaîne constante, qu’il est donc en mesure d’évaluer. Il va donc parcourir le répertoire indiqué et ajouter les fichiers et répertoires qu’il va y rencontrer. Vérifions le résultat:

$ pkger list
github.com/fgm/pkger_demo
 > github.com/fgm/pkger_demo:/templates
 > github.com/fgm/pkger_demo:/templates/layout
 > github.com/fgm/pkger_demo:/templates/layout/footer.gohtml
 > github.com/fgm/pkger_demo:/templates/page.gohtml

Les templates étant incorporés à l’exécutable, les appels à pkger.Open dans la fonction de rappel vont cette fois réussir et les templates pourront être compilés et renvoyés.

À la différence de la fonction de rappel de Walk, pkger n’a rien pu filtrer: il est donc important avec ce procédé que l’arborescence ne contienne aucun fichier annexe qui ne soit pas destiné à être manipulé à l’exécution, car cela augmenterait inutilement la taille et l’occupation mémoire de l’exécutable.

Une fois le jeu de templates compilé obtenu à la ligne 52, les handlers de requête HTTP peuvent l’utiliser comme si de rien n’était:

54
55
56
57
58
59
60
61
// Now serve pages
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		ctx := PageData{Path: r.URL.Path, Year: time.Now().Local().Year()}
		tpl.ExecuteTemplate(w, "page", ctx)
	})
	log.Printf("Listening on %s", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}

Ressources additionnelles par Mark Bates

  • En résumé, l’API de pkger est idiomatique, calquée sur les paquets io et filepath, alors que celle de packr2 était spécifique ; et elle est sans perte (lossless), préservant les informations fichier alors que celle de packr2 perdait une partie de l’information.

  1. Si l’application est conçue pour accepter des templates modifiables après le déploiement, il est alors nécessaire qu’ils soient présents, et la situation est la même que pour les assets statiques. ↩︎

  2. Il est possible d’obtenir une vue plus détaillée de l’analyse, au format JSON, avec le drapeau associé : pkger list -json ↩︎

  3. Avec un effet qui peut surprendre : il est possible de lancer le fichier compilé depuis n’importe quel répertoire courant et les fichiers sont toujours référencés à leur emplacement dans les sources du projet, et non depuis le répertoire courant, en raison de ces chemins absolus. ↩︎