321 lines
12 KiB
Markdown
321 lines
12 KiB
Markdown
# Webapp-Development in Haskell
|
|
|
|
Step-by-Step-Anleitung, wie man ein neues Projekt mit einer bereits erprobten
|
|
Pipeline erstellt.
|
|
|
|
## Definition der API
|
|
|
|
Erster Schritt ist immer ein wünsch-dir-was bei der Api-Defenition.
|
|
|
|
Die meisten Services haben offensichtliche Anforderungen (Schnittstellen nach
|
|
draußen, Schnittstellen intern, ...). Diese kann man immer sehr gut in einem
|
|
`Request -> Response`-Model erfassen.
|
|
|
|
Um die Anforderungen und Möglichkeiten des jeweiligen Services sauber zu
|
|
erfassen und automatisiert zu prüfen, dummy-implementationen zu bekommen und
|
|
vieles andere mehr, empfiehlt es sich den #[[OpenAPI|openapi-generator]] zu
|
|
nutzen.
|
|
|
|
Diese Definition läuft über openapi-v3 und kann z.b. mit Echtzeit-Vorschau im
|
|
http://editor.swagger.io/ erspielen. Per Default ist der noch auf openapi-v2
|
|
(aka swagger), kann aber auch v3.
|
|
|
|
Nach der Definition, was man am Ende haben möchte, muss man sich entscheiden, in
|
|
welcher Sprache man weiter entwickelt. Ich empfehle aus verschiedenen Gründen
|
|
primär 2 Sprachen: Python-Microservices (weil die ML-Libraries sehr gut sind,
|
|
allerdings Änderungen meist schwer sind und der Code wenig robust - meist nur 1
|
|
API-Endpunkt pro service) und Haskell (Stabilität, Performace, leicht zu ändern,
|
|
gut anzupassen).
|
|
|
|
Im folgenden wird (aus offensichtlichen Gründen) nur auf das Haskell-Projekt eingegangen.
|
|
|
|
## Startprojekt in Haskell
|
|
|
|
### Erstellen eines neuen Projektes
|
|
|
|
Zunächst erstellen wir in normales Haskell-Projekt ohne Funktionalität & Firlefanz:
|
|
|
|
```bash
|
|
stack new myservice
|
|
```
|
|
|
|
Dies erstellt ein neues Verzeichnis und das generelle scaffolding.
|
|
Nach einer kurzen Anpassung der `stack.yaml` (resolver auf unserer setzen;
|
|
aktuell: `lts-17.4`) fügen wir am Ende der Datei
|
|
|
|
```yaml
|
|
allow-newer: true
|
|
ghc-options:
|
|
"$locals": -fwrite-ide-info
|
|
```
|
|
|
|
ein.
|
|
Anschließend organisieren™ wir uns noch eine gute `.gitignore` und initialisieren
|
|
das git mittels `git init; git add .; git commit -m "initial scaffold"`
|
|
|
|
### Generierung der API
|
|
|
|
Da die API immer wieder neu generiert werden kann (und sollte!) liegt sich in
|
|
einem unterverzeichnis des Hauptprojektes.
|
|
|
|
Initial ist es das einfachste ein leeres temporäres Verzeichnis woanders zu
|
|
erstellen, die `api-doc.yml` hinein kopieren und folgendes ausführen:
|
|
|
|
```bash
|
|
openapi-generator generate -g haskell -o . -i api-doc.yml
|
|
```
|
|
|
|
Dieses erstellt einem dann eine komplette library inkl. Datentypen.
|
|
Wichtig: Der Name in der `api-doc` sollte vom Namen des Services (oben `myservice`)
|
|
abweichen - entweder in Casing oder im Namen direkt. Suffixe wie API schneidet
|
|
der Generator hier leider ab.
|
|
(Wieso das ganze? Es entstehen nachher 2 libraries, `foo` & `fooAPI`. Da der
|
|
generator das API abschneidet endet man mit foo & foo und der compiler meckert,
|
|
dass er nicht weiß, welche lib gemeint ist).
|
|
|
|
danach: wie gewohnt `git init; git add .; git commit -m "initial"`. Auf dem
|
|
Server der Wahl (github, gitea, gitlab, ...) nun ein Repository erstellen (am
|
|
Besten: `myserviceAPI` - nach Konvention ist alles auf API endend autogeneriert!)
|
|
und den Anweisungen nach ein remote hinzufügen & pushen.
|
|
|
|
#### Wieder zurück im Haskell-Service
|
|
|
|
In unserem eigentlichen Service müssen wir nun die API einbinden.
|
|
Dazu erstellen wir ein Verzeichnis `libs` (Konvention) und machen ein `git
|
|
submodule add <repository-url> libs/myserviceAPI`
|
|
|
|
Git hat nun die API in das submodul gepackt und wir können das oben erstellte
|
|
temporäre Verzeichnis wieder löschen.
|
|
|
|
Anschließend müssen wir stack noch erklären, dass wir die API da nun liegen
|
|
haben und passen wieder die `stack.yaml` an, indem wir das Verzeichnis unter
|
|
packages hinzufügen.
|
|
|
|
```yaml
|
|
packages:
|
|
- .
|
|
- libs/myserviceAPI # <<
|
|
```
|
|
|
|
Nun können wir in der `package.yaml` (oder `myservice.cabal`, falls kein `hpack`
|
|
verwendet wird) unter den dependencies unsere API hinzufügen (name wie die
|
|
cabal-Datei in `libs/myserviceAPI`).
|
|
|
|
### Einbinden anderer Microservices
|
|
|
|
Funktioniert komplett analog zu dem vorgehen oben (ohne das generieren natürlich
|
|
:grin:).
|
|
`stack.yaml` editieren und zu den packages hinzufügen:
|
|
|
|
```yaml
|
|
packages:
|
|
- .
|
|
- libs/myserviceAPI
|
|
- libs/myCoolMLServiceAPI
|
|
```
|
|
|
|
in der `package.yaml` (oder der cabal) die dependencies hinzufügen und schon
|
|
haben wir die Features zur Verfügung und können gegen diese Services reden.
|
|
|
|
### Entfernen von anderen Technologien/Microservices
|
|
|
|
In git ist das entfernen von Submodules etwas frickelig, daher hier ein
|
|
copy&paste der
|
|
[GitHub-Antwort](https://gist.github.com/myusuf3/7f645819ded92bda6677):
|
|
|
|
```bash
|
|
## Remove the submodule entry from .git/config
|
|
git submodule deinit -f path/to/submodule
|
|
|
|
## Remove the submodule directory from the superproject's .git/modules directory
|
|
rm-rf .git/modules/path/to/submodule
|
|
|
|
## Remove the entry in .gitmodules and remove the submodule directory located at path/to/submodule
|
|
git rm-f path/to/submodule
|
|
```
|
|
|
|
Falls das nicht klappt, gibt es alternative Vorschläge unter dem Link oben.
|
|
|
|
### Woher weiss ich, was wo liegt? Dokumentation? Halloo??
|
|
|
|
Keine Panik. Ein `stack haddock --open` hilft da. Das generiert die
|
|
Dokumentation für alle in der `package.yaml` (oder cabal-file) eingetragenen
|
|
dependencies inkl. aller upstream-dependencies. Man bekommt also eine komplette
|
|
lokale Dokumentation von allem. Geöffnet wird dann die Paket-Startseite inkl.
|
|
der direkten dependencies:
|
|
|
|
Es gibt 2 wichtige Pfade im Browser:
|
|
|
|
- ...../all/index.html - hier sind alle Pakete aufgeführt
|
|
- ...../index.html - hier sind nur die direkten dependencies aufgeführt.
|
|
|
|
Wenn man einen lokalen Webserver startet kann man mittels "s" auch die
|
|
interaktive Suche öffnen (Suche nach Typen, Funktionen, Signaturen, etc.). In
|
|
Bash mit `python3` geht das z.b. einfach über:
|
|
|
|
```bash
|
|
cd $(stack path --local-doc-root)
|
|
python3 -m SimpleHTTPServer 8000
|
|
firefox "http://localhost:8000"
|
|
```
|
|
|
|
### Implementation des Services und Start
|
|
|
|
#### Loader/Bootstrapper
|
|
|
|
Generelles Vorgehen:
|
|
|
|
- in `app/Main.hs`:
|
|
Hier ist quasi immer nur eine Zeile drin: `main = myServiceMain`
|
|
|
|
Grund: Applications tauchen nicht im Haddock auf. Also haben wir ein
|
|
"src"-Modul, welches hier nur geladen & ausgeführt wird.
|
|
- in `src/MyService.hs`:
|
|
`myServiceMain :: IO ()` definieren
|
|
|
|
Für die Main kann man prinzipiell eine Main andere Services copy/pasten. Im
|
|
folgenden eine Annotierte main-Funktion - zu den einzelnen Voraussetzungen
|
|
kommen wir im Anschluss.
|
|
|
|
![[Main.hs#]]
|
|
|
|
#### Weitere Instanzen und Definitionen, die der Generator (noch) nicht macht
|
|
|
|
In der `Myservice.Types` werden ein paar hilfreiche Typen und Typ-Instanzen
|
|
definiert. Im Folgenden geht es dabei um Dinge für:
|
|
|
|
- `Envy`
|
|
- Laden von `$ENV_VAR` in Datentypen
|
|
- Definitionen für Default-Settings
|
|
- `ServerConfig`
|
|
- Definition der Server-Konfiguration & Benennung der Environment-Variablen
|
|
- `ExtraTypes`
|
|
- ggf. Paketweite extra-Typen, die der Generator nicht macht, weil sie nicht
|
|
aus der API kommen (z.B. cache)
|
|
- `Out`/`BSON`-Instanzen
|
|
- Der API-Generator generiert nur wenige Instanzen automatisch (z.B. `aeson`),
|
|
daher werden hier die fehlenden definiert.
|
|
- `BSON`: Kommunikation mit `MongoDB`
|
|
- `Out`: pretty-printing im Log
|
|
- Nur nötig, wenn man pretty-printing via `Out` statt über Generics wie z.b.
|
|
`pretty-generic` oder die automatische Show-Instanz via `prerryShow`
|
|
macht.
|
|
|
|
![[MyService_Types.hs#]]
|
|
|
|
#### Was noch zu tun ist
|
|
|
|
Den Service implementieren. Einfach ein neues Modul aufmachen (z.B.
|
|
`MyService.Handler` oder
|
|
`MyService.DieserEndpunktbereich`/`MyService.JenerEndpunktbereich`) und dort die
|
|
Funktion implementieren, die man in der `Main.hs` benutzt hat.
|
|
In dem Handler habt ihr dann keinen Stress mehr mit Validierung, networking,
|
|
logging, etc. pp. weil alles in der Main abgehandelt wurde und ihr nur noch den
|
|
"Happy-Case" implementieren müsst.
|
|
Beispiel für unseren Handler oben:
|
|
|
|
```haskell
|
|
myApiEndpointV1Post :: MonadIO m => ServerConfig -> (ClientEnv,ClientEnv) -> TQueue BS.ByteString -> ([LogItem] -> IO ()) -> Request -> m Response
|
|
myApiEndpointV1Post sc calls amqPost log req = do
|
|
liftIO . log $ [Info $ "recieved "<>pretty req] -- input-logging
|
|
liftIO . atomically . writeTQueue . LBS.toStrict $ "{\"hey Kibana, i recieved:\"" <> A.encode (pretty req) <> "}" -- log in activeMQ/Kibana
|
|
|
|
|
|
--- .... gaaaanz viel komplizierter code um die Response zu erhalten ;)
|
|
let ret = Response 1337 Nothing -- dummy-response ;)
|
|
-- gegeben wir haben eine gültige mongodb-pipe;
|
|
-- mehr logik will ich in die Beispiele nicht packen.
|
|
-- Man kann die z.b. als weiteren Wert in einer TMVar (damit man sie ändern & updaten kann) an die Funktion übergeben.
|
|
liftIO . access pipe master "DatabaseName" $ do
|
|
ifM (auth (myServiceMongoUsername sc) (myServiceMongoPassword sc)) (return ()) (liftIO . printLog . pure . Error $ "MongoDB: Login failed.")
|
|
save "DatabaseCollection" ["_id" =: 1337, "entry" =: ret] -- selbe id wie oben ;)
|
|
return ret
|
|
```
|
|
|
|
Diese dummy-Antwort führt auf, wie gut man die ganzen Sachen mischen kann.
|
|
|
|
- Logging in die Dateien/`stdout` - je nach Konfiguration
|
|
- Logging von Statistiken in Kibana
|
|
- Speichern der Antwort in der MongoDB
|
|
- Generieren einer Serverantwort und ausliefern dieser über die Schnittstelle
|
|
|
|
#### Tipps & Tricks
|
|
|
|
##### Dateien, die statisch ausgeliefert werden sollen
|
|
|
|
Hierzu erstellt man ein Verzeichnis `static/` (Konvention; ist im generator so
|
|
generiert, dass das ausgeliefert wird). Packt man hier z.b. eine `index.html`
|
|
rein, erscheint die, wenn man den Service ansurft.
|
|
|
|
##### Wie bekomme ich diese fancy Preview hin?
|
|
|
|
Der Editor, der ganz am Anfang zum Einsatz gekommen ist, braucht nur die
|
|
`api-doc.yml` um diese Ansicht zu erzeugen. Daher empfiehlt sich hier ein
|
|
angepasster Fork davon indem die Pfade in der index.html korrigiert sind. Am
|
|
einfachsten (und von den meisten services so benutzt): In meiner Implementation
|
|
liegt dann nach dem starten auf http://localhost:PORT/ui/ und kann direkt dort
|
|
getestet werden.
|
|
|
|
##### Wie sorge ich für bessere Warnungen, damit der Compiler meine Bugs fängt?
|
|
|
|
```bash
|
|
stack build --file-watch --ghc-options '-freverse-errors -W -Wall -Wcompat' --interleaved-output
|
|
```
|
|
|
|
Was tut das?
|
|
|
|
- `--file-watch`: automatisches (minimales) kompilieren bei dateiänderungen
|
|
- `--ghc-options`
|
|
- `-freverse-errors`: Fehlermeldungen in umgekehrter Reihenfolge (Erster
|
|
Fehler ganz unten; wenig scrollen )
|
|
- `-W`: Warnungen an
|
|
- `-Wall`: Alle sinnvollen Warnungen an (im gegensatz zu `-Weverything`, was
|
|
WIRKLICH alles ist )
|
|
- `-Wcompat`: Warnungen für Sachen, die in der nächsten Compilerversion kaputt
|
|
brechen werden & vermieden werden sollten
|
|
- `--interleaved-output`: stack-log direkt ausgeben & nicht in Dateien schreiben
|
|
und die dann am ende zusammen cat\'en.
|
|
|
|
Um pro Datei Warnungen auszuschalten (z.B. weil man ganz sicher weiss, was man
|
|
tut -.-): `{-# OPTIONS_GHC -Wno-whatsoever #-}` als pragma in die Datei.
|
|
|
|
**Idealerweise sollte das Projekt keine Warnungen erzeugen.**
|
|
|
|
### Deployment
|
|
|
|
Als Beispiel sei hier ein einfaches Docker-Build mit Jenkins-CI gezeigt, weil
|
|
ich das aus Gründen rumliegen hatte. Kann man analog in fast alle anderen CI
|
|
übersetzen.
|
|
|
|
#### Docker
|
|
|
|
Die angehängten Scripte gehen von einer Standard-Einrichtung aus (statische
|
|
Sachen in static, 2-3 händische Anpassungen auf das eigene Projekt nach
|
|
auspacken). Nachher liegt dann auch unter static/version die gebaute
|
|
Versionsnummer & kann abgerufen werden. In der `Dockerfile.release` und der
|
|
`Jenkinsfile` müssen noch Anpassungen gemacht werden. Konkret:
|
|
|
|
- in der `Dockerfile.release`: alle `<<<HIER>>>`-Stellen sinnvoll befüllen
|
|
- in der `Jenkinsfile` die defs für "servicename" und "servicebinary" ausfüllen.
|
|
Binary ist das, was bei stack exec aufgerufen wird; name ist der Image-Name
|
|
für das docker-repository.
|
|
|
|
#### Jenkins
|
|
|
|
Änderungen die dann noch gemacht werden müssen:
|
|
|
|
- git-repository URL anpassen
|
|
- Environment-Vars anpassen (\$BRANCH = test & live haben keine zusatzdinger im
|
|
docker-image-repository; ansonsten hat das image \$BRANCH im Namen)
|
|
|
|
Wenn das fertig gebaut ist, liegt im test/live-repository ein docker-image
|
|
namens `servicename:version`.
|
|
|
|
### OMG! Ich muss meine API ändern. Was mache ich nun?
|
|
|
|
1. api-doc.yml bearbeiten, wie gewünscht
|
|
2. mittels generator die Api & submodule neu generieren
|
|
3. ggf. custom Änderungen übernehmen (:Gitdiffsplit hilft)
|
|
4. Alle Compilerfehler + Warnungen in der eigentlichen Applikation fixen
|
|
5. If it comipilez, ship it! (Besser nicht :grin:)
|