Marcel Peters

Architektur einer Go Web App

Veröffentlicht am 22. November 2017

Wenn man mit Java EE eine Web App entwickelt, fragt man sich zwangsläufig ob dies wirklich der Weisheit letzter Schluss sein kann. Zumindest geht es mir so. Mit Spring ist das Leben schon besser geworden, doch flexibel und schlank fühlt sich anders an. Auch mit Django (Python), Node.js oder PHP habe ich bereits Erfahrungen gemacht. Überzeugt haben mich die verschiedenen Sprachen und Frameworks nicht. Zu viele Abhängigkeiten, zu komplex und die Geschwindigkeit ist maximal mäßig.

Vor ein paar Monaten habe ich dann gelesen, die Quelle habe ich leider nicht mehr gefunden, dass Go von Haus aus alles mitbringt, was man für die Entwicklung einer vollwertigen Web App benötigt. Grund genug dem mal nachzugehen. Entstanden ist dabei eine einfache Demo eines Blogs, die aber alle wichtigen technischen Aspekte einer Web App abdeckt und demonstriert. Das Ergebnis habe ich bei BitBucket hochgeladen und kann frei genutzt werden.

git clone https://bitbucket.org/MarcelPeters/go-web-app-demo.git

Es gibt einige Standardaufgaben und Aspekte die den meisten Web Apps gemeinsam sind und diese wollte ich mir näher anschauen.

Das Grundgerüst

Die Idee war also diese Aspekte in Form eines Blogs beispielhaft mit Go zu implementieren. Der Blog selbst besteht lediglich aus einer Index-Seite zur Darstellung der Artikel und einem geschützten Bereich zum Hinzufügen von neuen Beiträgen.

Für die Anwendung habe ich eine Schichtenarchitektur gewählt, angelehnt an das Entwurfsmuster Model-View-Controller (MVC). Diese Konzepte sind leicht verständlich und minimieren Abhängigkeiten. In der Ordnerstruktur spiegeln sich diese Entscheidungen wieder.

_static/
_views/
context/
controller/
model/
web/
config.json
demo.db
main.go

Die Präsentation in Form von Templates und statischen Dateien ist in _static/ bzw. _views/ abgelegt. Controller regeln die Abläufe und enthalten die Geschäftslogik. Sie sind im Go-Package controller abgelegt. Das Datenmodell sowie die Integration der Datenbank ist im Package model enthalten. Der eigentliche Webserver wird durch web realisiert und context übernimmt verschiedene Querschnittsaufgaben. Die Datei main.go ist der zentrale Einstiegspunkt der Anwendung. Als Datenbank verwenden wir SQLite und legen sie in demo.db ab. Und dann gibt es noch eine Konfigurationsdatei config.json.

Die innere Struktur und Abhängigkeiten der Web App sind im nachfolgenden Diagramm noch einmal veranschaulicht.

Bausteinsicht: Innere Struktur und Abhängigkeiten

Unter der Haube

Nach dem Überblick können wir nun tiefer in den Code einsteigen. Ich werde dabei nur auf die wichtigsten Stellen eingehen.

main.go

Diese Datei ist der zentrale Einstiegspunkt und enthält die gute alte main-Funktion. Diese wird aufgerufen, wenn man die Anwendung ausführt. Sie initialisiert den Kontext und startet den Webserver. Der Kontext wird dabei weitergereicht und ist somit während der weiteren Verarbeitung bekannt.

func main() {
	log.Print("Starting Go web app demo")

	env := context.Init()
	web.Start(env)
	env.Release()
}

Kontext

Im Package context sind verschiedene Querschnittsfunktionen und globale Informationen über die Anwendung verortet. In diesem Fall die Konfiguration, der Zugriff auf die Datenbank und das Session-Management. Der Zugriff darauf erfolgt über eine Struktur Env (für Environment), die in env.go definiert wird.

type Env struct {
	config *Config
	db     *sql.DB
	sm     SessionManager
}

Die Funktion Init() liefert uns eine gültige und initialisierte Instanz von Env.

func Init() *Env {
	if env != nil {
		log.Print("Init was already called")
		return env
	}

	config := loadConfig(defaultConfig)
	db := OpenDB(config.DataSourceName)
	sm := NewSessionManager(config.CookieStoreAuthKey, "go-web-app-demo")
	env = &Env{config, db, sm}

	return env
}

Die Konfiguration wird aus einer JSON-Datei geladen (config.go). Der Verbindungsaufbau zur Datenbank ist mit wenigen Zeilen Code erledigt (db.go). Als Treiber für SQLite verwenden wir go-sqlite3 von mattn. Der Treiber muss lediglich importiert werden, im weiteren Verlauf wird dann nur noch sql.DB verwendet. Für das Session-Management verwenden wir ebenfalls eine Bibliothek. Um uns von dieser nicht zu sehr abhängig zu machen, kapseln wir die Funktionalität in einem Interface (session_manager.go).

Webserver & Middleware

Go bringt einen eigenen Webserver mit. Dieser ist sogar für den produktiven Einsatz geeignet. Der Webserver wird in server.go (Package web) konfiguriert und gestartet.

func Start(env *context.Env) {
	router := mux.NewRouter()
	router.PathPrefix("/static/").Handler(
		http.StripPrefix("/static/", http.FileServer(http.Dir("./_static/"))))
	ac := controller.NewAdminController(env)
	ac.Register(router)
	ic := controller.NewIndexController(env)
	ic.Register(router)
	lc := controller.NewLoginController(env)
	lc.Register(router)

	logging := middleware.NewLoggingHandler()
	server := &http.Server{
		Handler:      logging.Apply(router),
		Addr:         ":8080",
		WriteTimeout: 15 * time.Second,
		ReadTimeout:  15 * time.Second,
	}
	log.Fatal(server.ListenAndServe())
}

Für die Middlewares gibt es ein eigenes Subpackage. Im Grunde sind es einfache Closures die sich auf alle oder einzelne Anfragen anwenden lassen. Das nachfolgende Beispiel zeigt ein Closure zum Logging von Anfragen inkl. Dauer und HTTP-Methode (logging.go).

func (lh *LoggingHandler) Apply(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		millis := float64(time.Now().Sub(start).Nanoseconds()) / 1000000
		log.Printf("Serving request %s took %f milliseconds; method=%s",
			r.RequestURI, millis, r.Method)
	})
}

Die Middleware für die Authentifizierung ist ein wenig komplexer (auth.go). Mit Hilfe von SessionManager wird geprüft, ob eine Session existiert und der Benutzer sich bereits erfolgreich authentifiziert hat. Im Erfolgsfall wird die Anfrage ausgeführt und im Fehlerfall wird der Benutzer auf eine Login-Seite weitergeleitet. Des Weiteren gibt es eine Middleware zur Vereinfachung der Fehlerbehandlung (errors.go).

Präsentation

Nun steigen wir die einzelnen Schichten hinab, angefangen mit der Präsentation. Diese besteht aus den in _views/ abgelegten Templates. Es wird die von Go bereitgestellte Template-Engine verwendet. Die Syntax ist etwas ungewöhnlich für meinen Geschmack aber ansonsten bringt sie alles mit was man so benötigt. Um Wiederholungen zu vermeiden werden Layouts definiert (layout.html und admin_layout.html).

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>{{.BlogTitle}}</title>
	<link rel="stylesheet" href="/static/css/w3.css" />
</head>

<body>

...

<div class="w3-content" style="margin-top: 64px; max-width: 800px">
	{{template "body" .}}
</div>

</body>
</html>

Die einzelnen Seiten werden später in ein Layout eingebettet und definieren den body.

{{define "body"}}
...
{{end}}

Die Ausführung der Templates findet im Controller statt.

Controller

Womit wir schon beim richtigen Stichwort sind. Die Controller (Package controller) enthalten die eigentliche Geschäftslogik. Sie sind selbst dafür verantwortlich sich am Router zu registrieren, auf Anfragen zu lauschen und diese zu beantworten. Ein entsprechendes Interface wird vorgegeben.

type Controller interface {
	Register(router *mux.Router)
}

Ein gutes Beispiel für einen konkreten Controller ist der AdminController. Er deckt so ziemlich alles ab, was man bei einer formularbasierten Web App braucht. Nachfolgend ein kurzer Abriss der wichtigsten Punkte.

var adminTemplate = template.Must(template.ParseFiles("_views/admin_layout.html", "_views/add_post.html"))

type AdminController struct {
	env *context.Env
}

type AddPostData struct {
	BlogTitle string
	Message   string
	Errors    map[string]string
	Values    map[string]string
}

func NewAdminController(env *context.Env) *AdminController {
	return &AdminController{env}
}

func (ac *AdminController) genAddPostData() *AddPostData {
	return &AddPostData{
		ac.env.Config().BlogTitle,
		"",
		make(map[string]string),
		make(map[string]string),
	}
}

func (ac *AdminController) addPost(w http.ResponseWriter, r *http.Request) error {
	data := ac.genAddPostData()

	if r.Method == http.MethodPost {
		err := r.ParseForm()
		if err != nil {
			return err
		}

		title := escapedFormValue(r, "title")
		data.Values["title"] = title
		if len(title) < 5 {
			data.Errors["title"] = "Der Titel muss mindestens 5 Zeichen lang sein"
		}
		content := escapedFormValue(r, "content")
		data.Values["content"] = content
		if len(content) < 20 {
			data.Errors["content"] = "Der Inhalt muss mindestens 20 Zeichen lang sein"
		}

		if len(data.Errors) == 0 {
			post := model.NewPost(title, content)
			err = post.Create(ac.env.DB())
			if err != nil {
				return err
			}
			data.Values = make(map[string]string)
			data.Message = "Der Eintrag wurde erfolgreich erstellt!"
		}
	}

	return adminTemplate.Execute(w, data)
}

func (ac *AdminController) dashboard(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/admin/posts/add", http.StatusFound)
}

func (ac *AdminController) Register(router *mux.Router) {
	authHandler := middleware.NewAuthHandler(ac.env.SessionManager(), "/login")
	errorHandler := middleware.NewErrorHandler()
	router.HandleFunc("/admin/",
		authHandler.Apply(ac.dashboard)).
		Methods(http.MethodGet)
	router.HandleFunc("/admin/posts/add",
		authHandler.Apply(errorHandler.Apply(ac.addPost))).
		Methods(http.MethodGet, http.MethodPost)
}

Datenmodell & Persistenz

Die unterste Schicht definiert das Datenmodell und integriert die Datenbank. In Go scheint es eine gute Praxis zu sein jede Entität in einer eigenen Datei zu definieren und die Datenbankoperationen als Methoden auf der Entität hinzuzufügen, zumindest solche die eine Instanz voraussetzen. Auf einen O/R-Mapper wurde verzichtet, er war zumindest in diesem einfachen Fall überflüssig. Eine Instanz von sql.DB wird immer als Parameter in die Methoden übergeben. Das Schreiben von Unit-Tests wird dadurch vereinfacht.

An die Transaktionssicherheit wurde auch gedacht. Die Funktion Transact nimmt die eigentliche Operation als Parameter entgegen und führt sie innerhalb einer Transaktion aus. Eine sehr elegante Lösung wie ich finde, denn die Grenzen der Transaktion sind jederzeit klar, man muss sich nicht wiederholen und es gibt keine Zauberei ala Annotationen, Reflektion oder aspektorientierte Programmierung (AOP).

func Transact(db *sql.DB, txFunc func(*sql.Tx) error) error {
	tx, err := db.Begin()
	if err != nil {
		log.Printf("Beginning transaction failed: %s", err)
		return err
	}
	defer func() {
		if p := recover(); p != nil {
			tx.Rollback()
			panic(p)
		} else if err != nil {
			log.Printf("Rolling back transaction: %s", err)
			tx.Rollback()
		} else {
			err = tx.Commit()
		}
	}()
	err = txFunc(tx)
	return err
}

Der nächste Codeausschnitt zeigt die Verwendung von Transact anhand der Methode zur Erstellung eines neuen Blog-Eintrags. SQL-Injection wird durch Parametrisierung des Statements verhindert.

func (post *Post) Create(db *sql.DB) error {
	return Transact(db, func(tx *sql.Tx) error {
		result, err := db.Exec(
			"INSERT INTO posts(title, content, created_at) VALUES(?, ? ,?)",
			post.Title,
			post.Content,
			post.CreatedAt,
		)
		if err != nil {
			return err
		}
		id, err := result.LastInsertId()
		if err == nil {
			log.Printf("Created post with id: %d", id)
			post.Id = id
		}
		return err
	})
}

Offene Punkte

Vieles wurden angeschnitten, doch es gibt noch zwei Aspekte die nicht berücksichtigt wurden. Sie sind vielleicht nicht für jede Web App relevant, aber ich möchte sie an dieser Stelle nicht unterschlagen.

Die Internationalisierung, also die Unterstützung verschiedener Sprachen und angepasste Formatierung von Zahlen usw., ist eine wichtige Eigenschaften manch einer Web App. Einige Lösungsansätze werden hier und hier diskutiert.

Das Thema Sicherheit muss auch noch weiter gedacht werden. Wie man Eingaben validiert und Code-Injection verhindert wurde bereits angeschnitten. Darüber hinaus sollten natürlich sensible Daten, insbesondere Passwörter, verschlüsselt abgelegt und übertragen werden. Das Package crypto kann dabei behilflich sein. Auch gegen CSRF-Attacken kann man seine Anwendung bzw. deren Nutzer einfach und effektiv schützen.

Demo

Das waren jetzt ziemlich viele Details. Zeit die Web App einem praktischen Test zu unterziehen. Ich verwende als Entwicklungsumgebung LiteIDE X unter Ubuntu, aber dank der von Go vorgegebenen Projektstruktur sollte sich das Projekt auch mit anderen IDEs und Betriebssystemen auf Anhieb kompilieren lassen. Für alle Ubuntu-Nutzer haben ich unten ein Skript beigefügt, dass alle notwendigen Schritte enthält um die Demo auf einer frischen Installation zum Fliegen zu bringen.

sudo apt-get install golang-go
mkdir $HOME/Go
cd $HOME/Go
export GOPATH=$HOME/Go
mkdir src
cd src/
git clone https://bitbucket.org/MarcelPeters/go-web-app-demo.git
cd go-web-app-demo/
go get
go build
./go-web-app-demo

Die Web App kann jetzt unter http://localhost:8080/ im Browser aufgerufen werden.

Screenshot: Go Web App Demo

Betrieb

Eine Web App zu entwickeln und lokal auszuführen ist ein Thema. Einen stabilen Betrieb sicherzustellen ist jedoch wesentlich komplexer. Hier sind ein paar Gedanken wie man dies für eine Go Web App mit der vorgestellten Architektur bewerkstelligen könnte.

Ausführung als Dienst

Nach einem Neustart sollte die Anwendung automatisch gestartet werden. Unter Linux sind die klassischen Werkzeuge dafür Upstart oder systemd. Man könnte auch noch einen Schritt weiter gehen und Supervisor zur Prozessverwaltung einsetzen. Dieses kann auch Abstürze der Anwendung erkennen und startet sie automatisch neu.

Deployment

Beim Deployment der Anwendung sehe ich drei Möglichkeiten.

  1. Docker-Image
  2. Kompilieren auf dem Zielsystem
  3. Archiv mit Binärdatei und Ressourcen

Erstere Option wäre meiner Meinung nach Overkill in diesem Fall (Disclaimer: Ich bin absolut kein Fan von Docker). Die zweite Option wäre relativ leicht zu automatisieren, wie schon das obige Skript zeigt. Man muss sich dann aber Gedanken um das Management der Abhängigkeiten machen, ansonsten kann es zu Überraschungen kommen. Ich tendiere daher zur dritten Option. Ein Skript, welches den Code kompiliert und die Binärdatei zusammen mit den Ressourcen in ein Archiv packt, ist schnell geschrieben. Dieses muss dann nur noch verteilt und entpackt werden.

Ein weiterer Punkt ist die Migration der Datenbank. Entweder man führt die Skripte manuell aus oder nutzt Tools um diese Aufgabe zu automatisieren. Einige Benutzer scheinen mit goose positive Erfahrungen gemacht zu haben.

Logging

In der Demo werden die Logmeldungen auf die Konsole ausgegeben, besser wäre es jedoch sie in eine Datei zu schreiben. Das könnte man programmatisch lösen oder die Ausgaben per Befehl einfach in eine Datei umleiten.

Monitoring

Jedem Tierchen sein Pläsierchen.

Plattform

Entweder man nimmt einen virtuellen Server mit Linux oder nutzt Dienste wie Google App Engine oder AWS Elastic Beanstalk. Ein virtueller Server ist meiner Erfahrung nach zwar mehr Arbeit, jedoch etwas günstiger.

Skalierung

Je nach Infrastruktur bzw. Cloud-Anbieter führt ein anderer Weg nach Rom. Ein paar generelle Gedanken dazu im Kontext der vorgestellten Architektur.

SSL-Verschlüsselung

Sicherheit ist heute nicht mehr optional. Paradigmen wie das der Progressive Web App sehen sogar eine ausschließliche Nutzung von HTTPS vor. Eine Möglichkeit wäre nginx als Proxy einzusetzen und die SSL-Zertifikate dort zu terminieren. Oder man setzt auf die Dienste der Cloud-Anbieter wie beispielweise Amazon CloudFront.

#####

Für mich als Go-Anfänger war es schon recht aufwändig diese Architektur und Demo zu entwickeln. Dabei hat mich jedoch die Schlichheit manch einer Lösung (z. B. der Middlewares) beeindruckt. Auch war es möglich eine Web App fast ohne externe Abhängigkeiten zu entwickeln. Das ist nur mit wenigen Programmiersprachen möglich, ohne dass man das Gefühl bekommt das Rad neu zu erfinden. Trotz des Aufwands habe ich langsam eine Ahnung, warum Go als eine sehr produktive Sprache angepriesen wird. Nach dieser Erfahrung wäre ich nicht abgeneigt auch mal ein echte Web App mit Go zu entwickeln.