Mit dem Projekt speichere ich den Weg, wie ich meine Kopie der Artenschutzdatenbank aufgebaut habe. So kann jederzeit eine neue Version der Artenschutzdatenbank erstellt werden, beispielsweise nach einem Update der Original Datenbank. Das Projekt ist weder universell, flexibel, konfigurierbar oder sonst was, sondern dient nur diesem einen Zweck.
Analyse
Auf der WISIA Seite kann man eine Recherche starten, zB zu "testudo":
Von dort gelangt man auf die jeweilige Seite einer Art, zB "Testudo hermanni":
Eine Analyse des HTML Source Codes der Seite zeigt, dass die Seite über ein Frameset zusammengebaut ist und der rechte Teil mit den Informationen eine eigene Seite ist. Diese Seite wird über einen eindeutigen Parameter, der Knoten ID, aufgerufen.
Für Testudo hermanni lautet die Knoten ID: 19442. Der Aufruf der Seite: https://www.wisia.de/GetTaxInfo?knoten_id=19442:
Auf der Seite finden sich strukturiert alle Informationen zu der Art. Leider ist es technisch nicht so, dass es eine leere Vorlage der Seite ist, die anschließend zB über einen REST Aufruf, der ein JSON Objekt zurückgibt, gefüllt wird. Das hätte es mir bedeutend einfacher gemacht. So muss ich für jede Art die komplette Seite laden und die Informationen selbst extrahieren.
Ablauf
flowchart TD
A[Seite herunterladen] -->|speichern| B
B[Informationen extrahieren] -->|speichern| C
C[Daten validieren] --> D
D[Daten verarbeiten] --> E
E[Daten exportieren] --> F
E --> G
F[SQL Script]
G[serialisierter Objekt Graph]
In einem ersten Schritt lade ich die jeweilige Seite einer Art auf meinen Arbeitsrechner herunter und speichere sie. Anschließend kann ich mit der gespeicherten Seite weiter arbeiten und muss sie nicht jedesmal neu ziehen, um darauf zuzugreifen.
Die Seiten werden als GZIPte Objekte gespeichert. Am Ende hatte ich über 7 GB gespeichert, bei einer Kompression auf ca. 25% habe ich also ca. 30 GB heruntergeladen.
Informationen extrahieren
Im nächsten Stritt werden die Informationen der Seite extrahiert und gespeichert.
Die Informationen werden als GZIPte Objekte gespeichert. Die gespeicherten Objekte haben eine Größe von knapp 43 MB.
Für das Verarbeiten der Seite habe ich HtmlUnit verwendet.
Seiten ohne gültigem Namen werden aussortiert, zu deren Knoten ID ist keine Art zugeordnet.
Seiten mit gültigem Namen aber ohne Taxonomie werden ebenfalls aussortiert.
Der gültige Name / wissenschaftliche Name ist nicht eindeutig, daher muss die eindeutige Knoten ID weiter verwendet werden.
Bei der Taxonomie bin ich davon ausgegangen, dass die Pfade alle eindeutig sind. Ich war überrascht, dass dem nicht so ist. Es existieren einige Einträge mit mehreren Elternknoten. Beispielsweise Gomphus kann über Gomphidae, aber auch über Gomphaceae erreicht werden. Das ist aber anscheinend korrekt, denn ich fand folgendes:
Duplicate name. This name, above species rank, is duplicated within the NCBI classification
Aus den extrahierten Daten muss ich eine Struktur ableiten und diese dann dorthin überführen.
Die verarbeiteten Daten werden als ein serialisiertes, gezippte Objekte gespeichert. Dieses ist weniger als 2MB groß und kann in wenigen Sekunden geladen werden.
Daten exportieren
Ursprünglich hatte ich die Idee, ein SQL-Schema zu schreiben und die Daten als INSERT-INTO-Scripte generieren zu lassen. Kann vielleicht nochmal kommen.
Letztendlich habe ich aber die Objekt Datei verwendet.
Intern benutzt Java UTF-16, "extern" wurde bisher standartmäßig das Encoding des Betriebssystems, zB Windows-1252, verwendet. Das hatte in der Vergangenheit immer wieder zu lustigen Encoding Problemen geführt, so dass ich schon lange meine Projekte und IDE Einstellungen auf UTF-8 umgestellt hatte. Ausnahme sind die Property-Dateien, die erst ab Java 9 UTF-8 können. Und Java 9 ist noch lange nicht in allen Projekten verfügbar.
Ich habe ein kleines Testprojekt aufgesetzt und dafür Java 19 verwendet. Dabei stieß ich irgendwann auf ein Encoding Problem, dass sich recht einfach nachstellen ließ:
package deringo;
public class TestMain {
public static void main(String[] args) {
String s = "abc äüß def";
System.out.println(s);
}
}
Output:
Projekt auf Java 17 umgestellt und der Output ist korrekt:
Die IDE ist Eclipse und der Bug ist bekannt: Bug 579383
Mit Java 17 wurde, als Vorbereitung auf JEP400, eine neue System Property eingeführt: native.encoding
Im letzten Post musste ich CVS in Eclipse nachinstallieren.
Der Download über die Update Seite hatte aber nicht auf Anhieb geklappt, denn Eclipse konnte sich nicht mit dem Update Server verbinden. Es gab ein Problem mit SSL: "PKIX path building failed"
Hintergrund ist, dass die Verbindung aus dem Firmen Intranet hinaus in das Internet über einen Proxy von ZScaler läuft, der eigene Zertifikate ausstellt und diese sind nicht im Java Truststore.
Ich hatte schon mal in einem anderen Setup das gleiche Problem: Man In The Middle (ZScaler) aber mit anderen Anforderungen und anderer Lösung.
Für mein Eclipse Problem muss ich zuerst die Zertifikate finden, dann den Eclipse Truststore und dann diesem die Zertifikate hinzufügen.
Zertifikate
Die Zertifikate habe ich ganz einfach aus dem Firefox gezogen: Über Settings -> Privacy & Security -> Certificates exportieren:
Truststore
Der Pfad zum Truststore des von Eclipse verwendeten Javas findet sich in der eclipse.ini Datei:
Allerdings nicht im /bin Folder, sondern in der Datei /lib/security/cacerts
Das Standart Passwort lautet: changeit
KeyStore Explorer
Man könnte die Zertifikate händisch per Command Line hinzufügen.
Oder man macht es sich leicht und verwendet ein Tool mit GUI. Da bietet sich der KeyStore Explorer an.
Einfach den KeyStore Explorer öffnen, cacerts-Datei hinein ziehen, Passwort (changeit) eingeben und anschließend die aus dem Firefox heruntergeladenen Zertifikate hineinziehen und importieren. Anschließend speichern und ggf. Eclipse neu starten.
Aus der IDE Eclipse wurde der CVS Support entfernt.
Eine lapidare Mitteilung verkündet das Ende einer Ära: link
Ist halt nur blöd, wenn man ein Update fährt und anschließend ist der CVS Support weg, den man aber essentiell für die Arbeit braucht, da die Projekte nun mal in einem CVS liegen. Warum sollte man auch das gute alte CVS verlassen, ist es doch immerhin so perfekt, dass schon seit Jahren keine Updates dafür entwickelt werden mussten. SVN hat unser CVS schon überlebt und ob Git sich durchsetzen wird, muss sich erst noch zeigen.
Das Gute ist: Man kann aus einer älteren Eclipse Version die CVS Funktionalität nachinstallieren. Die Anleitung dazu habe ich hier gefunden:
On Eclipse, click on Help -> Install New Software...
We have a very, very old application that needs to be migrated into AWS. So we copied all files into AWS EC2 instance and tried to start the application. After fixing a lot of minor problems we faced a tough challenge with a SAPJCO RFC Call.
The Exception message was something like this:
Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [sapjcorfc (Not found in java.library.path)]. java.library.path [/usr/lib/jvm/java-1.6.0-ibm.x86_64/jre/lib/amd64/default:/usr/lib/jvm/java-1.6.0-ibm.x86_64/jre/lib/amd64:/usr/lib]
at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
at java.lang.J9VMInternals.initializeImpl(Native Method)
at java.lang.J9VMInternals.initialize(J9VMInternals.java:199)
I guess, with a JCO Version 3 we would not have much trouble, but in this ancient application JCO Version 2 is used and we cannot update to Version 3 without a huge efford. In other projects I had the luck that I could migrate to Version.
The application is running on a Linux system. But belive me: it would have been much harder on a Windows machine.
Analysis
To find the cause of the problem I wrote the simpliest JCO Test Programm I can image:
import com.sap.mw.jco.JCO;
public class TestMain {
public static void main(String[] args) {
System.out.println(JCO.getVersion());
}
}
Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: librfccm.so: cannot open shared object file: No such file or directory]. java.library.path [/app/JCo]
at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
at TestMain.main(TestMain.java:11)
Need to set an environment property first:
export LD_LIBRARY_PATH=/app/JCo
Run command line to start programm again and got another error:
Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: libstdc++.so.5: cannot open shared object file: No such file or directory]. java.library.path [/app/JCo]
at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
at TestMain.main(TestMain.java:11)
The interesting part of the error message:
Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: libstdc++.so.5
Solution
We need the libstdc++.so.5 library, but installed is libstdc++.so.6
To get libstdc++.so.5 we installed package compat-libstdc++-33-3.2.3-66.x86_64:
yum install compat-libstdc++-33-3.2.3-66.x86_64
## to be honest, I am not exactly 100%% sure, what I did in my investigations, so the command may be a little differend, ex:
# yum install compat-libstdc++-33-3
# yum install compat-libstdc++-33
# yum install compat-libstdc++-33 libstdc++.so.5
Test
Run from command line:
java -cp ".:/app/JCo/sapjco.jar" TestMain
That gave me no error, but SAPJCo Version number.:
libstdc++.so.5 is a very old version of the standard c++ library.
Some Analysis Details
Writing this article is giving me the feeling, that this was all super easy. But in reality it was a real pain in the allerwertesten.
To isolate the source of the problem, I did not only write the small Java (JCO.getVersion) application, I also set up a Docker environment.
One challenge was to find a useful Docker image to start from. I started with an OpenJDK Image that was already deprecated. Deprecated was not the problem, but I could not install libstdc++.so.5.
Next I tried to use the newer, undeprecated Eclipse-Temurin Image. But still could not install libstdc++.so.5
So I finally ended in a Debian Image and self installed Java where I was able to install libstdc++5.
But there is one problem: You can only interact with a running container. But the TestMain-Programm is executed and immediately closed.
So I wrote another Test Programm, that keeps running, so I can enter the running container and test stuff (install packages, compile Java programm, etc.):
import java.io.BufferedReader;
import java.io.InputStreamReader;
import com.sap.mw.jco.JCO;
public class TestMain {
public static void main(String[] args) {
System.out.println("Hello World");
//System.out.println("JCO Version: " + JCO.getVersion());
while (true) {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
System.out.println("Enter Input : ");
try {
String s = br.readLine();
System.out.println(s);
}catch(Exception e) {
System.out.println(e);
}
}
}
}
Wir wollen unsere Anwendung durch einen Filter schützen, der nur Anfragen von eingeloggten Benutzern mit der richtigen Rolle hindurch lässt.
Für diesen PoC bauen wir einen Reverse Proxy für den Login und einen Anwendungsserver für den Filter. Der Anwendungsserver bekommt einen Zugang über den RP und einen Zugang ohne RP, um so zu zeigen, dass nur über den RP auf die Anwendung zugegriffen werden kann.
In produktiven Umgebungen darf es den Zugang ohne RP so ungeschützt nicht geben, da es sehr einfach ist, zB über ein Browser-Plugin, den RP mittels selbstgesetzter Header zu faken.
Reverse Proxy
Der Reverse Proxy bekommt grundsätzlich die gleiche Konfiguration wie in den Beispielen zuvor.
Hier müssen wir aber noch den Scope params hinzufügen, um so die OneLogin-Rollen des Benutzers zu übertragen:
## OIDCScope params
## to put params including roles into header
OIDCScope "openid email profile groups params"
Im nächsten Fenster des Dialoges die Value ("User Roles") setzen:
Access Filter
Der Access Filter prüft, ob eine UserID vorhanden ist. Das ist in unserem Beispiel die Email, es könnte aber auch der Preferred Username genommen werden.
Des weiteren prüft der Access Filter, ob der Benutzer die erforderliche Rolle user hat.
Sind beide Bedingungen erfüllt, kann auf die Anwendung zugegriffen werden, ansonsten wird lediglich "Access denied" angezeigt.
Im Docker File des Anwendungsservers kopieren wir erst nur das Maven POM, lassen dann Maven bauen, um so die Abhängigkeiten herunterzuladen. Erst danach kopieren wir das gesamte Projekt in den Container und lassen die Anwendung bauen. So wird nach Code Änderungen, ohne Anpassungen in Maven, der Bau des Images beschleunigt, da nicht jedes Mal die Bibliotheken heruntergeladen werden müssen.
FROM tomcat:8.5-jdk8-openjdk-slim
RUN apt update && apt install -y \
maven
COPY pom.xml /app/pom.xml
WORKDIR /app
RUN mvn package
COPY . /app
RUN mvn package
WORKDIR $CATALINA_HOME
RUN mv /app/target/ROOT.war webapps
EXPOSE 8080
CMD ["catalina.sh", "run"]
Test
Zugriff auf den Anwendungsserver über den Reverse Proxy nach Anmeldung:
http://localhost/private/test.html
Direkter Zugriff auf den Anwendungsserver ohne Anmeldung:
Im vorherigen Post haben wir gesehen, dass ein neuer Benutzer sich selbst registrieren kann, aber um den Zugang zur Anwendung zu erhalten, er noch durch einen User-Admin freigeschaltet/der Anwendung hinzugefügt werden muss.
Das Hinzufügen des Benutzers erfolgt in OneLogin, so dass der User-Admin auch in diesem Tool geschult werden müsste.
Um den Schulungsaufwand zu vermeiden und ein flüssigeres Arbeiten, ohne Wechsel der Anwendungen, zu ermöglichen, wollen wir einige Funktionalität von OneLogin in unserer Anwendung ermöglichen. Dazu gibt es in OneLogin eine API-Schnittstelle, die wir mit einem Java-Client ansteuern werden.
Der Java-Client logt sich einmalig mit Client ID und Secret (API Credentials! siehe unten) ein und erhält so ein Access Token, mit dem die weiteren API-Funktionen ausgeführt werden können.
Die API-Dokumentation, für Version 2, findet sich hier: https://developers.onelogin.com/api-docs/2/getting-started/dev-overview
Vorbereitung / Daten sammeln
API-Subdomain
Die API-Domain des Testprojekt ist: deringo-dev.onelogin.com
Die API-Sub-Domain lautet foglich: deringo-dev
API ID & Secret
Für die Benutzung der API wird Client ID und Client Secret benötigt. Das kennen wir schon aus einem vorherigen Post zu OneLogin:
Für den Zugriff über die API benötigen wir aber ein anderes Client ID & Secret Paar! Dieses findet sich in dem Developers-Tab unter API Credentials.
Dort ist ein entsprechender Zugang einzurichten, für das Test-Projekt haben wir mit Manage All eingerichtet:
OneLogin App ID
Die ID unserer Test-Anwendung findet sich in der URL nachdem man auf Applications -> Applications die App auswählt, in unserem Beispiel "1739301":
Java Programm aufsetzen
Abhängigkeiten
Das Projekt wird für Java 8 aufgesetzt und benötigt Apache HttpClient 4.5 und Json:
Zum Testen der API-Funktionen wird eine Test-Klasse mit den oben gesammelten Informationen angelegt und dann jeweils eine eigene Methode in der Klasse verwendet.
public class TestMain {
private static final String ONELOGIN_SUBDOMAIN = "deringo-dev";
// OneLogin Administration -> Applications -> select Application, get ID from URL
private static final String ONELOGIN_APP_ID = "1739301";
// OneLogin Administration -> Developers -> API Credentials
private static final String ONELOGIN_CLIENT_ID = "12345<geheim>67890";
private static final String ONELOGIN_CLIENT_SECRET = "12345<geheim>67890";
public static void main(String[] args) {
TestMain main = new TestMain();
main.sayHello();
}
private void sayHello() {
System.out.println("Hello World!");
}
}
Wenn zB nur die Rollen für unsere Test-App angezeigt werden sollen und der Rollen Name ein "u" enthalten muss, setzt sich der resourceURL-String wie folgt zusammen:
Wollen wir uns anstelle der gesamten User-Information lediglich Vor- und Nachname zurückgeben lassen, neben der ID, dann brauchen wir lediglich den Query Parameter anpassen:
Seitenweises Vor- oder Zurückblättern ist möglich, dazu gibt es den Parameter "cursor". Im Header der ersten Paginierten Response gibt es, falls vorhanden, After-Curser und Before-Curser. Über diese Werte kann vor- und zurückgeblättert werden.
Beispielsweise um die erste Seite zu erhalten und anschließend eine Seite weiter blättern:
Es soll zuerst ein neuer User angelegt werden. Anschließend bearbeitet, der Anwendung hinzugefügt und wieder entfernt und abschließend gelöscht werden.
HTTP/1.1 201 Created
{"firstname":null,"updated_at":"2022-06-01T14:34:52.423Z","role_ids":[],"invitation_sent_at":null,"member_of":null,"distinguished_name":null,"email":null,"id":178924216,"password_changed_at":null,"manager_ad_id":null,"group_id":null,"invalid_login_attempts":0,"phone":null,"title":null,"preferred_locale_code":null,"department":null,"samaccountname":null,"lastname":null,"custom_attributes":{"my_role":null},"created_at":"2022-06-01T14:34:52.423Z","directory_id":null,"locked_until":null,"status":7,"company":null,"activated_at":null,"last_login":null,"trusted_idp_id":null,"manager_user_id":null,"username":"min.requirements","comment":null,"external_id":null,"state":1,"userprincipalname":null}
new User ID: 178924216
Nächstes Beispiel mit Vor-, Nach- und Usernamen, Passwort und schönerem JSON:
HTTP/1.1 201 Created
{"firstname":"Happy","updated_at":"2022-06-01T14:43:30.646Z","role_ids":[],"invitation_sent_at":null,"member_of":null,"distinguished_name":null,"email":null,"id":178924824,"password_changed_at":"2022-06-01T14:43:30.629Z","manager_ad_id":null,"group_id":null,"invalid_login_attempts":0,"phone":null,"title":null,"preferred_locale_code":null,"department":null,"samaccountname":null,"lastname":"Gilmore","custom_attributes":{"my_role":null},"created_at":"2022-06-01T14:43:30.646Z","directory_id":null,"locked_until":null,"status":1,"company":null,"activated_at":"2022-06-01T14:43:30.644Z","last_login":null,"trusted_idp_id":null,"manager_user_id":null,"username":"happy.gilmore","comment":null,"external_id":null,"state":1,"userprincipalname":null}
new User ID: 178924824
Unser neuer User Happy Gilmore ist angelegt und kann sich mit seinem Username & Passwort auch anmelden, kommt aber, wie erwartet, noch nicht auf die Anwendung, da er ihr noch nicht zugewiesen wurde:
HTTP/1.1 200 OK
{"firstname":"Very Happy","state":1,"manager_user_id":null,"department":null,"comment":null,"created_at":"2022-06-01T14:43:30.646Z","email":null,"last_login":"2022-06-01T14:46:10.795Z","lastname":"Gilmore","activated_at":"2022-06-01T14:43:30.644Z","directory_id":null,"updated_at":"2022-06-01T14:57:22.656Z","group_id":null,"samaccountname":null,"status":1,"userprincipalname":null,"trusted_idp_id":null,"title":null,"manager_ad_id":null,"invalid_login_attempts":0,"locked_until":null,"phone":null,"role_ids":[],"invitation_sent_at":null,"company":null,"distinguished_name":null,"external_id":null,"password_changed_at":"2022-06-01T14:43:30.629Z","preferred_locale_code":null,"id":178924824,"member_of":null,"username":"happy.gilmore","custom_attributes":{"my_role":null}}
User ID: 178924824
Firstname: Very Happy
User einer Anwendung hinzufügen
Als nächstes sollte der User einer Anwendung hinzugefügt werden, um so den Zugang zu gewähren.
Überraschenderweise gibt es aber keine API, um einer App einen User hinzuzufügen. irgendwie ist das nicht ganz nachzuvollziehen, da dies über die OneLogin-Webseite möglich ist und es einen API Call gibt, um alle User einer App anzeigen zu lassen (List App Users - OneLogin API).
Ein Erklärungs- und somit Lösungsansatz lautet:
The proper way to do this is to assign applications to a Role in the admin console and then use the APIs to set the roles for the user.
This, in turn grants the user access to the application assigned to the role.
Die Überprüfung in der OneLogin Webseite ergibt, dass der User in der Tat hinzugefügt wurde, und es wurden auch keine vorhandenen User aus der Rolle entfernt, die nicht in dem Array enthalten waren.
Unser neuer User Happy Gilmore ist angelegt und kann sich mit seinem Username & Passwort auch anmelden, kommt jetzt auf die Anwendung, da er ihr über die Rolle "user" zugewiesen wurde:
Um Daten von einem bestimmten Webservice beziehen zu können, muss der Aufruf durch einen authentifizierten User erfolgen. Der Webservice steht im Intranet des Kunden und der Aufruf im Browser funktioniert ohne Authentifizierung, denn im Hintergrund wird der Windows User übermittelt.
Der Aufruf über Java funktioniert nicht, da kein User übermittelt wird.
Die Authentifizierung am Webservice erfolgt lt. Ansprechpartner durch eine Windows Authentifizierung, was technischer ausgedrückt NTLM sein sollte.
Test Projekt aufsetzen
Um den Zugang zu testen ohne dabei den ganzen Ballast der großen Anwendung mitschleppen zu müssen, wird ein neues Projekt zum Testen aufgesetzt.
Das Projekt wird auf Java 8 konfiguriert und kommt mit einer einzigen Abhängigkeit aus: Dem Apache HTTPClient 4.5
Der Code funktioniert, wirft aber noch eine WARNING mit aus:
Mai 30, 2022 4:15:35 PM org.apache.http.client.protocol.RequestTargetAuthentication process
WARNING: NEGOTIATE authentication error: No valid credentials provided (Mechanism level: No valid credentials provided (Mechanism level: Failed to find any Kerberos tgt))
200 OK
Response body: [{"Vkorg":"","VkorgDesc":"TEST Korea Limited"}]
Test 2
public static void test02() throws Exception {
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(18);
cm.setDefaultMaxPerRoute(6);
RequestConfig requestConfig = RequestConfig.custom()
.setSocketTimeout(30000)
.setConnectTimeout(30000)
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM))
.setProxyPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
.build();
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY,
new NTCredentials(username, password, "", ""));
// Finally we instantiate the client. Client is a thread safe object and can be used by several threads at the same time.
// Client can be used for several request. The life span of the client must be equal to the life span of this EJB.
CloseableHttpClient httpclient = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultRequestConfig(requestConfig)
.build();
HttpGet httpGet = new HttpGet(url);
// HttpClientContext is not thread safe, one per request must be created.
HttpClientContext context = HttpClientContext.create();
try ( CloseableHttpResponse response = httpclient.execute(httpGet, context) ) {
StatusLine statusLine = response.getStatusLine();
System.out.println(statusLine.getStatusCode() + " " + statusLine.getReasonPhrase());
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
System.out.println("Response body: " + responseBody);
}
}
Der Code funktioniert und wirft keine Warnung mehr aus.
Die Konfiguration in Java war für mich jahrelang kein Problem, denn ich durfte mit einem Framework arbeiten, dass die Konfiguration sehr flexibel und komfortabel gelöst hat.
Beispielsweise konnte die URL für ein angebundenes System für die verschiedenen Stages ganz einfach in einer ini-Datei hinterlegt werden:
Diese ini-Datei ist Teil des Java Projekts und liegt im Classpath.
In der ini-Datei sind alle Informationen zu allen Stages zum OtherSystem gespeichert, was ich immer sehr übersichtlich und leicht zu pflegen fand.
Für die laufende Anwendung muss dann lediglich die Stage festgelegt werden, in welcher sie läuft und dann wird die Konfiguration passend zur Stage gezogen. Die Stage kann definiert werden über eine Konfiguration mit der Zuweisung über den HostName, eine System Property (zB im Tomcat definiert) oder über eine lokale Konfigurationsdatei.
Praktisch ist auch die Möglichkeit, über die lokale Konfigurationsdatei einzelne Konfigurationen überschreiben zu können. So ist es beispielsweise möglich, auf dem Entwicklerrechner in der DEV-Stage zu laufen, aber die Verbindung zur PROD DB zu konfigurieren um einen Bug zu reproduzieren.
Das Thema Sicherheit lasse ich bewusst außen vor, denn hier soll es einzig um die Konfiguration gehen.
Als ich dann ein Projekt in einem anderen Kundenkreis startete, und das propritäre Framework nicht mehr verwenden konnte, war ich schon sehr erstaunt, dass es anscheinend keine schlanke, flexible Möglichkeit der Konfiguration im Java SE Umfeld gibt.
Also muss ich selbst etwas basteln, etwas kleines, leichtgewichtiges und trotzdem flexibles.
Anforderung
Von dem Luxus, sämtliche Konfigurationen per Präfix in verschiedenen Dateien im Projekt zu hinterlegen, muss ich mich verabschieden. Statt dessen wird es eine Konfigurationsdatei im Projekt geben, deren Konfiguration dann von außen überschrieben werden muss. Beispielsweise mit den Datenbankverbindungsparametern auf dem PROD Server. Aufgrund der geringeren Komplexität des Projektes ist das aber durchaus ausreichend.
Die im Projekt hinterlegte Standard-Konfiguration soll über eine lokale Konfigurationsdatei überschrieben werden können. Dazu muss eine Umgebungsvariable (System Environment, bzw. System Property) "localconf" gesetzt werden, die auf diese Datei zeigt.
Außerdem sollen einzelne Konfigurationen über Umgebungsvariablen (System Environment, bzw. System Property) gesetzt werden können.
In den Umgebungsvariablen stehen sehr viele Konfigurationen, wie zB JAVA_HOME,TMP, user.name etc., welche nicht direkt mit der Anwendung zu tun haben. Ob diese Werte auch in unserer Anwendungskonfiguration aufgenommen werden sollen, wird über eine Property "config.includeSystemEnvironmentAndProperties" gesteuert.
Umsetzung
Zum Nachlesen dokumentiere ich hier ein paar Schritte aus dem Code, das Ganze soll später auch in einem GitHub Projekt landen.
Zuerst die Properties aus System Environment und System Properties sammeln:
// System Environment
Properties systemEnvironmentProperties = new Properties();
systemEnvironmentProperties.putAll(System.getenv());
// System Properties
Properties systemPropertiesProperties = new Properties();
systemPropertiesProperties.putAll(System.getProperties());
Die BaseProperties / Standard Properties aus dem ClassPath der Anwendung laden, sie müssen unter: /src/main/resources/application.properties gespeichert sein:
String basePropertiesFilename = "application.properties";
Properties baseProperties = new Properties();
try {
InputStream is = Config.class.getClassLoader().getResourceAsStream(basePropertiesFilename);
baseProperties.load(is);
} catch (Exception e) {
logger.error("Could not read {} from ClassLoader", basePropertiesFilename, e);
}
Falls LocalProperties geladen werden sollen, muss der Pfad zu der Datei in der Umgebungsvariablen "localconf" übergeben werden:
String localPropertiesProperty = "localconf";
Properties localProperties = new Properties();
logger.debug("----------------------------------------------------------------------------------");
logger.debug("LocalProperties Path from System Environment: {}", systemEnvironmentProperties.getProperty(localPropertiesProperty));
logger.debug("LocalProperties Path from System Properties: {}", systemPropertiesProperties.getProperty(localPropertiesProperty));
String localPropertiesPath = systemPropertiesProperties.getProperty(localPropertiesProperty) != null ? systemPropertiesProperties.getProperty(localPropertiesProperty) : systemEnvironmentProperties.getProperty(localPropertiesProperty);
if (localPropertiesPath == null) {
logger.debug("LocalProperties Path is not set, skip loading Local Properties");
} else {
logger.debug("Load LocalProperties from {}", localPropertiesPath);
try {
localProperties.load(new FileInputStream(localPropertiesPath));
} catch (Exception e) {
logger.error("Could not read {} from File", localPropertiesPath, e);
}
}
Sollen die Umgebungsvariablen auch übernommen werden:
String includeSystemEnvironmentAndPropertiesProperty = "config.includeSystemEnvironmentAndProperties";
String includeS = Stream.of(
systemPropertiesProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
systemEnvironmentProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
localProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
baseProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
Boolean include = Boolean.parseBoolean(includeS);
Abschließend alle Properties mergen:
Properties mergedProperties = new Properties();
mergedProperties.putAll(baseProperties);
mergedProperties.putAll(localProperties);
if (include) {
mergedProperties.putAll(systemEnvironmentProperties);
mergedProperties.putAll(systemPropertiesProperties);
} else {
mergedProperties.forEach((key, value) -> {
value = systemEnvironmentProperties.getProperty((String)key, (String)value);
value = systemPropertiesProperties.getProperty((String)key, (String)value);
mergedProperties.setProperty((String)key, (String)value);
});
}
Beispiel
In dem vorherigen Post hatte ich die Konfigurierbarkeit von JPA EntityManagerFactory im Code so gelöst:
import static org.hibernate.cfg.AvailableSettings.SHOW_SQL;
Properties properties = new Properties();
Optional.ofNullable(System.getenv(SHOW_SQL)).ifPresent( value -> properties.put(SHOW_SQL, value));
Optional.ofNullable(System.getenv(JPA_JDBC_URL)).ifPresent( value -> properties.put(JPA_JDBC_URL, value));
Optional.ofNullable(System.getenv(JPA_JDBC_USER)).ifPresent( value -> properties.put(JPA_JDBC_USER, value));
Optional.ofNullable(System.getenv(JPA_JDBC_PASSWORD)).ifPresent( value -> properties.put(JPA_JDBC_PASSWORD, value));
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);
Das lässt sich jetzt einfacher über die Config lösen:
Zuletzt kochte die Log4J Lücke hoch, so dass man sich mit dem Thema Logging auseinander setzen musste.
Mich betraf der Bug nicht besonders, nach eingehender Analyse stellte sich heraus, dass keines meiner im Betrieb befindlichen Projekte Log4J verwendet. Ein Paar Projekte, die ich jahrelang betreuen durfte, waren betroffen, aber für die bin ich nicht mehr verantwortlich und war nur beratend tätig und habe meine Einschätzung und Handlungsempfehlung abgegeben.
Allerdings trägt das grade in der Entwicklung, aber noch nicht in Betrieb gegangene, Projekt Log4J in sich, so dass das Thema vor dem GoLive angegangen werden muss.
Java Logging
Einen sehr schönen, pragmatischen Einstieg in Java Util Logging habe ich auf Java Code Geeks gefunden.
Das einfachste Beispiel, um einen Ausgabe auf der Console zu erhalten:
package deringo.jpa;
import java.util.logging.Logger;
public class TestMain {
public static void main(String[] args) throws Exception {
Logger logger = Logger.getLogger(TestMain.class.getName());
logger.warning("Dies ist nur ein Test!");
}
}
Ein paar Code Beispiele:
package deringo.jpa;
import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
public class TestMain {
public static void main(String[] args) throws Exception {
Logger logger = Logger.getLogger(TestMain.class.getName());
logger.warning("Dies ist nur ein Test!");
// Warnung wird ausgegeben, Fine nicht
logger.fine("Eine fine Nachricht. 1");
logger.warning("Eine warnende Nachricht. 1");
// Das Level des Loggers auf ALL setzen
logger.setLevel(Level.ALL);
// Trotzdem: Warnung wird ausgegeben, Fine nicht
logger.fine("Eine fine Nachricht. 2");
logger.warning("Eine warnende Nachricht. 2");
// Einen Handler für den Logger definieren, der Handler Level wird auf ALL gesetzt
Handler consoleHandler = new ConsoleHandler();
consoleHandler.setLevel(Level.ALL);
logger.addHandler(consoleHandler);
// Warnung wird ausgegeben, Fine wird ausgegeben
// ABER: Warnung wird doppelt ausgegeben
logger.fine("Eine fine Nachricht. 3");
logger.warning("Eine warnende Nachricht. 3");
}
}
Überraschend ist erstmal, dass die dritte Ausgabe, zumindest für die Warnung, doppelt erscheint.
Die Erklärung ist, dass es noch einen Root Logger gibt, welcher der Parent des TestMain Loggers ist. Standardmäßig gibt ein Logger seine Einträge an den Parent Logger weiter. Bzw. an die Handler des Parent Loggers. Der Root Logger hat die ersten Logs ausgegeben, als der TestMain Logger noch gar keinen Handler hatte, der die Log Einträge verarbeiten konnte.
Wird die Weitergabe an den Parent Handler deaktiviert, wird nicht mehr doppelt geloggt:
package deringo.jpa;
import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
public class TestMain {
public static void main(String[] args) throws Exception {
Logger logger = Logger.getLogger(TestMain.class.getName());
// Warnung wird ausgegeben, Fine nicht
logger.fine("Eine fine Nachricht. 1");
logger.warning("Eine warnende Nachricht. 1");
// Nicht an Parent Handler weiter reichen
logger.setUseParentHandlers(false);
// Warnung wird NICHT mehr ausgegeben, Fine ebenfalls nicht
logger.fine("Eine fine Nachricht. 2");
logger.warning("Eine warnende Nachricht. 2");
// Eigenen Handler definieren
Handler consoleHandler = new ConsoleHandler();
consoleHandler.setLevel(Level.ALL);
logger.addHandler(consoleHandler);
// Warnung wird ausgegeben, Fine wird NICHT ausgegeben
// Warnung wird NICHT doppelt ausgegeben
logger.fine("Eine fine Nachricht. 3");
logger.warning("Eine warnende Nachricht. 3");
// Das Level des Loggers auf ALL setzen
logger.setLevel(Level.ALL);
// Warnung wird ausgegeben, Fine wird ausgegeben
// Warnung wird NICHT doppelt ausgegeben
logger.fine("Eine fine Nachricht. 4");
logger.warning("Eine warnende Nachricht. 4");
}
}
Java Logging - Konfiguration per Datei
Möchte man die Konfiguration des Java Util Loggers nicht per Code, wie oben, vornehmen, sondern per Datei findet sich ein guter Einstieg auf Wikibooks.
Davon abgeleitet meine Konfigurationsdatei logging.properties, die ich in src/main/resources abgelegt habe:
# Der ConsoleHandler gibt die Nachrichten auf std.err aus
#handlers= java.util.logging.ConsoleHandler
# Alternativ können weitere Handler hinzugenommen werden. Hier z.B. der Filehandler
handlers= java.util.logging.FileHandler, java.util.logging.ConsoleHandler
# Festlegen des Standard Loglevels
.level= INFO
############################################################
# Handler specific properties.
# Describes specific configuration info for Handlers.
############################################################
# Die Nachrichten in eine Datei im Benutzerverzeichnis schreiben
java.util.logging.FileHandler.pattern = d:/java%%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter
java.util.logging.FileHandler.level = ALL
# Zusätzlich zu den normalen Logleveln kann für jeden Handler noch ein eigener Filter
# vergeben werden. Das ist nützlich wenn beispielsweise alle Nachrichten auf der Konsole ausgeben werden sollen
# aber nur ab INFO in das Logfile geschrieben werden soll.
java.util.logging.ConsoleHandler.level = ALL
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
############################################################
# Extraeinstellungen für einzelne Logger
############################################################
# Für einzelne Logger kann ein eigenes Loglevel festgelegt werden.
deringo.jpa.TestMain.level = FINEST
Leider funktionierte es nicht. Es wird nach wie vor die originale logging.properties von Java genommen, die im Java Installationsverzeichnis $JAVA_HOME/jre unterhalb des lib Verzeichnises liegt, bzw. ab Java 9 in $JAVA_HOME/conf. Vgl. Mkyong
Falls nicht die Original-logging.properties-Datei benutzt werden soll, kann über die System-Property java.util.logging.config.file die stattdessen zu verwendende Datei angegeben werden.
Wie das praktisch geht, kann bei Mkyong nachgesehen werden.
Ich habe folgenden Code verwendet:
package deringo.jpa;
import java.util.logging.Logger;
public class TestMain {
public static void main(String[] args) throws Exception {
String path = TestMain.class.getClassLoader().getResource("logging.properties").getFile();
System.setProperty("java.util.logging.config.file", path);
Logger logger = Logger.getLogger(TestMain.class.getName());
// Warnung wird ausgegeben, Fine wird ausgegeben
// Beides auf der Console und in der Datei D:/java0.log
logger.fine("Eine fine Nachricht. 1");
logger.warning("Eine warnende Nachricht. 1");
}
}
Es wird in der Console und der definierten Datei geloggt.
Warum die logging.properties des Projektes nicht standartmäßig anstelle der Java logging.properties gezogen wird, kann ich mir nicht erklären.
SLF4J
SLF4J ist kein Logging Framework, sondern eine Fassade vor der eigentlichen Implementierung. Man kann also im Code mit SLF4J loggen und SLF4J leitet das dann an das gewählte Framework, zB Java Util Logging oder Log4J weiter. So kann man das Logging Framework austauschen ohne den Code anfassen zu müssen.
Ob das jemals jemand vor dem Log4J Bug gemacht hat lasse ich mal dahingestellt, mir gefällt aber das eingebaute Templating, bzw. Parameterisierung, von SLF4J:
Object entry = new SomeObject();
logger.debug("The entry is {}.", entry);
package deringo.jpa.repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SLF4JTest {
public static void main(String[] args) {
Logger logger = LoggerFactory.getLogger(SLF4JTest.class);
logger.info("Hallo Welt!");
}
}
Folgende Grafik aus dem SLF4J Manual zeigt, dass nach /dev/null geloggt wurde:
Es wird also eine Logging Framework Implementierung benötigt.
Ich entscheide mich für das Java Util Logging Framework, denn dieses ist in Java bereits enthalten und ich muss keine weitere Bibliothek, wie zB Log4J, in mein Projekt einbinden.
Es kommt also eine weitere Maven Abhängigkeit hinzu:
package deringo.jpa.repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SLF4JTest {
public static void main(String[] args) {
Logger logger = LoggerFactory.getLogger(SLF4JTest.class);
logger.info("Hallo Welt!");
}
}
Führt jetzt zu folgender Ausgabe:
Äquivalent zu dem Code Beispiel zu Java Util Logging - Konfiguration per Datei weiter oben, führt folgender Code zusätzlich zu einem Logging in einer Datei:
package deringo.jpa.repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SLF4JTest {
public static void main(String[] args) {
String path = SLF4JTest.class.getClassLoader().getResource("logging.properties").getFile();
System.setProperty("java.util.logging.config.file", path);
Logger logger = LoggerFactory.getLogger(SLF4JTest.class);
logger.info("Hallo Welt!");
}
}