Single-Executable Web Apps Maken met Go
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 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:
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!