clean architecture

Vom Monolithen zum Microservice

Einführung

Microservices sind in aller Munde, das Thema hat Hochkonjunktur. Überall kann man lesen, wie man Microservices Architekturen aufbauen kann und welche Tools dafür notwendig sind. Wer heute ein neues Projekt als Monolith designt gilt als old-school. Das ist zwar ein grober Fehler, mit einen Monolithen zu starten ist in vielen Fällen die besere Alternative, das ist aber nicht unser Thema heute. Ich möchte heute auf ein Thema eingehen, das uns vermutlich die nächsten Jahre beschäftigen wird. Wie können wir eine existierende, Monolithische Anwendung in eine Microservices Architektur überführen?

In diesem Artikel werde ich auf einige Strategien und Muster eingehen, die wir in vergangenen Projekten angewendet haben, um monolithische Applikationen in eine Microservices Architektur zu überführen. Ich werde explizit weder auf die Definition von Microservices eingehen noch bestimmte Tools erwähnen, das kann man woanders in ausreichender Tiefe und Breite lesen.

Ansatzpunkte

Bei allen Fällen die ich erlebt habe, stellte sich erst einmal die Frage, wo man ansetzen soll. Wie kann man sich der Thematik annähern? Betrachten wir zuerst die möglichen Ausgangslagen. Der gemeinsame Nenner ist meistens, dass es eine monolithische Anwendung gibt, mit der es Probleme gibt. Hier findet sich meistens schon der erste Hinweis darauf, ob eine Überführung in eine Microservice Architektur überhaupt Sinn macht. Schauen wir uns diese Gründe an:

  • Die Applikation ist schlecht oder gar nicht mehr wartbar
  • Die Applikation lässt sich nicht in akzeptabler Zeit bzw. automatisiert deployen
  • Das Deployment der Applikation ist immer mit Downtime verbunden
  • Fehler in bestimmten Teilen der Applikation führen zu einem Totalausfall der Anwendung
  • Die Applikation skaliert schlecht oder gar nicht
  • Die Applikation lässt sich nicht mit vertretbarem Aufwand erweitern
  • Teile der Applikation lassen sich nicht ohne extrem negativen Auswirkungen auf das Gesamtsystem austauschen

Können mehrere dieser Gründe herangezogen werden, so ist es zumindest empfehlenswert, weiter zu untersuchen, ob eine Microservices Architektur besser geeignet wäre.

Erste Annäherungsversuche

UI Trennung

Ein erster Ansatzpunkt kann sein, zu untersuchen ob die UI - sofern die Applikation überhaupt eine hat - vom Rest getrennt werden kann. Je nach Art der Applikation kann die Präsentationsschicht recht umfangreich sein. Umso mehr lohnt es sich, diese zu extrahieren. Allerdings muss hier evtl. ein Zwischenschritt eingeführt werden. Ist die Präsentationsschicht zu komplex, ist es ratsam, diese erst als einen zweiten Monolithen zu extrahieren und zwischen dem Frontend-Monolithen und dem Backend-Monolithen eine Schnittstelle einzuziehen. Erst im nächsten Schritt kann dann damit begonnen werden, diese millerweile kleineren Monolithen in Services aufzulösen.

UI Trennung

Monolithen Modularisieren (Orthogonale Transformation)

Viele monolithische Anwendungen sind logisch nach einem Schichtenmodell segmentiert. Stark vereinfacht sind diese Segmente von unten nach oben: Persistenzschicht, Domänenschicht, Anwendungsschicht und Präsentationsschicht. Grafisch dargestellt kann diese Architektur als übereinander gestapelte Rechtecke dargestellt werden. Bei komplexen Anwendungen kann es sinnvoll sein, die Architektur des Monolithen erst einmal orthogonal zu transformieren, indem man innerhalb des Monolithen modularisiert. Die Darstellung der resultierenden Architektur besteht dann aus nebeneinander stehenden Boxen. Ein solcher Monolith lässt sich einfacher in eine Microservices Architektur übertragen.

Orthogonale Transformation

Cross Cutting Concerns

Ein weiterer, vorbereitender Punkt kann sein, die sog. Cross Cutting Concerns zu extrahieren. Kandidaten hierfür können, je nach Art der Anwendung, sein: Logging, Encryption, Authentication, Messaging, usw. Diese Themen sind oft technisch gut kapselbar, so dass sich hieraus einfacher Services extrahieren lassen, die dann über eine definierte Schnittstelle angebunden werden können.

Cross Cutting Concerns

Produkte extrahieren

Während sich die o.g. cross cutting concerns eher auf einer technischen Ebene bewegen, haben komplexe Anwendungen auch eine ganze Reihe an fachlichen Komponenten. Viele dieser Komponenten sind fertige Lösungen, die irgendwann in den Monolithen, mal mehr mal weniger gut gekapselt, integriert worden sind. Diese sind ebenfalls Kandidaten für eine einfache Extraktion zu einem Service. Beispiele hierfür sind: Workflow Engines, Search Engines, Affiliate Marketing, Recommendation Engines, CMS, usw.

Zweite Iteration, eine Ebene tiefer

Im vorherigen Abschnitt habe ich die "Low Hanging Fruits" der Microservices Extraktion aufgeführt. Nun bleibt der harte Kern des alten Monolithen übrig. Hier kann man nicht nach Schema F vorgehen, da die Herangehensweise zu stark von der vorliegenden Software abhängt. Trotzdem haben wir Muster identifiziert, die sich in den letzten Projekten wiederholt haben. Diese bespreche ich im folgenden Abschnitt.

Asynchrone Verarbeitung

Oben hatte ich bereits aufgeführt, dass z.B. Messaging ausgegliedert werden kann. Nun bietet es sich an, sich die Komponenten im Monolithen anzuschauen, die asynchron, evtl. über Messaging, kommunizieren. Da hier erfahrungsgemäß die Kopplung geringer ist, bieten sich solche Komponenten an.

Asynchrone Verarbeitung

Datenhaltungsparadigmen

In vielen Anwendungen sieht man eine polyglotte Datenhaltung. D.h. es werden unterschiedliche Datenhaltungen für unterschiedliche fachliche oder technische Anforderungen verwendet. Ich empfehle diese Datenhaltungen sowie die Komponenten, die diese verwenden, zu untersuchen. Oft können diese Kandidaten für eine Extraktion sein.

Datenhaltungsparadigmen

Threads

Threads sind in manchen Fällen ebenfalls lohnenswerte Einstiegspunkte. In vielen Anwendungen werden Threads verwendet, um sehr spezifische Aufgaben auszuführen. Da ein Thread von Natur aus eine lose Kopplung und eine hohe Kohärenz zum Gesamtsystem haben sollte, bietet es sich an, diese auf ihre Extraktionsfähigkeit zu untersuchen.

Algorithmen

Algorithmen bestehen oft aus vielen Klassen oder Modulen, die nur zur Erfüllung der Aufgabe des Algorithmus da sind. Diese sind ebenfalls Kandidaten, um extrahiert zu werden, da hier auch eine lose Kopplung mit einer hohen Kohärenz gegeben ist.

Zusammenfassung und Ausblick

Die o.g. Ansätze sind aus unseren Erfahrungen in Projekten verschiedener Größe entstanden. Wir gehen fest davon aus, dass dieses Thema in den kommenden Jahren immer wieder auftauchen wird. Wir freuen uns auf den regen Austausch mit Kunden, Partnern und der Community, um Ideen und Erfahrungen auszutauschen.

Leserliche require Anweisungen für mocha Unit Tests in Clean Architecture Node Applications

Bei Pulsar Solutions versuchen wir die Richtlinien von Clean Architecture zu befolgen, um Code zu produzieren, der testbar ist und unabhängig von Frameworks, UI, Datenbanken und sogar das Web. Gleichzeitig sollte der Zweck der Applikation offensichtlich werden, indem man sich die Dateistruktur ansieht. Das resultiert allerdings oft in einem weit verzweigten Dateibaum, was zu unleserlichen und schwer wartbaren require Anweisungen führt.

In diesem Artikel liegt der Fokus darin, leserliche und wartbare require Anweisungen für die Unit Tests einer Anwendung zu ermöglichen. Der Einfachheit halber setze ich voraus, dass mocha als Testing Framework eingesetzt wird. Für andere beliebte Frameworks kann ein ähnlicher Ansatz verwendet werden.

Gehen wir von der folgenden Verzeichnisstruktur aus:

-- root
  -- node_modules
  -- src
    -- 0_externals
    -- 1_controllers_gateways_presenters
    -- 2_use_cases
      -- searchCats.js
    -- 3_entities
  -- tests
    -- ...
    -- 2_use_cases
      -- searchCatsTest.js
    -- ...
  -- ...

Dem entsprechend soll searchCatsTest.js den Code in searchCats.js testen. Mit mocha und der oben gezeigten Struktur würde die require Anweisung so aussehen:

var searchCats = require('../../../src/2_use_cases/searchCats');  

für einen Node Modul mit dieser Definition:

module.exports = searchCats;  

Das ist offensichtlich schlecht. Es ist unleserlich und, sollte sich die Verzeichnisstruktur aus irgend einem Grund nach einigen Monaten Entwicklung verändern, müssten alle require Anweisungen angepasst werden.

Wäre es nicht besser wenn wir schreiben könnten

var searchCats = require('2_use_cases/searchCats');  

und wir könnten das Root Verzeichnis global setzen? Leider funktioniert die o.g. Syntax nicht out-of-the-box, da require nicht weiss wo die Datei searchCats.js liegt.

Bevor wir weitermachen sollten wir einen Blick darauf werfen, wie mocha in der Kommandozeile aufgerufen wird:

mocha -R spec -u bdd ./tests  

Dieser Aufruf teilt mocha mit, dass alle Tests im Verzeichnis ./tests rekursiv auszuführen sind, dass der BDD Style verwendet werden soll und dass der Fortschritt im spec Format erfolgen soll. Da wir aber diesen Aufruf aus dem Root-Verzeichnis ausführen, überrascht es nicht, dass searchCats.js nicht gefunden wird. Wir kriegen eine Exception.

Googelt man ein wenig gelangt man schnell zu dieser interessanten Sammlung von Lösungen für ein ähnliches Problem aus der Node.js Welt: (Better local require() paths for Node.js) Ich bevorzuge Lösung Nummer 7, den globalen Wrapper um require. Aber wie können wir diese Technik auf unsere Mocha Tests anwenden? Klar ist, dass es nicht funktionieren wird, wenn mocha weiterhin mit dem o.g. Aufruf gestartet wird. Glücklicherweise kann man einen eigenen Mocha Runner als minimalistische Node.js Anwendung implementieren. Wie der Zufall so will hat das jemand bereits getan: (Starting Mocha Tests Programmically With Runner.js).

Alles was wir jetzt tun müssen, ist den Wrapper um require herum am Anfang des Runners einzubauen. Der Runner sieht dann so aus:

var fs = require('fs'),  
    Mocha = require("mocha"),
    path = require('path');

global.rootRequire = function (name) {  
    return require(__dirname + '/src/' + name);
}

// Our Mocha runner
var mocha = new Mocha({  
    ui: "bdd",
    reporter: "spec",
    timeout: 60000,
    slow: 10000
});

// Files which need to be ignored
var avoided = [  
    "node_modules"
];

// Add the tests to the Mocha instance
(addFiles = function (dir) {
    fs.readdirSync(dir).filter(function (file) {
        if (!~avoided.indexOf(file)) {
            if (fs.statSync(dir + '/' + file).isDirectory()) {
                addFiles(dir + '/' + file);
            }
            return file.substr(-3) === '.js';
        }
    }).forEach(function (file) {
        mocha.addFile(dir + '/' + file);
    });
})(path.join(process.cwd(), process.argv[2] || "."));

// Run the files in Mocha
mocha.run(function (failures) {  
    process.exit(failures);
});

Damit sieht jetzt die require Anweisung so aus:

var searchCats = rootRequire('2_use_cases/searchCats');  

und der Aufruf von Mocha sieht so aus:

node runner.js tests  

Viel besser oder?