Divide and Conquer - Microservices mit Node.js

Divide and Conquer - Microservices mit Node.js

Umsetzung von Microservices mit Express oder Seneca. Remote Logging mit log4js. Node.js in Docker Containern.

9fd17a38660f0c98089a65c0a7da65d1?s=128

Sebastian Springer

May 11, 2017
Tweet

Transcript

  1. 2.

    Basti • Sebastian Springer • aus München • arbeite bei

    MaibornWolff GmbH • https://github.com/sspringer82 • @basti_springer • JavaScript Entwickler
  2. 3.

    Agenda HTTP Microservice mit Express • Routing • Struktur •

    Datenbank • Logging • Tests • Container Commandbased Microservices mit Seneca • Patterns • Plugins • Queueing Rainer Sturm / pixelio.de
  3. 5.

    JavaScript ist sehr weit verbreitet. Es gibt sehr viele OSS

    Module Node.js wurde fürs Web gebaut Es werden alle Protokolle, Systeme und Datenbanken unterstützt Node ist relativ schlank
  4. 10.
  5. 11.

    Express.js Express ist ein leichtgewichtiges (< 2MB) Web Application Framework

    für Node.js. Express unterstützt HTTP und HTTPS in Version 1 und 2. Express kümmert sich primär um das Routing in einer Applikation Außerdem verfügt es über eine Plugin-Schnittstelle, die Middleware genannt wird.
  6. 13.

    Aufbau eines Microservices . ├── db │ ├── config.js │

    └── db.sqlite3 ├── package.json ├── spec └── src ├── config.js ├── controller.js ├── index.js ├── logger.js ├── model.js └── router.js
  7. 15.

    Einstieg const express = require('express'); const router = require('./router'); const

    app = express(); router(app); app.listen(8080, () => { console.log('Service is listening to http://localhost: 8080'); });
  8. 16.

    Einstieg Hier werden alle globalen Pakete eingebunden und die Applikation

    konfiguriert. Alle weiteren Funktionalitäten werden in separate Dateien ausgelagert.
  9. 18.

    Router const controller = require('./controller'); module.exports = (app) => {

    app.get('/article', controller.getAll); app.get('/article/:id', controller.getOne); app.post('/article', controller.create); app.put('/article', controller.update); app.delete('/article', controller.remove); }
  10. 19.

    Router Der Router definiert alle für den Microservice verfügbaren Routen.

    Eine Route besteht aus einer HTTP-Methode und einem Pfad. Die Flexibilität einer Route kann durch Variablen erhöht werden. Um den Router übersichtlich zu halten, werden die Callback- Funktionen für die einzelnen Routen ausgelagert.
  11. 21.

    Controller const model = require('./model'); module.exports = { getAll(req, res)

    { res.json( model.getAll() ); }, getOne(req, res) {}, create(req, res) {}, update(req, res) {}, remove(req, res) {} }
  12. 22.

    Controller Der Controller ist dafür zuständig Informationen aus dem Request

    zu extrahieren und die Informationen an das Model zu übergeben. Auf die Variablen im Pfad der Route wird über req.params.<var> zugegriffen Werden Informationen im Request-Body übermittelt, bindet man die body-parser-Middleware ein und greift dann auf req.body zu.
  13. 24.

    Model Das Model steuert den Datenbankzugriff. Außerdem beinhaltet es die

    Businesslogik des Microservices. Im Idealfall verfügt ein Microservice über eine eigene Datenbank. Dies steigert die Unabhängigkeit des Services.
  14. 25.

    Model Node.js unterstützt alle gebräuchlichen Datenbanken wie beispielsweise OracleDB, MySQL,

    Redis, MongoDB. Für den Datenbankzugriff wird zunächst der Datenbanktreiber installiert. yarn add sqlite3 Zur einfacheren Handhabung der Operationen existieren verschiedene ORMs bzw. ODMs yarn add orm
  15. 26.

    ORM Um den Aufwand der Implementierung von CRUD- Operationen auf

    einer Datenbank zu reduzieren, können ORMs beziehungsweise ODMs verwendet werden. Das ORM übernimmt eine Sicherheits-Funktion, indem es für das korrekte Escaping der Abfragen sorgt.
  16. 27.

    ORM const express = require('express'); const orm = require('orm'); const

    router = require('./router'); const dbConfig = require('./db/config.js'); const {dbPath} = require('./config.js'); const app = express(); app.use(orm.express(dbPath, dbConfig)); router(app); app.listen(8080, () => { console.log('Service is listening to http://localhost: 8080'); });
  17. 28.

    ORM module.exports = { define(db, models, next) { models.articles =

    db.define('articles', { id: Number, title: String, price: Number }); next(); } }
  18. 29.

    Model Das Model übernimmt neben der Kapselung der Datenbank- Kommunikation

    weitere Aufgaben wie die Validierung der Eingaben und verschiedene Berechnungen. Die meisten Operationen erfolgen asynchron. Das Model kann hier für eine saubere API sorgen, indem statt Callback- Funktionen asynchrone Funktionen verwendet werden.
  19. 30.

    async getAll(req, res) { try { const articles = await

    model.getAll(req); res.json(articles); } catch (e) { res.status(500).send(e); } } getAll(req) { return new Promise((resolve, reject) => { req.models.articles.all((err, results) => { if (err) { reject(err); } else { resolve(results); } }); }); } controller model
  20. 32.

    Logging Im Microservice können verschiedene Fehler auftreten. Neben dem Abfangen

    und der Behandlung solcher Probleme, sollte das Auftreten solcher Situationen in einem Log festgehalten werden. Der Logger sollte die Informationen nicht lokal vorhalten, sondern die Möglichkeit bieten die Informationen an einen zentralen Logging Server zu senden.
  21. 33.

    Logging Für remote-Logging kann beispielsweise log4js verwendet werden. Die Bibliothek

    beinhaltet bereits einige Appender für verschiedene Log-Targets wie zum Beispiel Dateien oder einen Logstash-Server. Der Logstash-Appender kann für das Remote-Logging verwerndet werden.
  22. 34.

    Logging const log4js = require('log4js'); log4js.configure({ appenders: [{ "host": "127.0.0.1",

    "port": 10001, "type": "logstashUDP", "logType": "database", "layout": { "type": "pattern", "pattern": "%m" }, "category": "database" }], categories: { default: { appenders: ['database'], level: 'error' } } }); module.exports = log4js;
  23. 35.

    Logging const log4js = require('./logger'); module.exports = { getAll(req) {

    return new Promise((resolve, reject) => { req.models.articles.all((err, results) => { if (err) { reject(err); log4js.getLogger('database').error(err); } else { resolve(results); } }); }); } }
  24. 37.

    Tests Microservices müssen automatisiert getestet werden. Dies geschieht auf zwei

    Ebenen: Unittests für einzelne Units of Code und Schnittstellen-Tests für das Big Picture. Für Unittests kommt Jasmine zum Einsatz und die Schnittstellen können zum Beispiel mit Frisby.js getestet werden.
  25. 39.

    Unittests const model = require('../model'); describe('model', () => { it('should

    handle a database error correctly', () => { const req = { models: { articles: { all: (cb) => {cb('error', null);} } } } model.getAll(req).catch((e) => { expect(e).toBe('error'); }); }) });
  26. 40.

    Unittests Die Tests werden mit dem Kommando ./node_modules/.bin/ jasmine ausgeführt.

    Die Tests können entweder im Verzeichnis “spec” abgelegt werden oder bei den jeweiligen Source-Dateien die sie testen.
  27. 41.

    Mockery In Node.js geschieht das Abhängigkeits-Management vorwiegend über das Modulsystem.

    Mockery ist eine Bibliothek mit deren Hilfe man diese Abhängigkeiten für Testzwecke austauschen kann. yarn add -D mockery
  28. 42.

    Mockery const mockery = require('mockery'); beforeEach(() => { mockery.enable(); const

    fsMock = { stat: function (path, cb) {...} }; mockery.registerMock('fs', fsMock); }); afterEach(() => { mockery.disable(); });
  29. 43.

    Schnittstellentests frisby.js ist eine Bibliothek mit der sich REST-Schnittstellen testen

    lassen. Frisby ist eine Erweiterung des Jasmine- Testframeworks. frisby.js benötigt jasmine-node zur Ausführung der Tests. yarn add -D frisby jasmine-node
  30. 44.

    Schnittstellentests require('jasmine-core'); var frisby = require('frisby'); frisby.create('Get all the articles')

    .get('http://localhost:8080/article') .expectStatus(200) .expectHeaderContains('content-type', 'application/json') .expectJSON('0', { id: function (val) { expect(val).toBe(1);}, title: 'Mannesmann Schlosserhammer', price: 7 }) .toss();
  31. 45.
  32. 46.

    PM2 Node.js ist Single Threaded. Durch das Nonblocking I/O sind

    Node-Applikationen trotzdem schnell. Um mehrere Kerne nutzen zu können, kann das child_process Modul verwendet werden. Für die lokale Skalierung empfiehlt Express allerdings die Verwendung von PM2. yarn add pm2
  33. 47.

    PM2 pm2 start app.js pm2 start app.js -i 4 pm2

    reload all pm2 scale <app-name> 10 pm2 list pm2 stop pm2 delete
  34. 48.
  35. 49.

    Docker Die einzelnen Services laufen in separaten, in sich geschlossenen

    Containern. Von jedem Service kann eine beliebige Anzahl von Containern gestartet werden. Wird der Funktionsumfang der Applikation erweitert, können zusätzliche Services hinzugefügt werden.
  36. 50.

    Dockerfile FROM node:7.10 # Create app directory RUN mkdir -p

    /usr/src/app WORKDIR /usr/src/app # Install app dependencies COPY package.json /usr/src/app/ RUN yarn install # Bundle app source COPY . /usr/src/app EXPOSE 8080 CMD [ "yarn", "start" ]
  37. 53.

    API Gateway Damit sich jeder Microservice um seine Aufgaben kümmern

    kann, wird ein zentraler Knoten benötigt, der sich um allgemeine Aufgaben wie beispielsweise Authentifizierung kümmert. Das API Gateway leitet die autorisierten Anfragen an die jeweiligen Services weiter und empfängt ihre Antworten, die dann zum Client weitergeleitet werden.
  38. 54.
  39. 55.

    Seneca Seneca verfolgt im Gegensatz zu Express einen anderen Ansatz.

    Die Services kommunizieren über Nachrichten und werden dadurch unabhängig von der Transportschicht.
  40. 57.

    Service Definition const seneca = require('seneca')(); seneca.add({role: 'math', cmd: 'sum'},

    controller.getAll); Das erste Argument der add-Methode ist das Pattern, das den Service beschreibt. Das Pattern ist frei wählbar. Role und cmd haben sich aber als Best Practice etabliert. Das zweite Argument ist Aktion, also der Service-Handler. Hier kann wie schon bei Express beliebig abstrahiert werden.
  41. 58.

    Service-Handler async getAll(msg, reply) { try { const articles =

    await model.getAll(req); reply(null, JSON.stringify(articles)); } catch (e) { reply(e); } } Der Service-Handler erhält eine Repräsentation der Anfrage und eine Reply-Funktion. Über das msg-Objekt kann auf die Eigenschaften der Anfrage zugegriffen werden. Die Reply- Funktion wird mit einem Fehlerobjekt und den Nutzdaten aufgerufen.
  42. 59.

    Service Aufruf seneca.act({role: 'article', cmd: 'get'}, (err, result) => {

    if (err) { return console.error(err); } console.log(result); }); Mit der seneca.act-Methode kann ein Microservice konsumiert werden. Diese Methode akzeptiert eine Nachricht und eine Callback-Funktion. Mit der Nachricht wird der Service aufgelöst. Die Callback-Funktion wird aufgerufen, sobald der Service antwortet. Mit act können auch Routinen innerhalb eines Microservices aufgerufen werden (code reuse)
  43. 61.

    Patterns Existieren mehrere Patterns vom gleichen Typ, gewinnt immer das

    spezifischere. Über solche Patterns lassen sich beispielsweise auch Schnittstellen versionieren.
  44. 63.

    Plugins Ein Plugin ist eine Sammlung von Patterns. Es gibt

    verschiedene Quellen für Plugins: Von Seneca mitgelieferte, selbst entwickelte oder von Drittanbietern. Plugins vereinfachen das Loggen und Debuggen.
  45. 64.

    Plugins Mit der use-Methode können Patterns in Plugins organisiert werden.

    Der Name der Funktion wird zum Logging verwendet. Dem Plugin können Optionen übergeben werden, um es weiter zu konfigurieren. Das init-Pattern wird statt eines Konstruktors verwendet. const seneca = require('seneca')(); function articles(options) { this.add({role:'article',cmd:'get'}, controller.getAll); } seneca.use(articles);
  46. 65.

    Plugins const seneca = require('seneca')(); function articles(options) { this.add({role:'article',cmd:'get'}, controller.getAll);

    this.wrap({role:'article'}, controller.verify); } seneca.use(articles); Mit der Wrap-Methode kann Funktionalität definiert werden, die für mehrere Patterns gilt. Mit this.prior(msg, respond) kann der ursprüngliche Service aufgerufen werden.
  47. 67.

    Server function articles(options) { this.add({role:'article',cmd:'get'}, controller.getAll); this.wrap({role:'article'}, controller.verify); } require('seneca')()

    .use(articles) .listen(8080) Die listen-Methode bindet den Server an TCP-Port 8080. Der Service kann daraufhin per Browser oder mit einem anderen Server abgefragt werden: http://localhost:8080/act?role=article&cmd=get
  48. 68.

    Client require('seneca')() .client(8080) .act({role: 'article', cmd: 'get'}, console.log); Mit der

    client-Methode kann man eine Applikation mit einem Seneca-Microservice verbinden.
  49. 69.

    Änderung des Transports // client seneca.client({ type: 'tcp', port: 8080

    }); // server seneca.listen({ type: 'tcp', port: 8080 }); Die Angabe type: ‘tcp’ verwendet TCP statt HTTP als Übertragungsprotokoll.
  50. 71.

    Integration in Express const SenecaWeb = require('seneca-web'); const Express =

    require('express'); const router = new Express.Router(); const seneca = require('seneca')(); const app = Express(); const senecaWebConfig = { context: router, adapter: require('seneca-web-adapter-express'), options: { parseBody: false } } app.use( require('body-parser').json() ) .use( router ) .listen(8080); seneca.use(SenecaWeb, senecaWebConfig ) .use('api') .client( { type:'tcp', pin:'role:article' } );
  51. 72.

    Integration in Express Seneca fügt mit dieser Konfiguration die entsprechenden

    Routen zur Applikation hinzu. Die Seneca Patterns müssen dazu etwas angepasst werden. Alle Routen die mit einem Aufruf von seneca.act('role:web', {routes: routes}) definiert werden, werden zu den Express- Routen hinzugefügt. Über das path-Pattern findet dann ein entsprechendes Matching statt.
  52. 74.

    Queue Über Plugins von Drittanbietern kann statt über Netzwerk auch

    über eine Message Queue kommuniziert werden. Der Vorteil hierbei ist, dass eine Queue Client und Server weiter entkoppelt und das gesamte System weniger Fehleranfällig macht.
  53. 76.

    Queue require('seneca')() .use('seneca-servicebus-transport') .use(articles) .listen({ type: 'servicebus', connection_string: '...' });

    require('seneca')() .use('seneca-servicebus-transport') .client({ type: 'servicebus', connection_string: '...' }) .act({role: 'article', cmd: 'get'}, console.log); Server Client
  54. 77.

    Microservices, the silver bullet? Microservices sind eine sehr gute Lösung

    für bestimmte Probleme, jedoch nicht für alle. Microservices erhöhen die Komplexität einer Applikation. In Node.js gibt es viele Pakete die sich mit diesem Thema beschäftigen. Etliche sind veraltet oder von schlechter Qualität. Ein genauer Blick auf GitHub und npmjs.com vor der Einbindung lohnt sich.