Now Loading ...
-
-
OKD Cluster bei Hetzner
OKD4 wird langsam beliebt. Wer kein Geld für einen vollwertigen OpenShift Cluster hat, hat hier eine Chance auf den Service. Für VMs auf Basis von oVirt gibt es auf GitHub einige Projekte, die Installationen durchführen. Wie man aber einen Cluster auf echten Maschinen bei Hetzner aufbaut, muss man sich zusammensuchen.
Ich habe bis for ein paar Tagen einen OKD 3.11 Cluster als Single-Node-Cluster auf einem Node laufen gehabt. 64GB Hauptspeicher, 16 Cores. Da ich sowieso auf OKD 4 wechseln wollte und die Leistung am Ende war, habe ich mir jetzt drei Server mit jeweils 12 Cores und 64 GB Hauptspeicher geholt. Platten sind jeweils 500 GB enthalten. Als Loadblancer habe ich mir auf der Serverbörse zwei kleine Maschinen geschossen, die jeweils per haproxy auf den Cluster loadblancen. Außerdem werde ich dort ein paar weitere Dienste installieren, die nicht in Kubernetes laufen oder ich nicht dort haben will.
Außerdem habe ich mir temporär eine weitere Maschine als bootstrap-Maschine geholt - ich wollte eigentlich eine der kleineren Maschinen dazu nutzen, aber leider gab es da Netzwerkprobleme, da die entsprechende Netzwerkkarte von Fedora CoreOS nicht unterstützt wurde.
Ich hatte zwei Probleme:
Die Maschinen kamen alle als "localhost" hoch. Ich habe das dann so gelöst, dass ich per Ignition-File den einzelnen Hosts ihren Namen vorgegeben habe. Hat nur nichts gebracht. Also habe ich - sobald der Host hochkam - mit "hostnamectl" den Namen gesetzt und neu gestartet. Nachdem ich mit allen 3 Nodes durch war, musste ich nur noch per "oc delete node localhost" den blöden Zusatzeintrag loswerden.
Ich habe zwei Rechner im gleichen Subnetz bekommen. Dummerweise denken die dann, sie wären direkt über das Netz erreichbar, Hetzner verbietet aber natürlich direkte Kommunikation. Da ich mich mit Fedora CoreOS nicht ganz so gut auskenne und es nicht hinbekommen habe, diese Route zu ersetzen, habe ich mittels systemd-Timer einen Service aufgesetzt, der auf den Nodes alle 2 Sekunden die per DHCP gesetzte Route lösche und eine direkte Route zum Gateway setze.
Der Netzwerk-Workaround ist doof, aber die übliche dokumentierte Lösung über <interface>.connection-Datei hat bei mir nicht gegriffen.
-
Logging in Java II
Vor über 4 Jahren habe ich bereits im Beitrag Logging in Java ein paar grundlegende Gedanken zum Thema Logging geäußert. Dabei bin ich recht unspezifisch geblieben und habe nur ein paar grundlegende Ideen geäußert. Diesmal will ich etwas konkreter werden.
Erstmal wie man Logifles schreibt. Hier nutze ich inzwischen immer Slf4j. Mit dieser Fassade kann das eigentliche Logging über alle Logframeworks geleitet werden, ohne dass Änderungen am Quellcode notwendig werden. Es ist ein No-Brainer. Alles Logging über Slf4j, keine Diskussion darüber. Jede Klasse bekommt entweder einen statischen Logger (z.B über die Templating-Funktion der genutzten IDE bei der Erstellung des Files) oder - deutlich eleganter - mittels Der Annotation @Slf4j via lombok. Eigentlich ist es egal, aber ich sage wieder: nutzt lombok. Punkt.
Nachdem das geklärt ist, wird es jetzt etwas diffiziler. Hier kommt jetzt das Fingerspitzengefühl. Ich orientiere mich an den üblichen Logleveln, die via Slf4j addressiert werden können und dann auf die entsprechenden Loglevel der Frameworks verteilt werden:
Fehlerlevel
Beschreibung
Auswirkung auf 24/7 Betrieb
Error
Ein technischer Fehler ist passiert. Das System könnte dauerhaft gestört sein und ein Administrator sollte darauf reagieren.
Hier ist ein Bereitschaftsfall gegeben.
Warn
Ein technischer Fehler ist passiert. Ein einzelner Aufruf oder Batch wurde gestört und ein Administrator sollte sich das einmal anschauen.
Hier ist keine sofortige Reaktion notwendig. Es reicht, dass sich ein Admin das zu normalen Arbeitszeiten anschaut.
Info
Ein einzelner Aufrag oder Batch wurde bearbeitet. Auch fachliche Fehler werden auf diesem Level protokolliert. Wenn die Aufruffrequenz es zulässt, kann z.B. der Start und das Ende jedes Systemaufrufs protokolliert werden, sollte das Logvolumen dadurch zu hoch sein, wird nur das Ende quitiert.
Kein Fehler. Das Logvolumen sollte möglichst gering sein und nur die eigentlichen Transaktionen sichtbar sein.
Debug
Der Ablauf eines Auftrags oder Batches wird sichtbar. Mindestens der Aufruf eines Services wird zusätzlich zum Ende protokolliert (wenn es nicht sowieso schon auf Info-Level passiert).
Wenn ein System auf Fehlverhalten geprüft wird, sollten die Debug-Meldungen dem 2nd-Level-Support helfen, den Systemstatus zu erfassen und möglicht den Fehler zu analysieren und zu beheben. Das Logvolumen ist deutlich erhöht.
Trace
Der Ablauf eines Auftrags oder Batches wird detailiert protokolliert.
Hier kann der komplette Ablauf detailiert verfolgt werden. Es handelt sich um massives Logging, das dem 3rd-Level oder Lieferanten des Systems eine vollständige Analyse ermöglicht.
Natürlich sind diese "Regeln" abhängig von der Erfahrung, man muss lernen, zu antizipieren, was dem Betrieb hilft. Im Gegensatz zu meinem Artikel von 2017 bin ich inzwischen nicht mehr der Meinung, dass der Softwareentwickler selbst ein Addressat des Loggings ist. Als Entwickler nutze ich den Debugger bei der Entwicklung, das Logfile gehört dem Betrieb.
Sollte ein fachliches Logging gebraucht werden, kann man einen zusätzlichen Logger zusätlich definieren (z.B. als "bussiness" oder "bus" - dort können dann die fachlichen Logfiles geschrieben werden. Hier fällt normalerweise deutlich weniger Logfile an und es werden meist nur die Level Error, Info und Debug benötigt.
-
GitHub Runner in OpenShift/kubernetes
Nachdem wir uns im letzten Teil das quay.io-Container-Repository aufgesetzt haben Das Aufsetzen der OpenShift basierten GitHub Action Runner ist der letzte Schritt, den wir noch brauchen, um die CI-Pipeline fertig zu haben. Und darum kümmern wir uns in diesem Artikel.
Ich gebe zu, ich habe es mir eigentlich recht bequem gemacht, denn hier haben schon meine Kollegen von Red Hat sehr viel vorbereitet. Es gibt ein Helm-Chart und selbst einige Runner haben sie schon im Angebot. Nur beim Java-Runner musste ich etwas nacharbeiten, da ich für Vaadin neben dem Java 11 auch noch node.js im gleichen Runner gebraucht habe (Vaadin kompiliert den Frontendteil zusammen mit dem Java-Anteil). Aber dazu kommen wir später.
Als erstes brauche ich ein Projekt in meiner OpenShift-Umgebung (nunja, ich nutze hier meine OKD-Installation, die noch immer in Version 3.11 läuft, aber wenn Ihr ein OpenShift 4 habt, sollte es nicht viel anders sein).
$ oc new-project github-runner
Now using project "github-runner" on server "https://console.das.wuesstet.ihr.wohl.gerne:8443".
You can add applications to this project with the 'new-app' command. For example, try:
oc new-app django-psql-example
to build a new example application in Python. Or use kubectl to deploy a simple Kubernetes application:
kubectl create deployment hello-node --image=gcr.io/hello-minikube-zero-install/hello-node
$
Natürlich muss man dazu eingeloggt sein. Aber wie das geht, wisst Ihr bei eurem Cluster selbst.
Da builda-sa mit erhöhten Rechten laufen muss, erledige ich das hier gleich. Immer daran denken, dass dies natürlich ein Bruch der Security-Sandbox ist. Ob ihr dazu bereit seid, müsst Ihr selbst abwägen.
$ oc create -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: buildah-sa
EOF
$ oc adm policy add-scc-to-user privileged -z buildah-sa
securitycontextconstraints.security.openshift.io/privileged added to: ["system:serviceaccount:github-runner:buildah-sa"]
$
Jetzt haben wir den privilegierten Benutzer, damit buildah seine Arbeit verrichten kann. Allerdings brauchen wir noch ein GitHub PAT (personal access token) - ich verweise aus Faulheit auf die recht gute Dokumentation seitens GitHub mit der Anmerkung, dass wir mindestens die Berechtigung repo benötigt. Dieses Token und den Eigentümer des GitHub-Repositories schreiben wir uns mal in Umgebungsvariablen, denn wir werden sie öfter benötigen:
$ export GITHUB_PAT=<Personal Access Token>
$ export GITHUB_OWNER=<Eigentümer eures Repositories>
$ export GITHUB_REPO=<Repository>
Den letzten Eintrag braucht Ihr nur, wenn ihr den Runner an ein bestimmtes Repository binden wollt. Wenn Ihr es euch für mehrere Projekte einfach machen wollt, legt bei GitHub eine Organisation an und bindet die Runner an die Organisation - dann könnt ihr die Repos innerhalb der Organisation mit den gleichen Runnern glücklich machen und müsst nicht jedes Repository bei GitHub einzeln mit Runnern bespaßen.
Wir brauchen natürlich das Helm-Chart, um die Runner zu installieren. Das bekommen wir auch von GitHub:
$ helm repo add openshift-actions-runner https://redhat-actions.github.io/openshift-actions-runner-chart
$
Jetzt kann der Spaß losgehen:
$ helm upgrade --install buildah-runner openshift-actions-runner/actions-runner --set-string githubPat=$GITHUB_PAT --set-string githubOwner=$GITHUB_OWNER --set-string runnerImage=quay.io/redhat-github-actions/buildah-runner --set-string privileged=true --set runnerLabels="{podman,buildah}"
$ helm upgrade --install java-runner-11 openshift-actions-runner/actions-runner --set-string githubPat=$GITHUB_PAT --set-string githubOwner=$GITHUB_OWNER --set-string runnerImage=quay.io/klenkes74/java-runner-with-maven --set-string runnerTag=latest --set runnerLabels="{java,java-11,maven,gradle,ant,ivy}"
$
Ihr könnt auch gerne noch die anderen Runner der Red Hat Community of Practice installieren - es gibt noch einen allgemeinen Runner, einen Runner für node.js und einen k8s-tools-runner, damit Ihr per GitHub-Action auch direkt euren Cluster konfigurieren könnt. Aber für unsere Anwendung benötigen wir nur den Java- und den buildah/podman-Runner.
Dem aufmerksamen Leser wird auch noch etwas am java-runner-11 aufgefallen sein. Er lädt kein Image von quay.io/redhat-github-actions (wo es auch einen java-11-runner gibt), sondern einen von mir bereitgestellten Runner von quay.io/klenkes74/java-runner-with-maven. Das liegt daran, dass der normale Runner kein Maven beinhaltet. Und weil ich gerade dabei war, habe ich noch gradle, ant und ivy und - wie oben schon erwähnt - node.js mit reingeworfen. Wider erwarten findet er auch bereits bei mir unbekannten Projekten innerhalb von AWS Anwendung - wie ich durch die quay.io-Statistiken erfahren durfte. Wer sich für den Runner und seinen Bau interessiert, kann sich das auf https://github.com/klenkes74/java-runner-with-maven anschauen. Vielleicht schreibe ich da auch einen Blog drüber - aber eigentlich ist das nur ein Dockerfile, dass diverse Sachen nachinstalliert.
Und jetzt sollten die Pods laufen und sich zu Github verbinden. Auf OpenShift-Seite sieht es ungefähr so aus:
$ oc get pod
NAME READY STATUS RESTARTS AGE
buildah-runner-774f989c86-sc96s 1/1 Running 0 13d
java-runner-11-79f5c4f49d-rlmjt 1/1 Running 0 20h
$
Und auf Github findet man es unter den Settings (bei mir natürlich bei der Organisation und nicht im Repository):
Voilá, die Github-Runner laufen und verrichten ihre Arbeit. Ihr könnt euch auch über die OKD-Console den Output anschauen:
Jetzt haben wir alles zusammen. Ein Push in die konfigurierten Branches (bei mir development) wird den Workflow CI auslösen und am Schluss liegt dann - wenn alles funktionierte - das Container Image im quay.io-Repository.
Inzwischen habe ich im Projekt noch CodeQL-Checks hinzugefügt und einen zweiten Workflow für Releases auf den main-Branch gesetzt. Aber das ist dann nochmal ein neues Thema.
Ich wünsche allen viel Spaß mit Github-Actions ohne dabei auf die Minuten schauen zu müssen.
-
Aufsetzen des quay.io-Repositories
In diesem Teil der Artikelserie befassen wir uns, nachdem das GitHub-Source-Repository existert, mit dem quay.io-Repository und dem Einrichten eines Robot-Benutzers für die Nutzung durch GitHub Actions.
Als erstes braucht man natürlich einen Account auf quay.io. Wie das geht, findet Ihr ganz leicht selbst heraus. Im April ändert sich auch etwas, quay.io wird in das SSO-System von Red Hat eingebunden. Daher macht es keinen Sinn, die notwendigen Schritte hier zu erklären.
Das Anlegen eines neuen Repositories ist recht einfach:
Anlegen eines neuen Repositories in quay.io
Entweder man klickt einfach im Bildschirm auf das "+ Create New Repository" oder öffnet mit dem "+" oben das Menü und wählt dann "New Repository".
Im folgenden Screen kann man dann den Namen des Repositories wählen Den typ lässt man unverändert, wir wollen ja ein Container Image Repository. Außerdem ist es extrem wichtig, es auf öffentlich zu schalten!
Der Größte Teil ist damit schon erledigt. Jetzt brauchen wir noch einen Benutzer für GitHub und müssen ihm Zugriff auf dieses Repository erteilen. Dazu klicken wir auf unseren Usernamen:
Und in den sich öffenen Settings legen wir in den "Robot Accounts" durch das "+ Create Robot Account" einen neuen Account an.
Es öffnet sich ein Requestor, der nach Accountnamen und einer Beschreibung fragt:
Anlegen eines neuen Roboter-Accounts
Sobald man auf "Create robot account" klickt, öffnet sich die Seite, auf der man diesem Account die Berechtigung auf Repositories erteilen kann:
Einfach die gewünschten Repositories mit "Write"-Rechten ausstatten und der Account wird funktionieren. Auf den neuen Button "Add permissions" klicken (der Button erscheint erst, wenn man auch eine entsprechende Permission ausgewählt hat). Und der Account wurde angelegt und taucht in der Liste der Robot Accounts auf. Den Token kann man einsehen, in dem man in der Liste das Zahnrad ("Settings") und dann dort "View Credentials" auswählt:
Es öffnet sich wieder ein Requester und man sieht gleich den notwendigen Username und das Token für die GitHub-Secrets (siehe den letzten Artikel dieser Serie, wie er als Secret in GitHub hinterlegt werden kann).
Damit ist quay.io vorbereitet.
-
Aufsetzen des GitHub-Repositories
In diesem Teil der Artikelserie befassen wir uns mit dem GitHub-Repository und natürlich dem Workflow für die GitHub Actions.
Außerdem verliere ich ein paar Worte über den Maven-Build und die Integration des Helmcharts in diesen Build.
Als erstes braucht man natürlich einen Account auf GitHub. Ich gehe aber mal davon aus, dass diese Hürde bereits genommen ist und in der Softwareindustrie eigentlich jeder schon über einen GitHub-Account verfügt. Aber selbst wenn dies nicht der Fall sein sollte, ist es ganz leicht, dazu braucht ihr meine Hilfe nicht.
Auch das Anlegen eines neuen Repositories ist ganz einfach.
Anlegen eines neuen Repositories. Auf das "+" klicken und dann "New repository" anwählen.
Danach öffnet sich eine neue Seite und man kann den gewünschten Namen eingaben. Hier kann man auch seine Lizenz aussuchen oder es auch sein lassen.
Der Software-Build mit Maven
Danach hat man sein Repository und kann die Software dorthin installieren. Ich benutze mein Repository unter https://github.com/Paladins-Inn/delphi-council als Beispiel und gehe damit von einem Maven-Build (gesteuert durch die Datei pom.xml im Hauptverzeichnis) aus. Es handelt sich um eine Spring Boot Anwendung. Außerdem nutze ich Maven, um ein paar Parameter im Helm-Chart zu ersetzen. Die Sourcen des Helm-Charts leben unterhalb von ./src/main/helm und werden durch die Maven-Konfiguration nach ./target/helm installiert:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
...
<build>
...
<resources>
...
<resource>
<directory>src/main/helm</directory>
<targetPath>../helm</targetPath>
<filtering>true</filtering>
</resource>
...
</resources>
...
</build>
...
</project>
Da es sich um ein Spring-Boot-Projekt handelt, muss man aufpassen. Normalerweise ersetzt Maven Variablen im Format "${VARIABLENNAME}" - Spring-Boot Starter definieren es um und man muss die Notation "@VARIABLENNAME@" nutzen. Der targetPath oben ist wichtig, da für das Maven Resource-Plugin der Standard-Ausgabepfad ./target/classes ist. Mit dem <targetPath>../helm</targetPath> wird daraus ./target/helm.
Ich nutze die Ersetzung vor allem, um die aktuelle Versionsnummer in die Chart.yaml zu bekommen. Das gleiche Spielchen mache ich übrigens auch mit dem Dockerfile, dass hier unter ./src/main/docker liegt:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
...
<build>
...
<resources>
...
<resource>
<directory>src/main/docker</directory>
<filtering>true</filtering>
</resource>
...
</resources>
...
</build>
...
</project>
Hier akzeptiere ich jedoch, dass das Dockerfile nach ./target/classes kommt. Damit habe ich das Dockerfile auch im jar-Archiv und damit weiß jeder, wie die Software gebaut wurde. Das gefällt mir persönlich. Aber Ihr könnt gerne auch einen anderen targetPath konfigurieren. Bleibt aber unterhalb von ./target, damit mvn clean alles automatisch wegräumt.
Der Rest des Maven Buildfiles befasst sich mit dem Softwarebuild, der neben dem Javabuild auch einen npm-basierten Build für das Frontend umfasst. Das kommt aber so mit dem von mir verwendeten Framework Vaadin und eine nähere Besprechung würde endgültig den Rahmen dieser Artikelserie sprengen. Ihr könnt aber die Entwicklung der Software in meinen Live-Codings auf Twitch (und später auf Youtube) gerne verfolgen.
Auch die Software-Tests sollen hier während des Maven-Builds stattfinden, sodass wir uns beim Workflow nicht weiter darum kümmern müssen.
Der GitHub Workflow
Und zum Workflow kommen wir jetzt. Er versteckt sich in der Datei ./github/workflows/ci.yml. Natürlich kann man mehrere Workflows haben. Einfach eine weitere Datei daneben legen und es sind schon zwei Workflows. Aber wir werden uns die vorhande Datei mal von Oben nach unten anschauen.
## This is basic continuous integration build for your Quarkus application.
name: CI
on:
push:
branches: [ main ]
jobs:
Der Kopf der Datei. Hier definieren wir den Namen des Workflows, wie er uns später auch von GitHub angezeigt wird.
Außerdem definieren wir, wann der Workflow ausgeführt werden soll. Hier soll er bei jedem Push im Branch main ausgeführt werden. Damit ist dies eigentlich kein CI-Workflow mehr sondern eher ein Release-Workflow. Aber wenn man unter branches noch development eintragen würde, wäre es wieder ein CI-Workflow, ignorieren wir diese Information also erstmal. Denn jetzt kommen die Definitionen der jobs. Jobs sind die Schritte im Workflow, die jeweils einem Runner zugewiesen werden können. Dazu nutzt man den Parameter runs-on und definiert dann die einzelnen Schritte (steps), die auf diesem Runner ausgeführt werden sollen:
jobs:
java-build:
runs-on: [ java ]
steps:
...
container-build:
runs-on: [ podman ]
needs: java-build
steps:
...
Dieser Workflow hat also zwei Schritte (java-build und container-build). Diese habe ich so gewählt, da ich einen runner für java (mit maven, gradle und wie oben beschrieben auch node.js) und einen Runner für buildah und podman (für Containerbuilds und Management) habe und diese so den verschiedenen Runnern zuweisen kann.
Und mittels des needs-Eintrags sorge ich dafür, dass cer container-build nicht parallel startet sondern erst nach dem java-build, da der Container natürlich die Software benötigt, die dort gebaut wird.
Der Java-Build-Teil des Workflows
Schauen wir uns den Java-Build an, zerfällt er wieder in drei Teile. Zuerst wird der Build vorbereitet, indem die Sourcen aus git ausgecheckt werden (mit der Aktion actions/checkout@v2) und die Java-Umgebung wird vorbereitet (Aktion actions/setup-java@v1). Die einzelnen Steps haben Namen und Ids. Welche Aktion genutzt wird, wird per uses definiert und mittels with werden die Aktionen mit Parametern versehen. So wähle ich per java-version hier Java11 aus.
jobs:
java-build:
runs-on: [ java ]
steps:
- name: Checkout sources
id: checkout-sources
uses: actions/checkout@v2
- name: Set up JDK 11
id: setup-java
uses: actions/setup-java@v1
with:
java-version: 11
Nachdem die Umgebung vorbereitet ist, können wir jetzt den Java-Build starten. Hierzu rufen wir Maven auf:
- name: Build
id: build-java
run: mvn package -B -Pproduction
Damit ist der Maven-Build gelaufen und unsere Artifakte liegen alle unterhalb des Verzeichnisses ./target. Ja, durch die oben beschriebenen Änderungen am pom.xml, nutzen wir drei Artifakte:
Das jar-File mit der Anwendung ./target/delphi-council-is-@project.version@.jar (um in der Spring-Schreibweise zu bleiben)
Das Dockerfile ./target/classes/Dockerfile
Das Helmchart ./target/helm/delphi-council-is
Die Versionsnummer will ich nur an einer Stelle pflegen: im Maven-Buildfile unter project->version. Daher habe ich ja die Resourcen definiert, die diese Version überall hin ersetzen. Und die Version brauche ich mindestens für das Sichern der Artefakte auf GitHub. Daher extrahiere ich den APP_NAME und die APP_VERSION aus dem Dockerfile und merke mir die Variablen als IMAGE und VERSION in den $GITHUB_ENV (diese werden bei jedem step ins Environment geschrieben und machen diese als ${{ env.IMAGE }} und ${{ env.VERSION }} für die steps verfügbar. Ich gebe diese Variablen als Information aus und nutze sie zum Upload nach GitHub. Hierzu nutze ich die Aktion actions/upload-artifact@v2 mit der Liste der Dateien, die zu sichern sind. Damit wird ein Archiv mit dem konfigurierten Namen (dci) erstellt. Die Besonderheit ist, dass der gemeinsame Pfad der Dateien möglichst weit gekürzt wird. Im Archiv stehen also nicht die Dateien ./target/delphi-council-is-${{ env.VERSION }}.jar, ./target/classes/Dockerfile und ./target/helm/... - nein, im Archiv sind ./delphi-council-is-${{ env.VERSION }}.jar, ./classes/Dockerfile und ./helm/...; daran müssen wir uns erinnern, wenn wir im zweiten Teil des Flows auf diese Dateien zugreiffen wollen.
- name: Set Image name and version
run: |
echo "IMAGE=$(cat target/classes/Dockerfile | grep APP_NAME= | head -n 1 | grep -o '".*"' | sed 's/"//g')" >> $GITHUB_ENV
echo "VERSION=$(cat target/classes/Dockerfile | grep APP_VERSION= | head -n 1 | grep -o '".*"' | sed 's/"//g')" >> $GITHUB_ENV
- name: Image name and version
run: echo "Working on image '${{ env.IMAGE }}:${{ env.VERSION }}'."
- name: Upload Artifact
id: upload-jar
uses: actions/upload-artifact@v2
with:
name: dci
path: |
target/delphi-council-is-${{ env.VERSION }}.jar
target/classes/Dockerfile
target/helm
retention-days: 1
Damit ist der eigentliche Build der Software abgeschlossen und die Ergebnisse liegen im Archiv dci bei den GitHub Actions.
Hier findet man bei den Workflow-Ausführungen die erzeugte Datei. Da ich sie nur für 1 Tag aufbewahre, ist sie hier als expired markiert.
Der Container-Build-Teil des Workflows
Jetzt kommen wir zum 2. Teil des Workflows, dem Container-Build. Hier wird erstmal definiert, dass er einen Runner mit dem Tag podman nutzen soll (da ich nur dort die notwendige Software installiert habe. Außerdem wird definiert, dass dieser Schritt erst nach erfolgreichem java-build laufen darf. Das git-Repository wird wieder ausgecheckt und das Archiv dci aus dem Java-Build heruntergelanden sowie wieder die Umgebungsvariablen erzeugt (es ist ein anderer Runner, damit sind diese natürlich nicht verfügbar).
container-build:
runs-on: [ podman ]
needs: java-build
steps:
- name: checkout sources
id: checkout-sources
uses: actions/checkout@v2
- name: retrieve jar and dockerfile
id: retrieve-jar
uses: actions/download-artifact@v2
with:
name: dci
- name: Set Image name and version
run: |
echo "IMAGE=$(cat classes/Dockerfile | grep APP_NAME= | head -n 1 | grep -o '".*"' | sed 's/"//g')" >> $GITHUB_ENV
echo "VERSION=$(cat classes/Dockerfile | grep APP_VERSION= | head -n 1 | grep -o '".*"' | sed 's/"//g')" >> $GITHUB_ENV
Als erstes nutzen wir buildah über die Aktion redhat-actions/buildah-build@v2, um einen Dockerfile-basierten container-Build anzustoßen. ./classes/Dockerfile ist die Datei aus dem Archiv dci und der Build soll das aktuelle Verzeichnis als Basis nutzen. Außerdem braucht das Image einen Namen (image) und tags (ich tagge das Image mit drei tags): ${{ env.VERSION }} (die Version aus dem Maven-Buildfile), latest und die Git Commit-Id (${{ github.sha }}).
- name: Buildah
id: build-container
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE }}
tags: ${{ env.VERSION }} latest ${{ github.sha }}
dockerfiles: |
./classes/Dockerfile
context: ./
Nach dem Build muss der erzeugte Container natürlich noch in die Container-Registry geschoben werden. Dies erledigt die Aktion redhat-actions/push-to-registry@v2, die dazu diverse Parameter braucht. Unter anderem auch Credentials für die Registry und natürlich das eigentliche Repository auf der Registry, in das das Image geschoben werden soll.
- name: Push To quay
id: push-to-quay
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ env.IMAGE }}
tags: ${{ env.VERSION }} latest ${{ github.sha }}
registry: ${{ secrets.QUAY_REPO }}
username: ${{ secrets.QUAY_USER }}
password: ${{ secrets.QUAY_TOKEN }}
Damit man nicht seinen persönlichen Account nutzen muss, legt man auf quay.io einen Robot-Account an. Dazu kommen wir im nächsten Teil. Ein solcher Account hat einen Benutzernamen und ein Token, dass hier in der Aktion als username bzw. password gesetzt werden müssen. Außerdem muss man den Pfad zum Repository angeben. Die Secrets werden im Repository definiert:
In einem normalen Repository würden QUAY_TOKEN und QUAY_USER auch unter "Repository secrets" stehen. Da ich diese aber für eine Organisation angelegt habe, werden sie hier zwar gelistet aber bei der Organisation gepflegt. Für den Workflow macht dies keinen Unterschied, Ihr könnt sie auch direkt im Repository anlegen. Ich habe mehrere Projekte und will nur einen Quay-Account pflegen und habe daher den Weg über die Organisation gewählt.
In das QUAY_REPO müsst Ihr den kompletten Pfad angeben, bei mir ist das quay.io/klenkes74. Der QUAY_USER beinhalten den Benutzernamen für das quay-Repository, der QUAY_TOKEN das generierte Token für den Benutzer. Aber um quay.io kümmern wir uns im nächsten Teil der Artikelserie.
Sichere und unsichere Github Actions
GitHub weißt darauf hin, dass beim Einsatz eigener Runner natürlich die Sicherheit nicht vernachlässigt werden darf. Immerhin können Fremde einen Pullrequest stellen und wenn man da nicht aufpasst, kann natürlich auch der Workflow verändert werden. Daher bietet GitHub eine Konfiguration an, welche Runner erlaubt sind und welche nicht. Hier müsst Ihr auswählen, wem Ihr vertraut. Ich vertraue allen GitHub-Actions und denen von verifizierten Erstellern:
Sicherheitseinstellungen für Actions auf github.com
Damit sind wir am Ende der Github-Repository-Konfiguration angekommen. Als nächstes schauen wir uns an, wie wir an ein Quay-Repository kommen und dort einen eigenen Robot-Account für Github Actions anlegen.
-
CI/CD mit Maven, GitHub Actions, quay.io und OpenShift
In der Softwareentwicklung gehören CI/CD-Pipelines inzwischen zum guten Ton. Allerdings braucht man hierfür einiges an Infrastruktur, um den Buildprozess so weit zu automatisieren. In dieser kurzen Artikelserie will ich eine mögliche Pipeline auf Basis von GitHub, GitHub Actions, quay.io und OpenShift-basierten Runnern für GitHub Actions betrachten. Ich nutze hier OpenShift, da ich einen OKD-Cluster zu Verfügung habe, aber die Runner lassen sich auch 1:1 für Kubernetes-Cluster nutzen.
</amp-fit-text>
Meine Software ist eine Java spring-boot-Anwendung, die per Maven gebaut wird. Aber dies betrifft nur den kurzen build-Teil der Pipeline und man kann die gleiche Methode auch für Gradle-Builds oder auch für node.js nutzen - man muss gegebenenfalls einen anderen Build-Runner aussuchen.
</amp-fit-text>
Dieser Teil der Artikelserie verdeutlicht das Zusammenspiel der Komponenten und bietet so eine Übersicht. Die einzelnen Bestandteile werden dann in den folgenden Artikeln besprochen.
Überblick über die Komponenten, die wir hier betrachten werden.
Wir nutzen hier die Services von Github (als git-Repository und als Steuerung für unsere Pipeline, bei Github "Workflow" genannt), quay.io (als Container-Repository, das App-Repository ist nur als zukünftige Erweiterung der Pipeline eingetragen) und natürlich unseren eigenen OCP-Cluster (bei mir noch OKD 3.11).
Die Applikation kommt aus einem weiteren Projekt von mir, aber ist in diesem Kontext Nebensache. Sie wird per Maven gebaut und dann per podman-Build in einen Container gegossen. Den java-Runner werden wir hier im Rahmen der Artikel selbst erweitern, um den Node-Part der Anwendung ebenfalls hier bauen zu können. Und weil wir gerade dabei sind, packen wir auch noch gradle als Buildsystem in den Container. Aber dazu mehr im entsprechenden Artikel dieser Serie.
Das mag jetzt nach einem großen Haufen an Komponenten aussehen, aber neben dem Runner-Projekt auf OCP und den Repositories auf Github (source) und quay.io (Container) sind es noch zwei Dateien im Source-Repository (.github/workflow/ci.yaml und src/main/docker/Dockerfile), die unsere Pipeline vervollständigen werden.
Schauen wir uns den Ablauf der Pipeline mal systematisch an:
Ablauf eines Github Workflows mit Github Actions
So ein Workflow funktioniert ganz einfach. Nach dem Auslöser (normalerweise ein commit) führt er einfach der Reihe nach alle definierten Aktionen aus. Wenn ein Fehler auftritt, bricht er ab. Wurden alle Aktionen ausgeführt, beendet sich der Workflow. Nichts besonderes also.
Wir werden einen einfachen Workflow haben, der die Software
mittels Maven auf dem java-runner baut,
den Container mittels buildah baut und
ihn dann per podman nach quay.io hochlädt.
Es kommen neben diesen grundsätzlichen Arbeiten noch ein paar technische Aktionen (auschecken des Codes, aufsetzen der Build-Umgebung, ...) Also nichts, wovor man sich fürchten müsste.
Damit haben wir unseren Überblick. Im nächsten Teil werden wir uns um das Aufsetzen des Github-Repositories (und die Definition des Workflows dort) kümmern.
-
Schattenjagen
Heute will ich etwas über eine beliebte Beschäftigung von Softwareentwicklern schreiben: dem Schattenjagen.
Schon früh lernt man als Softwareentwickler, Fehler zuerst bei sich zu suchen, bevor man Probleme bei anderen sucht. Oft genug ist es Lernen-durch-Schmerzen. Man behauptet, eine Library oder Code eines Kollegen hat einen Bug und bekommt dann nachgewiesen, dass es der eigene Code war. Man lernt also, erstmal auszuschließen, dass es sich bei dem Problem um den eigenen Code handelt, bevor man ihn woanders sucht.
Aber manchmal (oder öfter, je nach der einen Fähigkeit) ist es halt doch ein Problem, das man nicht selbst verursacht hat. Aber bis man dahin kommt, habt man oft schon lange Zeit bei der Fehlersuche verbracht.
Mein aktuelles Beispiel war z.B. ein Fehler in Liquibase, der bei der Nutzung von mariadb eine seltsame Exception geworfen hat (https://liquibase.jira.com/browse/CORE-3457). Und ausgerechnet bei einem neuen Pet-Project hatte ich mich entschieden, eine MariaDB zu nutzen. Und lief auf den Fehler. Ich habe an meinen Changelogs für Liquibase herumgeschraubt und sie solange umgeschrieben, bis ich einfach mal ein leeres Changelog nutzte und den gleichen Fehler bekam.
Und diesmal brachte mich Google dann auf die Jira-Issue CORE-3457. Ich habe also nun frohen Mutes einen Github-Issue für Quarkus 1.7.1.Final geöffnet - der derzeit aktuellen Version. Und dann wollte ich diesen Issue auch gleich lösen und einen Pullrequest dafür öffnen - man will ja auch etwas für die OpenSource tun und nicht immer nur geiern.
Also quarkus geforkt, in die IDE geladen und dann erst gesehen, dass im Master bereits liquibase 4.0.0 genutzt wird - und der Fehler wurde bereits vor ein paar Tagen gefixt.
Und so lebte mein Bug-Issue bei Quarkus ganze 10 Minuten, bevor ich ihn wieder kleinlaut geschlossen habe. Im Moment baue ich gerade das Snapshot-Quarkus, da ich keine 20 Tage warten will, bis ich weitermachen kann mit meinem Projekt. Aber alles in allem, habe ich sicherlich 5 Stunden wieder mal einen Schatten gejagt ...
Titelbild: Meme von G.L Solaria auf dev.to
-
-
-
-
-
Providing documentation the OpenShift way - Part 1
Documentation is one of the most hated part of the life of a developer. So the documentation is often the most neglected part of a project. At work I use Asciidoctor to write my customer documentation and it is quite acceptable. I loved LaTeX and Asciidoctor is an acceptable replacement for technical documentation - especially with the alternatives being google doc or word.
Problem statement
Lets define the problem to solve first:
A service/software needs the current documentation available on the internet.
The documentation source should be saved in the same git repository as the software/service source code itself, so it is managed in the same lifecycle as the software itself.
A static documentation should be generated and provided in form of HTML via HTTP (commonly known as "web server").
It should be deployed within an OpenShift or OKD cluster.
Asciidoctor as markup language for documentation
I don't want to advocate Asciidoctor, well in fact I do want to advocate for it. Asciidoctor is a nice way to write technical documentation as pure ascii (well, UTF-8) text and let compile it to nice output like PDF or HTML. But check other resources like the home page of Asciidoctor for the syntax and semantic of this text processing language. Believe me, it's really easy to write and read (even in unprocessed form).
The nice thing, the whole documentation may be checked in on the source code revisioning. Since we work with s2i-bilder I assume you use git. If that's a gitlab or public or private github or something like gogs, doesn't matter. If it is able to provide a remote git repo, that's fine.
Writing documentation
Let's put the documentation in the directory /documentation of the git repo. There is an index.adoc file (the future landing page as index.html on the webserver). Perhaps you put the whole documentation into one single file (I talk about the output, the input may be split into more than one file included in the generation process). Or you have an hierarchy of documents.
How you structure your documentation is completely up to you. It only matters that there is a single index.adoc in the base directory of your documentation.
Generating documentation
And that's it. I want to have a build configuration where I point to that git repository (the URI, the branch and the contextDir like /documentation) and the rest is done by software.
And how I solve this, is described in the upcoming articles:
Part 2: Creating the s2i builder with ASCIIDOC generation software included
Part 3: Creating the documentation site
Part 4: Bundling the components for OpenShift
-
Openshift and GroupSync from LDAP
OpenShift offers a variety of possible integrations into security providers. The integration is divided into authentication and authorization. Authentication is handled by one of the configurable IdentityProviders of OpenShift. While authorization is handled by importing groups into OpenShift. For importing groups the most used method is reading from an LDAP (or an Active Directory via its LDAP interface). OpenShift already has a synchronization tool for this type of synchronization. And as long as that tool is sufficient, there are more reasons to stay with that tool than to replace it. But there are some situations where you need to replace it. And here the base software I written and published to github project klenkes74/openshift-ldapsync.
Reasons for using this software
Possible reasons (among others) not to use the official LDAP sync are:
The SSL certificate of the LDAP could not be checked.
The group structure is not supported. Mainly nested groups pose a (performance) problem.
The linking element between user and groups (the username) is not exposed as single attribute on the LDAP.
You don't have an LDAP server to sync with but other means of providing group authorization data.
Well, the last reason will result in a little bit more work since you have to replace the LDAP reading part of the software, but it's still doable and easy forward. The current software as provided on github will take care of the first three problems. But since your LDAP structure may vary, you will propably need to change the code.
Integration into openShift
The LDAP sync will run as single pod within OpenShift. By using this approach we don't need to take care of system cron jobs or how to handle the case that the server this job is running on fails. OpenShift is good at managing that a pod is running, so it was a natural decision to give that job to OpenShift. In very restrictive environments you may have to define an egress router to be able to connect to the LDAP directory (or even the OpenShift API). But you will know that and there is quite good documentation for adding this egress router.
The README of the github project describes how to install the software and which parameter you need to set.
Spring Boot
The software is a default spring-boot application. It leveraged the easy startup and scheduling functions of spring-boot to start and run the synchronization. The application class is very straight forward and starts only the injected runner SyncGroupsController.
Doing the work
The SyncGroupsController reads the groups from the LDAP and the OpenShift API into standardized maps (taking the OpenShift name as key and the Group as value). And then it runs all defined group executors and passing them the two maps. Currently only one executor is defined but you could add additional ones matching your needs (e.g. you could enrich already existing users with their email addresses or names if your authentication module does not deliver that data). It is really up to you.
But looking at the SyncGroupEecutor (this is the class, the real work takes place) you see, it is really structured. It first creates selector sets for synched groups (groups that exist in LDAP and OpenShift), groups to add (groups that only exist in LDAP) and groups to delete (groups that only exist in OpenShift). As long as we have only one source for groups, this handling is simple and easy.
After having selected the different action items, the code creates a set of commands to run (these could be either Create, Update or Delete). Every command executes the changes for exactly one group. And this set of course is then executed. And that's it.
Well. But almost every LDAP looks different and one of the requirements was to handle it different. And you are right. That magic is hidden in the readers for OpenShift and LDAP (the things creating the maps with the Group). There are the readers mapping the data read from OpenShift or LDAP and providing the normalized Group object. Within these readers the converts convert from OpenShift (since the OpenShift data does not change, the converter is within the same class) or LDAP (one for the groups and one for the users). And here are the places to change the mappings. Feel free to adapt them to your needs.
And the ugly rest ...
Well, and then there is the SSL part. I'm not proud of it. But especially the type of companies with big datacenters using OpenShift often have problems providing corect SSL certificates to their AD servers. So there is a small part of code in LdapServer (lines 79 to 97), that copes with that problem:
[code lang="java" toolbar="true" title="Removing checks for SSL in java" firstline="79"]
try {
SSLContext ctx = SSLContext.getInstance("TLS");
X509TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] xcs, String string) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] xcs, String string) throws CertificateException {
}
public X509Certificate[] getAcceptedIssuers() {
return null;
}
};
ctx.init(null, new TrustManager[]{tm}, null);
SSLContext.setDefault(ctx);
} catch (Exception ex) {
ex.printStackTrace();
}
[/code]
Of course there is some boilerplate code to read configuration from the system environment to be able to configure it the "OpenShift way". But you also could give every needed parameter via command line (to run it "adhoc" as a first import for example).
Summary
Of course you should not use this software (or a derivate of it) when the good tested and easy configurable default LDAP sync mechanism works for you. Never add any security related component to your system if you don't have to. But If you need, then you have a nice basis to work on. Drop me an email or comment. And I'd love to receive pull requests to improve the software. Especially the tests are missing ...
-
Logging in Java
Der Artikel "Is Standard Java Logging Dead?" auf dzone.com hat mich animiert, wieder einmal ein paar Gedanken zu Papier zu bringen. Logging ist immer ein nettes Thema in den Projekten. Meistens wird wenig dazu gesagt oder vereinbart. Jeder Programmierer zieht sein Ding durch. Doch wenn Logging wirklich helfen soll, dann muss man sich ein paar Gedanken machen.
Zu allererst muss man sich klarmachen, für wen das Logging ist und wie es ihn unterstützen soll. Sofort kommen zwei Adressaten in den Sinn. Natürlich der Entwickler. Er braucht Informationen während der Entwicklung und die Einführungsphase der Software. Und dann braucht natürlich der Admin in der Produktion Logmeldungen, die ihn in das System sehen lassen.
Aber es gibt noch weitere Adressaten. So kann zum Beispiel ein Abrechnungssystem auf entsprechende Daten angewiesen sein. Oder die Governance-Abteilung. Moderne Frameworks erlauben, Logging-Events nach diversen Regeln zu interpretieren.
Aber jetzt beginnt das Problem. Der Programmierer wird das Logging so bauen, dass er mit dem Wissen, was er programmiert hat, schnell Informationen bekommt. Dummerweise hat sein Nachfolger im Projekt oder der Administrator dieses Wissen nicht mehr. Und fragt sich, warum hier geloggt wird und da nicht. Mit diesem Entwicklerlogging kann der Admin und erst recht das Billing nichts anfangen. Daher müssen Logginganforderungen auch als nicht-funktionale Anforderungen erfasst werden. Wenn man ein System aus Micro-Services zusammensetzt wird Operations sehr genau definieren müssen, wie es Logging-Events erhalten will, damit diese zentral verarbeitet und aufbereitet werden können - sonst müssen die Admins hunderte Logfiles auswerten und die einzelnen Geschäftsvorfälle durch die Systeme verfolgen. Eine nicht zu leistende Aufgabe.
-
Buildsystem via Vagrant-Box
In manchen Java-Projekten hat man das Problem, dass Entwickler unter Windows, Linux und MacOS arbeiten. Damit stellt schon das Build-Tooling ein Problem dar. Obwohl moderne Build-Systeme meistens in der JVM laufen und damit eigentlich betriebssystemunabhängig sein sollten, stößt man oft an das Problem, dass Pfadangaben leider nicht so unabhängig sind.
Um trotzdem lokal bauen zu können, wäre ein System schön, das wie ein zentraler Build-Server fungiert. Aber halt lokal. Und hier kommt Vagrant ins Spiel ...
In den meisten Fällen dürfte der Buildserver auf einem Linuxsystem laufen, die sind billig und schnell aufgesetzt. Meistens ist das die Qualifikation dafür, da das Management für solchen "Overhead" kein Geld ausgeben will. Daher betrachte ich hier nur diesen Fall[ref name="dislike-windows"]Außerdem mag ich Windows und MacOS als Server nicht.[/ref].
Was braucht man alles?
Eine Virtualisierungsumgebung (z.B. VMWare oder VirtualBox).
Vagrant zum Erzeugen der Virtual Machine.
Zeit, Lust und Durchhaltevermögen zum Erstellen des Vagrantfiles für die lokale Box.
Ich benutze hier VirtualBox auf einem MacBook Pro. Zusammen mit der VirtualBox ist das eine nette Kombination. Vagrant gibt es auf als Download auf https://www.vagrantup.com/downloads.html[ref name="vagrant-version"]Die aktuelle Version 1.8.7 für den Mac sind ca. 70 MB, je nach Netzwerk ist es also sofort da oder dauert etwas.[/ref]. In Version 1.8.7 ist wohl das gelieferte curl defekt und muss gelöscht werden:
[code lang="bash" toolbar="true" title="Entfernen des defekten curl"]
hermes:vagrant klenkes$ rm /opt/vagrant/embedded/bin/curl
hermes:vagrant klenkes$
[/code]
Damit funktionieren die Box-Downloads[ref]siehe https://github.com/twobitcircus/rpi-build-and-boot/issues/25[/ref]. Danach kann man per
[code lang="bash" toolbar="true" title="Herunterladen der Box mit Centos/7"]
hermes:vagrant klenkes$ vagrant box add centos/7
==> box: Loading metadata for box 'centos/7'
box: URL: https://atlas.hashicorp.com/centos/7
This box can work with multiple providers! The providers that it
can work with are listed below. Please review the list and choose
the provider you will be working with.
1) libvirt
2) virtualbox
3) vmware_desktop
4) vmware_fusion
Enter your choice: 2
==> box: Adding box 'centos/7' (v1610.01) for provider: virtualbox
box: Downloading: https://atlas.hashicorp.com/centos/boxes/7/versions/1610.01/providers/virtualbox.box
==> box: Successfully added box 'centos/7' (v1610.01) for 'virtualbox'!
hermes:vagrant klenkes$
[/code]
eine Boxdefinition herunterladen. Ein Update könnte man mit
[code lang="bash" toolbar="true" title="Updaten einer Box-Definition"]
hermes:vagrant klenkes$ vagrant box update --box centos/7
Checking for updates to 'centos/7'
Latest installed version: 1610.01
Version constraints: > 1610.01
Provider: virtualbox
Box 'centos/7' (v1610.01) is running the latest version.
hermes:vagrant klenkes$
[/code]
ausgeführt werden.
Und mit
[code lang="bash" toolbar="true" title="Ausführen einer Vagrant-Box"]
hermes:vagrant klenkes$ vagrant up
Bringing machine 'default' up with 'virtualbox' provider...
==> default: Clearing any previously set forwarded ports...
==> default: Clearing any previously set network interfaces...
==> default: Preparing network interfaces based on configuration...
default: Adapter 1: nat
default: Adapter 2: hostonly
==> default: Forwarding ports...
default: 80 (guest) => 8080 (host) (adapter 1)
default: 22 (guest) => 2222 (host) (adapter 1)
==> default: Running 'pre-boot' VM customizations...
==> default: Booting VM...
==> default: Waiting for machine to boot. This may take a few minutes...
default: SSH address: 127.0.0.1:2222
default: SSH username: vagrant
default: SSH auth method: private key
default: Warning: Remote connection disconnect. Retrying...
==> default: Machine booted and ready!
==> default: Checking for guest additions in VM...
default: No guest additions were detected on the base box for this VM! Guest
default: additions are required for forwarded ports, shared folders, host only
default: networking, and more. If SSH fails on this machine, please install
default: the guest additions and repackage the box to continue.
default:
default: This is not an error message; everything may continue to work properly,
default: in which case you may ignore this message.
==> default: Configuring and enabling network interfaces...
==> default: Rsyncing folder: /Users/klenkes/Documents/demo/vagrant/ => /vagrant
hermes:vagrant klenkes$
[/code]
kann man die Box starten. Wie man aber sieht, können keine Verzeichnisse per Virtualbox gemountet werden, da die guest additions nicht installiert sind. Darum müssen wir uns kümmern:
Wie kommen die Virtualbox Guest-Addons ins System?
Hier gibt es ein Vagrant-Plugin namens "vagrant-vbguest". Und das installieren wir erstmal:
[code lang="bash" toolbar="true" title="Installieren von vagrant-vbguest"]
hermes:vagrant klenkes$ vagrant plugin install vagrant-vbguest
Installing the 'vagrant-vbguest' plugin. This can take a few minutes...
Installed the plugin 'vagrant-vbguest (0.13.0)'!
hermes:vagrant klenkes$
[/code]
Die Meldung "this can take a few minutes..." ist ernst gemeint. Es kann etwas dauern. Ist das Plugin installiert, muss es noch konfiguriert werden - natürlich im Vagrantfile:
[code lang="text" toolbox="true" title="Virtualbox Guest-Addons konfigurieren im Vagrantfile"]
...
if Vagrant.has_plugin?("vagrant-proxyconf")
config.vbguest.auto_update = true
config.vbguest.no_remote = false
end
...
[/code]
Beim nächste start sollte es jetzt da sein. Das Plugin wird erstmal den GCC und alles was CentOS noch so braucht, um Kernelmodule zu kompilieren, installieren und dann auch gleich die Addons installieren. Sehr bequem.
Pack den Proxy in den Tank
Meistens befindet man sich ja als Consultant in einem abgeschlossenen Netz und braucht einen Proxy, um auf das Internet zugreifen zu könne. Hier hilft das Plugin "vagrant-proxyconf". Mittels
[code lang="bash" toolbar="true" title="Installieren von vagrant-proxyconf"]
hermes:vagrant klenkes$ vagrant plugin install vagrant-proxyconf
Installing the 'vagrant-proxyconf' plugin. This can take a few minutes...
Installed the plugin 'vagrant-proxyconf (1.5.2)'!
hermes:vagrant klenkes$
[/code]
lässt sich das benötigte Modul installieren. Nun kann man den Proxy in das Vagrantfile eintragen und die Einstellungen aus dem Host-System in die Box übernehmen:
[code lang="text" toolbox="true" title="Proxy-Konfiguration im Vagrantfile"]
...
if Vagrant.has_plugin?("vagrant-proxyconf")
puts "Configuring proxy!"
if ENV["http_proxy"]
puts "http_proxy: " + ENV["http_proxy"]
config.proxy.http = ENV["http_proxy"]
end
if ENV["https_proxy"]
puts "https_proxy: " + ENV["https_proxy"]
config.proxy.https = ENV["https_proxy"]
end
if ENV["no_proxy"]
puts "no_proxy: " + ENV["no_proxy"] + ",127.0.0.1,localhost"
config.proxy.no_proxy = ENV["no_proxy"] + ",127.0.0.1,localhost"
end
...
[/code]
Mount des Verzeichnisses /vagrant
Laut Dokumentation wird dieses Verzeichnis bei virtualbox per Virtualbox gemounted. Leider wurde es bei mir immer nur per rsync synchronisiert. Aber ein Eintrag für den Mount mit dem richtigen Typ hat dies behoben:
[code lang="text" toolbox="true" title="Mountpoint korrigieren"]
...
config.vm.synced_folder "./", "/vagrant", type: "virtualbox"
...
[/code]
Damit steht einem das Host-Verzeichnis auch in der Gast-Box zu Verfügung.
Build-System
Jetzt muss man noch das gewünschte Build-System per Provision installieren. Ich installiere git und java gleich mit. und habe es in einem Aufwasch hinter mir. In das Script kann man noch alle notwendigen Änderungen einfügen. Es handelt sich um ein Shell-Script, dass mit den Rechten des Users "vagrant" auf der Box ausgeführt wird. Root-Aktionen müssen also per "sudo" eingeleitet werden ...
Die Sourcen legen wir in unserem Host-Directory neben das Vagrantfile und nennen den Ordner src. Damit steht er sowohl auf dem Host wie auch in der Guest-Box zu Verfügung.
[code lang="text" toolbox="true" title="Build-System aufsetzen"]
...
config.vm.provision "shell", inline: <<-SHELL
sudo yum install -y git unzip zip vim java-1.8.0-openjdk-headless java-1.8.0-openjdk-devel-debug maven
cd /vagrant
mkdir -p /vagrant/src
SHELL
...
[/code]
Und voila, wir haben eine Vagrant-Box, die ein definiertes Build-System beinhaltet.
Und alles zusammen sieht das Vagrantfile nun so aus:
[code lang="text" toolbox="true" title="Vollständiges Vagrantfile für ein Build-System"]
# -*- mode: ruby -*-
# vi: set ft=ruby :
# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|
config.vm.box = "centos/7"
# accessing "localhost:8080" will access port 80 on the guest machine.
config.vm.network "forwarded_port", guest: 80, host: 8080
config.vm.network "private_network", ip: "192.168.33.10"
# Share an additional folder to the guest VM. The first argument is
# the path on the host to the actual folder. The second argument is
# the path on the guest to mount the folder. And the optional third
# argument is a set of non-required options.
# config.vm.synced_folder "../data", "/vagrant_data"
config.vm.synced_folder "./", "/vagrant", type: "virtualbox"
# Provider-specific configuration so you can fine-tune various
# backing providers for Vagrant. These expose provider-specific options.
# Example for VirtualBox:
#
config.vm.provider "virtualbox" do |vb|
# Display the VirtualBox GUI when booting the machine
vb.gui = false
# Customize the amount of memory on the VM:
vb.memory = "1024"
end
if Vagrant.has_plugin?("vagrant-proxyconf")
puts "Configuring proxy!"
if ENV["http_proxy"]
puts "http_proxy: " + ENV["http_proxy"]
config.proxy.http = ENV["http_proxy"]
end
if ENV["https_proxy"]
puts "https_proxy: " + ENV["https_proxy"]
config.proxy.https = ENV["https_proxy"]
end
if ENV["no_proxy"]
puts "no_proxy: " + ENV["no_proxy"] + ",127.0.0.1,localhost"
config.proxy.no_proxy = ENV["no_proxy"] + ",127.0.0.1,localhost"
end
end
# Enable provisioning with a shell script. Additional provisioners such as
# Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
# documentation for more information about their specific syntax and use.
config.vm.provision "shell", inline: <<-SHELL
sudo yum install -y git unzip zip vim java-1.8.0-openjdk-headless java-1.8.0-openjdk-devel-debug maven
cd /vagrant
mkdir -p /vagrant/src
SHELL
end
[/code]
Viel Spaß damit!
-
Blick in das JNDI ...
Manchmal muss man einfach in einen vorhanden JNDI reinschauen, um herauszufinden, was dort eigentlich auf Entdeckung schlummert. Die folgende Klasse liefert einen formatierten Baum aus den JNDI-Eintragungen.
[java autolinks="false" collapse="false" firstline="1" gutter="true" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
package de.kaiserpfalzedv.office.common.jndi;
import javax.naming.InitialContext;
import javax.naming.NameClassPair;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A small utlitiy class to print the JNDI tree.
*
* @author klenkes {@literal <rlichti@kaiserpfalz-edv.de>}
* @version 1.0.0
* @since 2016-09-29
*/
public class JndiWalker {
private static final Logger LOG = LoggerFactory.getLogger(JndiWalker.class);
/**
* Walks the {@link InitialContext} with the entry point given and returns a string containing all sub entries.
*
* @param context The initial context to be printed.
* @param entryPoint The node to start with.
* @return A string containing the tree of the initial context (nodes including the leaves).
* @throws NamingException If the lookup failes for some unknown reason.
*/
public String walk(final InitialContext context, final String entryPoint) throws NamingException {
StringBuffer sb = new StringBuffer(" JNDI tree for '").append(entryPoint).append("': ");
LOG.trace("JNDI-Walker activated for entry point '{}' on: {}", entryPoint, context);
walk(sb, context, entryPoint, 0);
return sb.toString();
}
private void walk(
StringBuffer sb,
final InitialContext context,
final String name,
final int level
) throws NamingException {
NamingEnumeration ne;
try {
ne = context.list(name);
} catch (NamingException e) {
LOG.trace("Reached leaf of JNDI: {}", name);
return;
}
while (ne.hasMoreElements()) {
NameClassPair ncp = (NameClassPair) ne.nextElement();
printLevel(sb, level);
sb
.append(ncp.getName())
.append(" (").append(ncp.getClassName())
.append(", ").append(getNameInNamespace(ncp))
.append(", ").append(getRelativeFlag(ncp))
.append(")");
walk(sb, context, name + "/" + ncp.getName(), level + 4);
}
}
private void printLevel(StringBuffer sb, int level) {
sb.append("\n");
for (int i=0; i < level; i++) {
sb.append(" ");
}
}
private String getNameInNamespace(NameClassPair ncp) {
String nameInNamespace;
try {
nameInNamespace = ncp.getNameInNamespace();
} catch (UnsupportedOperationException e) {
nameInNamespace = "not-supported";
}
return nameInNamespace;
}
private String getRelativeFlag(NameClassPair ncp) {
String isRelative;
try {
isRelative = ncp.isRelative() ? "relative" : "fixed";
} catch (UnsupportedOperationException e) {
isRelative = "not-supported";
}
return isRelative;
}
}
[/java]
Die Basis für diese kleine Hilfsklasse war ein Blogeintrag von Anders Rudklaer Norgaard. Ich habe sie nur Servlet-unabhängig gemacht.
-
H2-Datenbankserver für Integrationstests mit Maven starten und stoppen
Der Datenbankserver H2 ist ein beliebter Server während der Entwicklungsphase einer Software. Die Fähigkeit zu In-Memory-Datenbanken ist für viele Tests geradezu ideal. Wer jedoch die Datenbankstrukturen analysieren will, kommt um eine Datenbank mit Persistierung nicht herum. H2 kann natürlich das auch. Allerdings gestaltet sich das Starten/Stoppen für die Integrationstests etwas komplex. Natürlich gibt es entsprechende Plugins, aber auf einem CI-Server kommen so leicht Portkonflikte zustanden, wenn mehrere Builds gleichzeitig laufen. Ich beschreibe hier ein Setting, das den Maven-Build-Helper nutzt, um dies zu verhindern.
Das eigentliche Starten und Stoppen geht per Plugin ganz einfach. Allerdings mögen es CI-Systeme wie z.B. Jenkins nicht, wenn z.B. der Datenbankport hart vorgegeben ist. Da sind Konflikte einfach vorprogrammiert. Aber Mojohaus (Nachfolger des Mojo-Projekts bei Codehaus) bietet auch hier Abhilfe mit dem build-helper-maven-plugin.
[xml autolinks="false" collapse="false" firstline="1" gutter="true" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
...
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>1.12</version>
<executions>
<execution>
<id>reserve-ports</id>
<phase>generate-sources</phase>
<goals>
<goal>reserve-network-port</goal>
</goals>
<configuration>
<portNames>
<portName>h2port</portName>
</portNames>
</configuration>
</execution>
</executions>
</plugin>
...
</plugins>
...
</build>
...
[/xml]
Mit diesem Port (der jetzt als Maven-Property ${h2port} verfügbar ist) kann man nun das eigentliche Plugin konfigurieren. Wichtig ist, dass das Plugin die gleiche Version der H2-Datenbank nutzt, wie die Anwendung. Sonst kommen unschöne Fehler vor, die sich schwer debuggen lassen. Daher habe ich die Versionen wieder einmal als Properties ausgelagert:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="3-4,14,17,43" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
...
<properties>
<h2.version>1.3.176</h2.version>
<h2-maven-plugin.version>1.0</h2-maven-plugin.version>
</properties>
...
<build>
...
<plugins>
...
<plugin>
<groupId>com.edugility</groupId>
<artifactId>h2-maven-plugin</artifactId>
<version>${h2-maven-plugin.version}</version>
<configuration>
<!--suppress MavenModelInspection --><!-- Will be set by the build-helper -->
<port>${h2port}</port>
<allowOthers>true</allowOthers>
<baseDirectory>${project.basedir}/target/data</baseDirectory>
<shutdownAllServers>true</shutdownAllServers>
<trace>true</trace>
</configuration>
<executions>
<execution>
<id>Spawn a new H2 TCP server</id>
<phase>pre-integration-test</phase>
<goals>
<goal>spawn</goal>
</goals>
</execution>
<execution>
<id>Stop a spawned H2 TCP server</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
</dependencies>
</plugin>
...
</plugins>
...
</build>
...
[/xml]
Damit wird nun der H2-Server vor den Integrationstests gestartet und danach gestoppt. Wichtig ist, dass die Integrationstests per failsafe ausgeführt werden, da der Stop durch die dynamische Zuweisung des Ports nicht mehr automatisiert durchgeführt werden kann, sobald der Mavenprozess abgebrochen ist. Ein Build-Abbruch während der Integrationstests führt also zu einem hängendem Datenbankserver auf einem automatisch generierten Port.
Im Zusammenspiel mit einem Datenbankversionierungssystem wie z.B. Liquibase lässt sich nun viel in Maven automatisieren.
-
Datenbankversionierung mit Liquibase und Maven
SQL-Datenbanken gehören trotz der großen Aufmerksamkeit für NOSQL zum Brot-und-Butter-Handwerkzeug eines Softwareentwicklers. Und zumindest im Unternehmensumfeld wird des wohl noch lange so bleiben, denn dort setzen sich neue Konzepte nur langsam durch. Doch schon während der Softwareentwicklung stößt man im Team auf das Problem, dass Änderungen an den Datenstrukturen verwaltet werden müssen. Die Kollegen brauchen bei Softwareänderungen auch geänderte Testdatenbanken, man will auch nachhalten, welcher Commit welche Datenbankänderung nach sich zog.
Hier kommen dann Datenbankversionierungstools wie Flyway oder eben Liquibase ins Spiel. Flyway habe ich uns vor langer Zeit nur kurz angeschaut, aber eine persönliche Präferenz des für das damalige Projekt verantwortlichen Softwarearchitekten für XML-basierte Tools hat mich dann zu Liquibase geführt. Wie es in ein Mavenprojekt integriert werden kann, damit Integrationstests immer auf eine aktuelle Datenbank zugreifen können, will ich im folgenden beschreiben.
Ich werde hier nicht näher darauf eingehen, wie man zum Beispiel einen H2-Server in einen Maven-Integrationstest einbauen kann. Ich gehe von einem laufenden Server aus, um dann die Datenbankstruktur validieren und updaten zu können.
Die Verwaltung der Datenbankänderungen
Man braucht natürlich die Dateien, die die Datenbank und ihre Änderungen beschreiben. Ich habe mir Angewöhnt, nicht erst beim 1. Update in der Produktion mit Liquibase zu arbeiten sondern bereits in der Entwicklung damit zu starten. Im Team kann man so auch leicht Strukturänderungen an den Datenbanken übernehmen und damit kommunizieren. Außerdem werden sie so im Sourcecodemanagement-Tool verwaltet und können (und sollten!) Bestandteil des Code-Review im Team werden. Ich fange meist mit einer zentralen Changelog-Datei an, die dann weitere Dateien einbindet. Je nach Komplexität werden in den eingebundenen Dateien direkt Datenbankänderungen konfiguriert oder weitere Dateien eingebunden. Es lässt sich so eine Gruppierung der Änderungen vornehmen:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="6" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd">
<include file="topic-file.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
[/xml]
Natürlich können hier auch mehrere Dateien eingebunden werden. Doch schauen wir uns das topic-file.xml mal genauer an:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="6" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd">
<include file="topic-1.0.0.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>[/xml]
Auch hier sind noch keine Änderungen direkt erfasst. Ich nutze diese Ebene, um die verschiedenen Versionen zu strukturieren. Damit lässt sich sehr einfach nachhalten, zu welchem Release welche Datenbankänderungen eingeflossen sind. Und zu guter letzt kommt die eigentliche Verwaltungsdatei topic-1.0.0.xml (die ersten Nutzungen von Maven-Properties, die durch die Filterung von Maven dann ersetzt werden, habe ich noch hervorgehoben):
[xml autolinks="false" collapse="false" firstline="1" gutter="true" hightlight="13,16,23" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd">
<changeSet id="topic-schema-create-h2" author="klenkes74">
<preConditions>
<dbms type="H2"/>
<preConditions>
<comment>Creates the schema needed for the database.</comment>
<sql>CREATE SCHEMA IF NOT EXISTS ${db.schema};</sql>
...
</changeSet>
<changeSet id="topic-initial" author="klenkes74">
<comment>The initial data base tables.</comment>
...
</changeSet>
</databaseChangeLog>
[/xml]
Über die eigentliche Syntax der Datei möchte ich mich nicht auslassen, sie ist auf der Webseite von Liquibase gut beschrieben und recht eingängig. Wichtig ist, dass ein Changeset nicht mehr verändert werden darf, sobald es einmal im zentralen SCM (git oder svn oder was immer ihr auch einsetzen mögt) eingecheckt wurde. Alle späteren Änderungen müssen als weitere Changesets veröffentlicht werden.
Diese Dateien lege ich meistens im Ordner src/main/resources/ddl ab. Sollte ich Daten via Liquibase importieren (mit dieser Funktion kann man sich oft ein gesondertes Aufsetzen von z.B. dbunit sparen), dann liegen diese Dateien meist unter src/main/resources/data für Daten, die auch in Produktion gebraucht werden (z.B. einen Admin-User in den Benutzertabellen) oder unter src/test/resources/data für Testdaten. Apropos Testdaten. Hier hilft meine Changelog-Datei, die unter src/test/resources/ddl liegt:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="7" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd">
<!-- Import the real changelog -->
<include file="${project.build.outputDirectory}/ddl/changelog-master.xml" relativeToChangelogFile="false" />
</databaseChangeLog>
[/xml]
Wie man schön sieht, binde ich hier die Änderungsdatei aus der Produktion ein. Außerdem könnte ich hier jetzt noch Testdaten-Importe durchführen.
Die JPA-persistence.xml Konfigurationsdatei
Ich nutze unter src/test/resources/META-INF/persistence.xml eine besondere persistence.xml für Integrationstests. Man sieht hier gut die Nutzung der Maven-Properties für die eigentliche Konfiguration. Natürlich muss Maven so aufgesetzt sein, dass die Resourcen auch gefiltert werden.
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="13-16,19" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
<persistence xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://xmlns.jcp.org/xml/ns/persistence"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
version="2.1">
<persistence-unit name="tenant">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
... meine Klassen sind hier nicht besonders interessant, daher weg damit ...
<properties>
<property name="javax.persistence.jdbc.url" value="${jdbc.url}"/>
<property name="javax.persistence.jdbc.user" value="${jdbc.user}"/>
<property name="javax.persistence.jdbc.password" value="${jdbc.password}"/>
<property name="javax.persistence.jdbc.driver" value="${jdbc.driver}"/>
<property name="hibernate.show_sql" value="false"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.dialect" value="${jdbc.dialect}"/>
<property name="hibernate.hbm2ddl.auto" value="validate"/>
<!-- Configuring Connection Pool -->
<property name="hibernate.c3p0.min_size" value="5"/>
<property name="hibernate.c3p0.max_size" value="20"/>
<property name="hibernate.c3p0.timeout" value="500"/>
<property name="hibernate.c3p0.max_statements" value="50"/>
<property name="hibernate.c3p0.idle_test_period" value="2000"/>
</properties>
</persistence-unit>
</persistence>
[/xml]
Einbinden des Liquibase-Plugins
Nachdem ich die Daten vorbereitet habe, kommt jetzt das Zusammenfügen per Maven. zuerst brauchen wir ja die Properties, die oben ersetzt werden und einige, die uns das Arbeiten mit dem Plugin erleichtern. Zu beachten ist die Zeile 6, in der die benötigte URL steht. Hier nutze ich die Maven-Property ${h2port}. Diese wird bei mir vom Maven-Build-Helper gesetzt. Wer diesen nicht einsetzen will, sollte hier einen entsprechenden Port eintragen, sonst wird das nicht funktionieren:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="8" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
...
<properties>
...
<skipDatabaseSetup>${skipTests}</skipDatabaseSetup>
<db.schema>TENANT</db.schema>
<database.changelog>${project.build.testOutputDirectory}/ddl/changelog-test.xml</database.changelog>
<jdbc.url>jdbc:h2:tcp://localhost:${h2port}/testdb;AUTO_RECONNECT=TRUE;DB_CLOSE_DELAY=-1</jdbc.url>
<jdbc.driver>org.h2.Driver</jdbc.driver>
<jdbc.user>sa</jdbc.user>
<jdbc.password>password</jdbc.password>
<jdbc.driver>org.h2.Driver</jdbc.driver>
<jdbc.dialect>org.hibernate.dialect.H2Dialect</jdbc.dialect>
<liquibase-maven-plugin.version>3.5.2</liquibase-maven-plugin.version>
<h2.version>1.3.176</h2.version>
...
</properties>
...
[/xml]
Und nun die Plugin-Definition innerhalb der Builddefinition des pom-Files:
[xml autolinks="false" collapse="false" firstline="1" gutter="true" highlight="12-16,20,27,35-39,43" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
...
<build>
...
<plugins>
...
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase-maven-plugin.version}</version>
<configuration>
<changeLogFile>${database.changelog}</changeLogFile>
<driver>${jdbc.driver}</driver>
<url>${jdbc.url}</url>
<username>${jdbc.user}</username>
<password>${jdbc.password}</password>
<verbose>true</verbose>
<logging>warning</logging>
<promptOnNonLocalDatabase>false</promptOnNonLocalDatabase>
<skip>${skipDatabaseSetup}</skip>
<dropFirst>false</dropFirst>
</configuration>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>update-db</id>
<phase>pre-integration-test</phase>
<configuration>
<changeLogFile>${database.changelog}</changeLogFile>
<driver>${jdbc.driver}</driver>
<url>${jdbc.url}</url>
<username>${jdbc.user}</username>
<password>${jdbc.password}</password>
<verbose>true</verbose>
<logging>warning</logging>
<promptOnNonLocalDatabase>false</promptOnNonLocalDatabase>
<skip>${skipDatabaseSetup}</skip>
<dropFirst>false</dropFirst>
</configuration>
<goals>
<goal>update</goal>
</goals>
</execution>
</executions>
</plugin>
...
</plugins>
...
</build>
...
[/xml]
Und das war es eigentlich auch schon. Hat man alles richtig gemacht, wird nun in der Maven-Build-Phase pre-integration-test die Datenbank via Liquibase upgedated. Wer sich das ganze im Zusammenspiel anschauen möchte, ist gerne eingeladen, mal in mein Projekt https://github.com/klenkes74/kp-office zu schauen. Dort habe ich ein Parent für JPA-Module (kp-office-parent-adapter-jpa) mit den globalen Definitionen (wie dem Plugin) und dann Modulen, die dies nutzen (z.B. kp-office-base-adapter-jpa), in denen dann die Changelog-Dateien und Testdaten für die eigentlichen Datenbanken liegen.
-
-
-
-
Konstruktoren, Factory oder Builder? Wie entstehen Objekte?
Im Netz wurde schon sehr viel geschrieben, wie man Objekte erschaffen kann. Es ist eines der Grundprobleme bei der Softwareentwicklung und wie bei vielen wichtigen Entscheidungen gibt es eine klare Antwort: Es kommt auf die Situation an.
Die einfachste Fassung ist es, den Konstruktor direkt aufzurufen. Das funktioniert immer, wird aber ab einer bestimmten Anzahl von Paramtern leicht unübersichtlich, vor allem wenn viele diese Parameter vom gleichen Typ sind. Das Typenproblem kann aber auch schon bei sehr wenigen Parametern auftreten:
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="11,20-27" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
package de.kaiserpfalzEdv.blog.creation;
import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ConstructorDemo {
...
public demo() {
...
ValueObject object = new ValueObject("Meier", "Hermann");
...
}
}
class ValueObject {
private String familyName;
private String givenName;
public ValueObject(final String familyName, final String givenName) {
checkArgument(isNotBlank(familyName));
checkArgument(isNotBlank(givenName));
this.familyName = familyName;
this.givenName = givenName;
}
public String getFamilyName() {
return familyName;
}
public String getGivenName() {
return givenName;
}
}
[/java]
Bei diesem Bespiel könnte es nach einiger Zeit (oder bei einem anderen Entwickler) fraglich sein, ob man zuerst den Nachnamen oder den Vornamen übergeben muss. Noch schlimmer wird es, wenn es vier oder fünf Parameter werden und diese eventuell sogar den gleichen Typ haben - da kann selbst die beste IDE nicht mehr helfen und es wird fast automatisch irgendwann zu Verdrehern kommen.
Das Builder Pattern (deutsch: Erbauer) hilft es, diese Klippe zu umschiffen. Hier hilft eine weitere Klasse, der sogenannte Builder, das Objekt zu erschaffen:
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="12,35-57" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
package de.kaiserpfalzEdv.blog.creation;
import java.lang.IllegalStateException;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class BuilderDemo {
...
public demo() {
...
ValueObject object = new ValueObject.Builder().withFamilyName("Meier").withGivenName("Hermann").build();
...
}
}
class ValueObject {
private String familyName;
private String givenName;
private ValueObject() {}
private boolean isValid() {
return isNotBlank(familyName) && isNotBlank(givenName);
}
public String getFamilyName() {
return familyName;
}
public String getGivenName() {
return givenName;
}
public static class Builder() {
private ValueObject buildObject = new DataClass();
public ValueObject build() {
if (buildObject.isValid()) {
return buildObject;
} else {
throw new IllegalStateException("Sorry, DataObject can not be created with the given data.");
}
}
public Builder withFamilyName(final String name) {
buildObject.familyName = name;
return this;
}
public Builder withGivenName(final String name) {
buildObject.givenName = name;
return this;
}
}
}
[/java]
Jetzt können die Paramter nur noch verwechselt werden, wenn der Entwickler nicht weiß, was "familyName" und was "givenName" bedeutet. Aber dann hat man ein ganz anderes Problem. Wie man aber am Code sieht, muss man sich ein paar Gedanken machen: der eigentliche Konstruktor ist nun private, kann also nicht mehr direkt aufgerufen werden.
Das funktioniert, wenn der Builder wie hier eine innere Klasse zum ValueObject darstellt. Bei so kleinen Objekten wie diesem hier geht das auch noch, kann aber unübersichtlich werden.
Bleibt einem so also nicht die Wahl und man muss den Builder als eigene Klasse bauen, dann könnte man das eigentliche Value-Objekt package-local definieren (also ohne "public") und den Builder ins gleiche Package stecken. Der Konstruktor des Value-Objektes müsste demnach dann auch package-lokal sein, was die enge Kapselung leider wieder etwas öffnet. Auch müsste man die Variablen des Value-Objektes package-lokal definieren (oder entsprechende Setter, die wie man oben sieht hier vollständig fehlen).
Im Builder kann man aber auch noch etwas anderes verstecken: Werden oft Objekte mit den gleichen Daten benötigt, so könnten diese in einen Cache gelegt werden und dann diese wieder ausgegeben werden. Da es sich um ValueObjekte handelt, deren Status (hier: Vor- und Nachname) sich nicht mehr ändern kann, kann auch die Software in Wirklichkeit mit einem einzelnen Objekt arbeiten und braucht nicht immer ein neues Objekt. Es kann also dadurch optimiert werden.
Was mache ich aber, wenn die die Möglichkeit des Wiederverwendens eines Objekts nutzen will, aber ein Builder mit Kanonen auf Spatzen geschossen wäre (zum Beispiel, wenn ich nur einen Parameter habe)?
Dann kann ich eine Factory nutzen.
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="11,34-39" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
package de.kaiserpfalzEdv.blog.creation;
import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ConstructorDemo {
...
public demo() {
...
ValueObject object = ValueObject.Factory.getInstance("Meier", "Hermann");
...
}
}
class ValueObject {
private String familyName;
private String givenName;
private ValueObject(final String familyName, final String givenName) {
this.familyName = familyName;
this.givenName = givenName;
}
public String getFamilyName() {
return familyName;
}
public String getGivenName() {
return givenName;
}
public static class Factory {
public static ValueObject getInstance(final String familyName, final String givenName) {
checkArgument(isNotBlank(familyName));
checkArgument(isNotBlank(givenName));
return new ValueObject(familyName, givenName);
}
}
}
[/java]
Die Factory unterscheidet sich von der Factory-Methode dadurch, dass sie eine eigene Klasse ist, die eine oder mehrere Factory-Methoden in sich vereint, während die Factory-Methode eine statische Methode in der eigentlichen Klasse darstellt:
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="11,25-30" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="2" toolbar="true"]
package de.kaiserpfalzEdv.blog.creation;
import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ConstructorDemo {
...
public demo() {
...
ValueObject object = ValueObject.getInstance("Meier", "Hermann");
...
}
}
class ValueObject {
private String familyName;
private String givenName;
private ValueObject(final String familyName, final String givenName) {
this.familyName = familyName;
this.givenName = givenName;
}
public static ValueObject getInstance(final String familyName, final String givenName) {
checkArgument(isNotBlank(familyName));
checkArgument(isNotBlank(givenName));
return new ValueObject(familyName, givenName);
}
public String getFamilyName() {
return familyName;
}
public String getGivenName() {
return givenName;
}
}
[/java]
Damit haben wir nun alle vier Möglichkeiten gesehen. Welche passt am besten? Und die Antwort hatten wir schon zu Anfang: Es kommt auf die Situation an. Eventuell kann die folgende Kurzliste helfen ...
Das Objekt hat nur wenige (1-3) Parameter, die auch nicht leicht zu verwechseln sind. Außerdem soll es jedesmal neu geschaffen werden. Ein typischer Fall für den Konstruktor-Aufruf per "new Object(...)".
Das Objekt hat nur wenige (1-3) Parameter, die auch nicht leicht zu verwechseln sind. Es soll eventuell gecacht werden. Ein typischer Fall für die Factory-Method innerhalb des Objekts.
Das Objekt hat nur wenige (1-3) Parameter, die auch nicht leicht zu wechseln sind. Es soll eventuell gecacht werden oder gar eine Subklasse generiert werden (abhängig von den Parametern). Ein typischer Fall für die Factory.
Das Objekt hat viele Paramter oder wenige Paramter, die leicht zu verwechseln sind. Hier kann der Builder echt helfen ...
-
Lesbarer Quellcode - Heute: Wie validiere ich Methodenparameter?
Fast alle Softwareentwickler sind sich einig, dass Quellcode lesbar sein soll und für andere Entwickler möglichst lesbar sein soll.
Was allerdings als lesbar und leicht verständlich ist, ist nicht immer so eindeutig. Neben den vereinbarten Styleguides und den eigenen Vorlieben gibt es dann noch andere Kriterien.
Ein Beispiel ist die Validierung von Parametern. Viele generische Bibliotheken beinhalten entsprechende Funktionen. Von den von mir benutzten Bibliotheken dürften zwei der bekanntesten Google Guava und die schon zu den Altmeistern gehörende apache-commons Bibliothek sein. Beide beinhalten Methoden zur Validierung von Parametern. Bei Google handelt es sich um die Klasse Preconditions mit den checkArgument()-Funktionen, bei Apache Commons sind es die Methoden der Klasse Validate (für Java bis 1.4) bzw. Validate (für Java 5+). Egal für welche Variante man sich entscheidet, beide Methoden werfen eine IllegalArgumentException.
Jetzt habe ich ein Projekt, in dem beide Klassen (Guava und apache-commons) genutzt werden. Ob das sinnvoll ist, zwei sich soweit überdeckende allgemeine Bibliotheken zu nutzen, wäre einen eigenen Blogpost wert, daher will ich hier nicht weiter darauf eingehen. Allerdings stellt sich dann die Frage, ob man jetzt zu den Precondition-Klassen oder die Validate-Klassen bei der Argumentvalidierung greift:
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="3,7" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="4" toolbar="true"]
package de.kaiserpfalzEdv.blog.style;
import static com.google.common.base.Preconditions.checkArgument;
public class ValidationStyle {
public void checkWithPrecondition(String argument) {
checkArgument(argument != null && !argument.isEmpty(), "You have to give a not-null and not-empty argument!");
...
}
...
}
[/java]
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="3,7" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="4" toolbar="true"]
package de.kaiserpfalzEdv.blog.style;
import org.apache.commons.lang3.Validate.notEmpty;
public class ValidationStyle {
public void checkWithValidate(String argument) {
notEmpty(argument, "You have to give a not-null and not-empty argument!");
...
}
...
}
[/java]
Auf den ersten Blick sieht die apache-commons-Variante unten eleganter aus. Das ist sie auch, da die Google-Variante immer einen kompletten boolschen Ausdruck erfordert. Allerdings kann man - wenn man sowieso schon Guava und apache-commons eingebunden hat, auch zu einer dritten Variante greifen:
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="3-4,8" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="4" toolbar="true"]
package de.kaiserpfalzEdv.blog.style;
import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang3.StringUtils.isNotEmpty;
public class ValidationStyle {
public void checkWithPreconditionAndStringUtils(String argument) {
checkArgument(isNotEmpty(argument), "You have to give a not-null and not-empty argument!");
...
}
...
}
[/java]
In dieser Variante kann mn sofort erkennen, dass ein Argument überprüft wird ("checkArgument") und was denn geprüft wird ("isNotEmpty"). Im Moment ist diese Variante meine Lieblingsvariante. Aber ich bin für Anregungen offen. Die letzte Variante will ich nur noch der Vollständigkeit halber darstellen: alles selbst machen ...
[java autolinks="false" collapse="false" firstline="1" gutter="true" highlight="3,7-9" htmlscript="false" light="false" padlinenumbers="false" smarttabs="true" tabsize="4" toolbar="true"]
package de.kaiserpfalzEdv.blog.style;
import java.langIllegalArgumentException;
public class ValidationStyle {
public void checkWithPreconditionAndStringUtils(String argument) {
if (argument == null || argument.isEmpty) {
throw new IllegalArgumentException("You have to give a not-null and not-empty argument!");
}
...
}
...
}
[/java]
Touch background to close