Maja framework_de - SoltauFintel/maja-web GitHub Wiki

Maja Framework

Mit Maja entwickelt man Web Apps, also dynamische Webseiten.

Maja ist ein schlankes, modulares, modernes Web Framework und bringt die Sachen mit, die man oft braucht. Es erweitert das Java 8 Web Framework Spark um folgende Features:

  • Action Klassen
  • Templates
  • Login
  • Logging
  • Build und Deployment
  • Responsive Web Design
  • Persistenz
  • Cache

Maja Web Apps laufen gewöhnlich produktiv auf einem Linux Server (z.B. domainfactory Jiffybox) als Docker Container. Die Web Apps sind also eher klein, haben also Microservice Größe. Das Aktualisieren einer produktiven Maja Web App geht schnell und einfach (2 Klicks und unter 1 Minute). Die Entwicklung erfolgt mittels der Eclipse IDE (z.B. mit Version Mars.2) auf einem Windows 10 PC. O.g. Features werden mit den folgenden Technologien realisiert:

Maja Entwicklung erklärt

Statt einer klassischen Dokumentation beschreibe ich nun die Entwicklung einer Web App mit Maja. Unsere erste Web App "tankstellen" soll einen Facebook-Login inkl. Remember-Me-Funktion anbieten. Dafür wird auch die MongoDB benötigt. Die Beispiel-App soll Tankstellennummern und -bezeichnungen verwalten. Die Sourcefiles der fertigen tankstellen-App sind unter Github einzusehen.

Vorbedingungen:

  • Kenntnisse bzgl. Java 8, Gradle, Eclipse
  • Docker und MongoDB Server auf Entwicklungsrechner

Von mir verwendete Versionen: Java=8(121), Gradle=3.3, Eclipse=4.5.2, Docker=1.13.1, MongoDB=2.6.4

Server

Als Server kann man sich bspw. bei domainfactory eine Jiffybox (2 Cores kosten ohne SSD 20 EUR im Monat) besorgen (zzgl. Web Domain). Dort hat man (im Vergleich zu einfacheren Webhostinglösungen) root-Rechte und kann somit Java-Programme ausführen. Eigentlich braucht man auf der Jiffybox nur Docker zu installieren. Alles andere läuft jeweils als Docker Container. Auf dem Server läuft ein Container des registry:2.5 Images. In diese private Registry kann man Docker Images hochladen. Als Webserver auf Port 80 empfiehlt sich übrigens nginx. Die Maja Web Apps laufen auf eigenen Ports. Man kann aber im nginx sehr einfach eine Subdomain für jede App einrichten und erspart sich so die Portangabe in der URL.

Lokal benötigt man eine Docker Installation. Docker muss gestartet sein, damit Docker Images gebaut werden können.

Die Einrichtung der Jiffybox, der Docker Installationen samt registry und nginx würde den Rahmen dieser Anleitung sprengen. Domainfactory bzw. Docker bieten aber Anleitungen im Web an. Ich empfehle Ubuntu 14 für die Jiffybox. Ältere Linux Distributionen sind bzgl. Docker problematisch.

Neues Java Project

Es wird im Eclipse in der Java Perspective ein neues normales Java Project mit Source Folder src/main/java angelegt. Unter 'Window > Preferences > Java > Build Path > Source folder name' kann man übrigens src/main/java eingeben, damit jedes neue Eclipse Project automatisch diesen Source Folder erhält.

Als Ausgangslage für den Buildprozess kopiert man die Dateien

  • build.gradle,
  • build.xml und
  • gradlew.bat sowie den
  • gradle Ordner

aus einem bereits vorhandenen Project in das neue Project. In der build.xml trägt man in Zeile 2 den name ein (Name des Projects). In der build.gradle sind ein paar Einstellungen vorzunehmen:

  • mainClassName = vollqualifizierter Klassenname der main-Klasse
  • ports im docker Abschnitt, bspw. 8008 (Wenn man das vergisst gibt es später seltsame Fehler.)
  • imagename = RegistryHost + ":" + RegistryPort + "/" + Name des Projects

Die anderen Einstellungen sind gewöhnlich für jedes Project gleich; müssen also nur einmalig eingegeben werden.

Maja wird übrigens mittels Jitpack eingebunden. Coole Sache! Mir als Framework-Autor erspart es das Deployment auf MavenCentral.

	dependencies {
		compile 'com.github.SoltauFintel:maja-auth-mongo:0.1.1'
	// ...
	repositories {
		maven { url 'https://jitpack.io' }
	// ...

Nun ist der Buildbefehl eclipse auszuführen (welcher alle JARs lädt) und das Java Project zu refreshen.

App-Klasse schreiben und Anwendung starten

Es wird nun im Sourcefolder die Klasse github.soltaufintel.tankstellen.TankstellenApp samt main() Methode angelegt. Die App-Klasse erbt von de.mwvb.maja.web.AbstractWebApp.

package github.soltaufintel.tankstellen;

import de.mwvb.maja.web.AbstractWebApp;

public class TankstellenApp extends AbstractWebApp {
	public static final String VERSION = "0.1.0";

	public static void main(String[] args) {
		new TankstellenApp().start(VERSION);
	}

	@Override
	protected void routes() {
	}
}

Damit man obige Klasse starten kann müssen noch folgende Dateien angelegt werden:

  • Sourcefolder src/main/resources
  • darin die Dateien banner.txt und favicon.ico
  • banner.txt enthält bspw. einfach "Tankstellen App". Das ist der Text der beim Hochfahren der Webapp in der Console angezeigt wird. Tipp: eine größere Schriftart mittels ASCII Generator verwenden (bspw. Schriftart Slant).
  • favicon.ico kann bspw. eine 16x16 Pixel große PNG-Grafikdatei sein. Diese Datei wird im Browser angezeigt. Falls die Anzeige des favicon beim Bookmarken im Firefox nicht funktioniert: Bookmark löschen und dann Web App und Firefox beenden und neu starten.
  • Außerdem die Datei AppConfig.properties auf gleicher Ebene wie build.gradle. Sie muss port=8008 enthalten.

Nun kann man http://localhost:8008/rest/_ping aufrufen. Es wird "pong" im Browser angezeigt. Die Web App läuft.

Vor jedem Deployment-in-production soll die Versionsnummer hochgezählt werden. Die Versionsnummer wird beim Hochfahren der App in der Console angezeigt und bietet somit die Kontrolle, ob wirklich die neueste App aktiv ist.

Model

Bevor die Startseite samit Login angelegt wird, müssen noch Model Dateien angelegt werden.

package github.soltaufintel.tankstellen.model;

import org.mongodb.morphia.annotations.Entity;
import org.mongodb.morphia.annotations.Id;
import org.mongodb.morphia.annotations.Indexed;

@Entity
public class Tankstelle {
	@Id
	private String id;
	@Indexed
	private String userId;
	private int nummer;
	private String bezeichnung;
// Getter/Setter...

Best-practise in der modernen Entwicklung ist es, eine GUID als Primary key zu verwenden. Das id-Attribut ist also vom Typ String. Da die Tankstellen benutzerspezifisch erfasst werden sollen, benötigen wir eine userId, ebenfalls vom Typ String da es ein Foreign key ist. Danach stehen die eigentlichen Nutzdaten.

Als weitere Klasse benötigen wir das DAO:

package github.soltaufintel.tankstellen.model;

import de.mwvb.maja.mongo.AbstractDAO;
import de.mwvb.maja.mongo.Database;
import github.soltaufintel.tankstellen.TankstellenApp;

public class TankstelleDAO extends AbstractDAO<Tankstelle> {

	public TankstelleDAO(Database database) {
		super(TankstellenApp.database, Tankstelle.class);
	}
}

Die database Variable fehlt noch in der App-Klasse:

	public static Database database;
	public static final String DBNAME = "tankstellen";

	@Override
	protected void initDatabase() {
		database = new Database(config.get("dbhost", "localhost"),
				config.get("dbname", DBNAME),
				config.get("dbuser"), config.get("dbpw"),
				Tankstelle.class);
	}
	
	@Override
	protected String getDatabaseInfo() {
		return "MongoDB database: " + config.get("dbname", DBNAME)
			+ "@" + config.get("dbhost", "localhost")
			+ (config.get("dbpw") == null ? "" : " with password");
	}

Sofern MongoDB auf dem EntwicklungsPC läuft, funktioniert das Ganze schon. In-production müssen jedoch Angaben in der AppConfig.properties gemacht werden, da hier die MongoDB-Datenbank passwortgeschützt sein sollte.

Test

Wer's nicht glaubt, kann einen kleinen JUnit Testcase schreiben. Hierzu muss in der build.gradle unter dependencies

	testCompile 'junit:junit:4.12' 

eingetragen werden und dann der Buildbefehl eclipse ausgeführt werden und das Project refresht werden. Anschließend fehlt noch der Sourcefolder src/test/java.

TankstellenApp muss um diese Methode erweitert werden:

	public static void startForTest() {
		TankstellenApp app = new TankstellenApp();
		app.initConfig();
		app.initDatabase();
	}

Der Testcase sieht dann so aus:

package github.soltaufintel.tankstellen.model;

import org.junit.BeforeClass;
import org.junit.Test;

import github.soltaufintel.tankstellen.TankstellenApp;

public class TankstelleTest {
	private static TankstellenApp app;
	
	@BeforeClass
	public static void openDatabase() {
		app = new TankstellenApp();
		app.startForTest();
	}
	
	@Test
	public void test() {
		TankstelleDAO dao = new TankstelleDAO(TankstellenApp.database);

		Tankstelle star = new Tankstelle();
		star.setNummer(1);
		star.setBezeichnung("Star");
		star.setId(TankstelleDAO.code6(TankstelleDAO.genId()));
		star.setUserId("TEST");
		
		dao.save(star);
	}
}

Ein paar Details zur Belegung des Id-Feldes: Mit genId() wird eine neue GUID erzeugt. Diese besteht aus dem Zeichensatz 0-9A-F und ist 32 Zeichen lang. Für die Verwendung in einer URL etwas unschön. Daher gibt es noch die code6() Methode, welche aus der GUID einen 6 Zeichen langen String mit Zeichensatz 0-9A-Z macht.

Nach der Ausführung des Testcases ist ein Datensatz in der MongoDB. Dies können wir mit den folgenden Befehlen in einer DOS-Box überprüfen:

mongo tankstellen
	db.Tankstelle.find()
	db.Tankstelle.remove({})   // löscht alle Tankstellen
	exit

Anmeldeseite

Nun wird der src/main/resources Ordner bestückt. Im Ordner templates liegen alle Vorlagen. Im Ordner web liegen statische Dateien, die per HTTP erreichbar sein sollen.

Wir brauchen aus github/tankstellen folgende Ordner bzw. Dateien:

  • templates/master.html (In dieser Datei muss der Programmname "Tankstellen" eingetragen werden.)

  • templates/menu.html (In dieser Datei muss ggf. das Menü angepasst werden. Iconnamen)

  • web/bootstrap

  • web/bootstrap/fonts

  • web/fontawesome

  • web/fonts

  • web/metisMenu

  • web/sbadmin2

  • templates/login.html ist die Startseite wenn man noch nicht angemeldet ist und sieht so aus:

      #parse("/templates/master.html")
      #@master()
      
      <div class="row"><div class="col-lg-12">
      
      	<h1 class="page-header">Tankstellen</h1>
      
      	<p style="padding-top: 1em;">Bitte melden Sie sich an.</p>
      	<p style="padding-top: 1em; width: 270px;">
      		<a href="/login/facebook" class="btn btn-block btn-social btn-facebook">
      		<i class="fa fa-facebook"></i> Anmelden mit Facebook</a>
      	</p>
      
      </div></div>
      
      #end
    

Durch Hinzufügen von

	@Override
	protected void init() {
		auth = new AuthPlugin(
				new FacebookAuthorization(),
				new FacebookFeature(),
				new RememberMeInMongoDB(database));
	}

in TankstellenApp verzweigt der Aufruf von http://localhost:8008 nun auf die Anmeldeseite login.html. Noch funktioniert die Anmeldung via Facebook nicht, aber es wird bereits das Bootstrap Theme SB Admin 2 angezeigt, welches auch auf dem Smartphone schön aussieht.

Facebook-Login

WICHTIG: Das mit dem Facebook- und Google-Login ist überholt. Seitdem Facebook auf https umgestellt hat, funktioniert das nicht mehr.

Damit keine eigene Benutzerverwaltung entwickelt werden musste und Benutzer sich nicht ständig für jede App registrieren müssen, wurde ein Facebook-Login in Maja implementiert. Ebenso ein Google-Login für Leute, die nicht auf Facebook sind. Die Logintechnik basiert auf Scribejava, was das OAuth-Protokoll implementiert. Für jede Web App muss man im Developer Center von Facebook (bzw. Google API Console) eine App und deren Development/Production-URLs anlegen. Die Einrichtung für Facebook und Google ist jedes Mal ein wenig tricky, da die Bedienung nicht sooo einfach ist (viele Menüs und Informationen). Ergebnis der Einrichtung ist jeweils eine App-ID (Key) und ein App-Geheimcode (Secret).

Für Facebook sind diese Angaben in der AppConfig.properties notwendig:

host = http://localhost:8008
facebook.key=
facebook.secret=
facebook.url=https://graph.facebook.com/v2.9/me
facebook.state=

und bei Google diese:

host = http://localhost:8008
google.key=
google.secret=
google.url=https://www.googleapis.com/plus/v1/people/me
google.state=

Hinter state steht ein frei einzugebender geheimer Text. Am besten verwendet man für den Text nur Buchstaben ohne Umlaute, Ziffern und keine Leerzeichen. Unterstrich, Minus- und Ausrufezeichen sind okay. Die Web App gibt den geheimen state an den Login Service (Facebook oder Google). Der Login Service führt ggf. den Login aus und ruft eine Callback-URL der Web App auf. Diese URL enthält dann den state. Maja-auth stellt so sicher, dass der Callback rechtens ist. Die Callback-URL wird mittels Config-Parameter "host" gebildet. Dieser Parameter unterscheidet sich also auf dem Entwicklungsrechner und auf dem Production-Server.

Man kann übrigens im Facebook Developer Center Testuser generieren, um so den Login auch mit anderen Usern zu testen.

Maja-auth ist so gebaut, dass wenn eine Seite X angefordert wird und man nicht eingeloggt ist, erst die Anmeldeseite der App erscheint. Nach dem Klicken auf den Facebook-Button zeigt Facebook möglicherweise eine eigene Loginseite an. Nach erfolgreichen Login wird Seite X angezeigt.

Index

Vorbereitungen

Zu Beginn ist die TankstellenApp-Klasse um folgende Methode zu erweitern:

public static String getUserId(Request req) {
	String userId = AuthPlugin.getUserId(req.session());
	if (userId == null || userId.isEmpty()) {
		throw new RuntimeException("User Id ist leer!"); // Programmschutz
	}
	return userId;
}

In der AppConfig.properties ist noch development=true einzutragen. Auf dem Webserver ist dieser Wert dann auf false zu setzen.

Und die TankstelleDAO benötigt folgenden Code:

public List<Tankstelle> findByUser(String userId) {
	List<Tankstelle> list = ds.createQuery(Tankstelle.class)
		.field("userId").equal(userId).asList();
	list.sort((a,b) -> a.getBezeichnung().toLowerCase()
		.compareTo(b.getBezeichnung().toLowerCase()));
	return list;
}

Index-Klasse und index.html

Zu der Klasse github.soltaufintel.tankstellen**.pages.Index** wird im templates Ordner die Datei index.html angelegt. Die HTML-Datei zu einer Action muss immer im templates Ordner liegen, den Klassennamen in Kleinbuchstaben und die Endung .html haben.

#parse("/templates/master.html")
#@master()
<div class="row"><div class="col-lg-12">
	<h1 class="page-header">Tankstellen</h1>
	<table class="table table-striped">
		#foreach($ta in $tankstellen)
		<tr>
		 <td>
			<a href="tankstelle/$ta.url">$T.esc($ta.bezeichnung)</a>
		 </td>
		 <td>
			$ta.nummer
		 </td>
		</tr>
		#end
	</table>
</div></div>
#end

Die Index-Klasse erbt von Action. In ihrer execute() Methode werden die Daten per DAO geladen und mittels put() ans Template übergeben.

public class Index extends Action {
	protected void execute() {
		String userId = TankstellenApp.getUserId(req);
		TankstelleDAO dao = new TankstelleDAO(TankstellenApp.database);
		List<Tankstelle> tankstellen = dao.findByUser(userId);
		put("tankstellen", tankstellen);
	}
}

routes Eintrag

Die Index-Klasse wird in der routes() Methode der TankstellenApp-Klasse mittels

	_get("/", Index.class);

eingebunden.

Login testen und die Option auth

Wenn die Indexseite fertig ist, kann auch der Facebook-Login getestet werden. Und ebenso die Abmelden Funktion. Der Login lässt sich mit dem AppConfig-Parameter auth=false deaktivieren. Dies ist für den In-Production-Mode natürlich nicht zu empfehlen.

Neu

Ähnlich verhält es sich für die Neu-Funktion. Zu der Neu-Klasse, die von Action erbt und nur eine leere Methode enthält, gibt es eine neu.html Datei. routes wird so erweitert:

	_get("/neu", Neu.class);
	_get("/speichern", Speichern.class);

Wenn der Anwender auf den Submit-Button klickt, wird die URL "/speichern" aufgerufen. Die Speichern-Klasse erbt lediglich von ActionBase und in ihrer run() Methode werden die Daten mittels req.queryParams("feldname") geholt, validiert und in die Datenbank geschrieben. Anschließend wird ein Redirect auf die Index-Page durchgeführt.

	res.redirect("/");
	return "";

Bearbeiten

Die Bearbeiten-Funktionalität besteht ebenfalls aus eine Action-Klasse Bearbeiten und einer ActionBase-Klasse BearbeitenSpeichern. Die URL enthalten hier aber den Platzhalter :id für den Tankstellen Primary Key.

	_get("/tankstelle/:id", Bearbeiten.class);
	_get("/tankstellespeichern/:id", BearbeitenSpeichern.class);

Die URL kann nach der Id noch den Tankstellennamen enthalten. Wir erhalten so eine sprechendere URL, Beispiel: http://tankstellen.servername.de/tankstelle/oe94zh/aral-berlin

Löschen

	_get("/loeschen/:id", Loeschen.class);

Die Löschen Funktion erbt einfach von ActionBase. In ihrer run() Methode wird geprüft, ob die Tankstelle mit der Id gelöscht werden darf, das Objekt gemerkt und dann aus der DB gelöscht. In der GUI erfolgt vor dem Löschen keine Sicherheitsabfrage. Es ist moderner dann auf der Index-Page einen "Löschen rückgängig" Button anzubieten. Dafür ist das zuvor gemerkte Objekt.

Erstmalige Installation

ab hier UNDER CONSTRUCTION

Auf dem Production-Server ist in einem neuen Ordner tankstellen die Datei AppConfig.properties anzulegen. Man kann dafür bspw. den Editor nano verwenden.

Für die erstmalige Installation ist eine passwortgeschützte MongoDB-Datenbank auf dem Production-Server anzulegen. Die Zugangsdaten sind in o.g. AppConfig-Datei einzutragen.

In der AppConfig-Datei sind ebenfalls die Facebook-Login-Daten einzutragen. Der host-Eintrag muss ebenfalls angepasst werden.

OceanGround ist (m)ein Docker-Web-Tool für die einfache Aktualisierung von Docker Containern. OceanGround ist um die Parameter für die Tankstellen-App zu erweitern.

Für das erste Deployment ist der Buildbefehl "full build" auszuführen. Das Ergebnis sind die Docker Images tankstellen und tankstellenbase, welche auf den Production-Server hochgeladen wurden. Der Upload von tankstellenbase dauert relativ lange, da das Image recht groß ist. Mit dem tankstellen Button in OceanGround kann nun der Docker Container live gesetzt werden. Einen kurzen Moment später kann man den Container in OceanGround anschauen und über http die App aufrufen.

Deployment

Nach jeder Aktualisierung der App ist der Buildbefehl "build+push" auszuführen. Die Verarbeitung dauert unter 1 Minute. Über den tankstellen Button in OceanGround kann anschließend das Update live gesetzt werden. Dazu wird der alte Container gelöscht und ein Container aus dem neuen Image gestartet.

Der Clou

Der Clou dieses cleveren Build-Deployment-Prozesses ist es, dass das tankstellenbase-Image alle dependent JARs enthält. Das tankstellen-Image enthält aber lediglich die tankstellen-JAR; ist also sehr schlank. Das macht das Hochladen auf den Production-Server rasend schnell. Und das funktioniert in der Praxis auch fast immer, da nur selten weitere JARs hinzugefügt werden. Denn nur dann muss ein "full build" durchgeführt werden. Das tankstellen-Image basiert auf dem tankstellenbase-Image. Man macht sich hier also das Layer-Prinzip von Docker zunutze.

Damals hatte ich nur die geänderten Sourcen ins Gitlab meines Production-Servers gepusht - was natürlich sehr schnell war - und hatte dort Build (als Fat-JAR) und Docker-Image-Build von Gitlab ausführen lassen. Da die Jiffybox aber im Vergleich zu meinem Entwicklungsrechner recht lahm ist, dauerte das zu lange. Ich bin dann von dem Fat-JAR-Ansatz weg gegangen und habe wieder das gute alte Application-Plugin von Gradle verwendet. Damit gab es nur separate JARs. Diese wurden als .tar file gebaut. Ein .tar hat wieder den Clou, dass es automatisch in den root Ordner des Containers entpackt wird. In der tankstellenbase-Dateistruktur ohne tankstellen.jar kommt dann das Docker-Layer des tankstellen-Image mit der tankstellen.jar hinzu. Im Container wird stets das Script "/tankstellen/bin/tankstellen" ausgeführt.

Durch diese Vorgehensweise entstehen bei jedem Update Docker-Image-Leichen. Ganz normal. Diese kann man mit OceanGround wegräumen.

⚠️ **GitHub.com Fallback** ⚠️