Single-Executable Web Apps Maken met Go

Luca Techniek

Het zijn twee begrippen die eigenlijk niet zonder elkaar kunnen: een API en een web app. Toch zijn dit hele andere beesten als het gaat om productie apps en deployments. Hoewel het in de praktijk niet moeilijk om aantal services te deployen om te zorgen dat de API en web app allemaal online staan, kan het potentieel nog makkelijker met een single-executable setup. Rest de vraag, is deze vereenvoudiging het waard?

Voordat we in de code en voorbeelden duiken, laten we even kort de begrippen samenvatten waar we het hier precies over hebben. Allereerst hebben we een API, dit is vaak een op zichzelf staande server die HTTP requests afhandelt, en bijvoorbeeld JSON teruggeeft. Een web app daarentegen is gewoon een berg bestanden met HTML, CSS en JavaScript die uit zichzelf helemaal niks doen. Als je een cloud applicatie deployt zullen deze bestanden dan ook geserveerd moeten worden door een andere server, ofwel ook door de API server, ofwel door een proxy als Traefik of nginx.

Enter: Go

Een van mijn favoriete programmeertalen op het moment is Go. Als je net als ik gewend bent aan TypeScript en Java is de syntax af en toe een beetje ongebruikelijk, maar de taal heeft hele coole features. Een van die features is het kunnen embedden van statische bestanden in de gebouwde executable, wat mogelijk is sinds Go 1.16. Zo zou je een bestand hello.txt kunnen embedden in een string:

package main

import (
    _ "embed"
    "fmt"
)

//go:embed hello.txt
var greeting string

func main() {
    fmt.Println(greeting)
}

Wanneer je nu deze code runt, zal de text in het hello.txt bestand geprint worden:

De IDE die main.go runt, met als output "Hello from hello.txt"

De echte kracht van embed komt echter wanneer je meerdere bestanden embed door middel van een embedded filesystem. Zo kan je met wildcards meerdere bestanden of directories embedden. Dit voorbeeld embed alle bestanden uit de static en web mappen:

package main

import "embed"

//go:embed static/* web/*
var content embed.FS

Dit embedded filesystem kan nu gebruikt worden als ware het een “normaal” filesystem, je kan er bestanden uit lezen, directory listings opvragen, etc. Het grote verschil is dat dit een read-only filesystem is, aangezien het ingebakken zit in het programma.

Go + Web App = 🚀

Laten we voor dit voorbeeld uitgaan van een simpele web app. Uiteraard kan je dit ook integreren met frameworks zoals React of Vue, maar dat laat ik als een oefening voor de lezer. Hier hebben we een simpel stukje HTML en JavaScript wat het /api/hello endpoint aanroept, en het resultaat daarvan in een element zet:

<html>
<body>

<p>Hier komt een resultaat uit de API:</p>
<p id="api-result">Laden...</p>

<script>
    const apiResultElem = document.getElementById('api-result');
    fetch('/api/hello')
        .then(res => res.text())
        .then(text => apiResultElem.innerText = text);
</script>

</body>
</html>

Vervolgens hebben we ook wat Go code nodig om dat endpoint te maken, en om de statische bestanden te serveren. Dit is allebei eenvoudig te doen met de standaard library:

package main

import (
    "blogtest/web"
    _ "embed"
    "net/http"
)

func main() {
    server := http.NewServeMux()

    // Serve the static web files.
    server.Handle("/", http.FileServer(http.FS(web.Web)))

    // The hello endpoint, which simply responds with some text.
    server.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello from the hello endpoint!"))
    })

    // Start the server.
    err := http.ListenAndServe(":8080", server)
    if err != nil {
        panic(err)
    }
}

Hier maken we als eerste een HTTP server. Vervolgens registreren we 2 handlers, een voor de statische bestanden op / en een voor de /api/hello endpoint. Tot slot starten we de server op poort 8080 en kijken we of alles goed is gegaan. Misschien is het je al opgevallen door naar de code te kijken, maar er mist hier nog een deel! We verwijzen naar web.Web voor de statische file handler, maar deze staat niet in dit bestand. Deze staat namelijk in een los bestand in de web map, naast de web app bestanden:

package web

import "embed"

//go:embed *
var Web embed.FS

Door de Web variabele in een apart bestand te definiëren bevat deze een filesysteem met alle bestanden in de desbetreffende map. Al met al ziet het project er nu zo uit:

.
├ main.go
└ web
  ├ index.html
  └ web.go

De web/index.html is onze simpele web app, web/web.go bevat de embedded filesystem voor alles in de web map, en main.go bevat de server voor de API en statische bestanden.

Als we nu onze code runnen en naar http://localhost:8080 gaan in de browser krijgen we onze web app geserveerd, en kan deze een API call maken:

Een browser die localhost weergeeft met de tekst "Hello from the hello endpoint!"

Als deze code gecompileerd en gedistribueerd wordt is het resultaat (de web app en API) op alle systemen exact hetzelfde, en dat met slechts een enkele executable!

Alles Single-Executable?

Wordt dit nu de manier om alle (cloud) applicaties te bouwen? In het kort: nee. Op het moment dat je aan een cloud app werkt verschuif je hiermee voornamelijk de complexiteit van de deployment naar de build. Bovendien kan de scheiding tussen API en web app juist handig zijn wanneer je een van de twee moet schalen voor grootschalige deployments, of wanneer je je statische assets door een CDN wil laten serveren.

Dit betekent echter niet dat single-executable apps helemaal geen voordelen hebben. Allereerst maakt deze aanpak het extreem eenvoudig om een app te bouwen die je op een lokale computer kan draaien. Geen gedoe meer met hele mappen die je moet uitpakken en bestanden die op de goede plek moeten staan, gewoon lekker simpel een “.exe” bestand. Verder kan het handig zijn voor kleine (hobby-)projecten waar een grootschalige deployment het niet waard is. Dit maakt de deployment simpeler, en kan daardoor ook in cloud kosten schelen!

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of geef een belletje.