Lingua franca mit TypeScript, Node.js & Azure
JavaScript ist dort, wo Java vor Jahren gerne gewesen wäre: Auf verschiedensten Plattformen, sowohl am Client als auch am Server. Doch wie baut man die TypeScript-Libraries und -Apps möglichst wiederverwendbar auf?
Lingua franca
JavaScript ist zur Universalsprache für verschiedenste Aufgaben auf verschiedensten Plattformen geworden: Von "klassischer" clientseitiger Entwicklung für Webseiten oder Apps, über serverseitige Lösungen mit Node.js bis hin zu Scripting-und Verwaltungstools. Mit TypeScript steht ein mächtiger Transpiler zur Verfügung, dessen statische Typisierung skalierbare Entwicklung, Fehlerfindung zur Compile-Zeit, bessere IDE-Integration und Rücktranspilierung von neueren ECMA-Script-Konstrukten in ältere ECMA-Script-Versionen ermöglicht. Bei all diesen Vorteilen wäre es doch schön, wenn man vorrangig nur mehr TypeScript programmieren und damit viel Aufwand bei der Ausbildung und Schulung der Mitarbeiter, bei Projekt-Onboardings und bei der Applikationsentwicklung sparen könnte. Darum geht es im ersten Artikel dieser Reihe.
Eine Frage für Unternehmen, Entwickler und Architekten
Ich wurde kürzlich auf einer Veranstaltung gefragt, welche Programmiersprache man neuen bzw. umzuschulenden Mitarbeitern beibringen solle. Es ergab sich schnell eine hitzige Diskussion und Statistiken wurden in den Raum geworfen, z. B. die vom IEEE Spectrum oder der PYPL (PopularitY of Programming Language Index) und der TIOBE Index [1]. Wenn man auf GitHub nachsieht, sind die meisten Pushes und die aktivsten Repositories diejenigen mit JavaScript. Bei einem Blick auf die Jahr-zu-Jahr-Wachstumsraten sieht man JavaScript bei den Gewinnern. Wie auch immer die genauen Zahlen sein mögen – die Bandbreite der JavaScript-Einsatzgebiete wächst laufend und moderne, progressive Web- und mobile Apps mit Frameworks wie React und AngularJS tun ihr Übriges.
Eine für alle – JavaScript am Client und am Server
Gesetzt den Fall, wir entschließen uns nun bei einem neuen Projekt für den TypeScript-only-Weg, müssen wir uns überlegen, wie und mit welchen Tools wir den Entwicklungszyklus aufbauen und die Applikation strukturieren und paketieren. Ein großes Ziel dabei ist, dass wir am Client und am Server die gleichen Mechanismen verwenden und hohen Code-Reuse erreichen.
Wir nehmen als Beispiel eine Single-Page-Applikation, die auf Node.js-REST-Services zugreift. Diese Services kommunizieren ihrerseits über eine Datenzugriffsschicht und den Tedious-Treiber mit Azure SQL. Abb. 1 zeigt die vereinfachte Architektur.
Die Herausforderung besteht nun darin, die am Client und am Server benötigten Funktionen (das Domain-Model sowie Utilities für Arrays, Datumsberechnung, Logging, Decorators usw.) so zu strukturieren, dass sie nicht doppelt gewartet werden müssen. Was kann dabei schwierig sein? Ein Unterschied zwischen der client- und der serverseitigen Programmierung besteht in der Modularisierung. Beim clientseitigen JavaScript gibt es zahlreiche Varianten, angefangen vom klassischen Inkludieren der JavaScript-Dateien über <script>-Tags, über Module-Loader wie AMD (require.js) oder JSPM bis hin zu Bundling-Tools wie webpack.
Das Modulsystem von Node.js aus der 3000-Meter-Sicht
Ein Node.js-Modul entspricht der CommonJS-Konvention [2] und ist eine Sammlung von JavaScript-Dateien, die in einem Unterordner des Ordners node_modules liegen. Der Einsprungspunkt in ein Modul ist z. B. die Datei index.js, die bei Bedarf andere JavaScript-Dateien laden kann. Die Verzeichnisstruktur unserer Server-Anwendung ist in Abb. 2 beschrieben.
- Der TypeScript-Sourcecode unserer Server-Applikation befindet sich im Ordner ts.
- TypeScript transpiliert die Dateien nach js.
- node_modules enthält die Node.js-Module, die unser Server verwendet, z. B. Datenbanktreiber, File-System usw.
- package.json (s. Listing 1) beschreibt die Abhängigkeiten unserer Applikation von anderen Node.js-Modulen. Node.js bringt einen eigenen Paketmanager mit, den Node Package Manager (npm). Er sorgt dafür, dass die benötigten Module in der richtigen Version installiert werden. In Listing 1 sehen Sie die Datei package.json unserer Applikation. Der Eintrag main definiert den Einsprungspunkt in die App bzw. das Modul. In unserem Beispiel handelt es sich um server.js, unseren REST-Server. Im Falle von Modulen wird als Einsprungspunkt oft index.js verwendet.
In der Sektion dependencies werden die Abhängigkeiten von anderen Modulen beschrieben und in devDependencies die Abhängigkeiten, die nur zur Entwicklungszeit benötigt werden. Die Schreibweise @types/node bezeichnet ein "scoped Module" und bedeutet in diesem Fall, dass die so genannten TypeScript Declaration Files für Node.js benötigt werden. Sie enthalten die TypeScript-Typinformationen, anhand derer die IDE z. B. Intelli-Sense anbieten und der TypeScript-Transpiler statisch prüfen kann. - tsconfig.json enthält Anweisungen für den TypeScript-Transpiler, z. B. wohin die transpilierten JS-Dateien generiert werden sollen, welcher ECMA-Script-Standard und welches Modulsystem verwendet wird. Letzteres wird für uns in Kürze noch sehr wichtig werden.
- web.config wird erst im nächsten Artikel zu diesem Thema relevant, wenn es darum geht, den Server-Teil unserer App auf Azure zu deployen.
Mittels des npm-Kommandos npm install werden alle in package.json defnierten Abhängigkeiten installiert (standardmäßig von der npm Registry). Mit der Funktion require lädt/importiert man in Node.js eine Abhängigkeit (ein anderes Modul) z. B. das File-Handling: const fs = require('fs');
Listing 1: package.json unserer Server-App
{
"name": "cohera",
"version": "0.0.22",
"description": "Enterprise Relationship Management",
"main": "./js/server.js",
"engines": {
"node": ">=10.0.0",
"npm": ">=6.0.0"
},
"config": {
"deployDir": "..\\cohera-deploy"
},
"scripts": {
"start": "node ./js/server.js",
"caw": "tsc -w -p ./",
"deploy": "... <wir deployen unsere app über git nach azure>"
},
"repository": {
"type": "git",
"url": "..."
},
"author": "thomas mahringer",
"license": "MIT",
"dependencies": {
"applicationinsights": "^1.2.0",
"body-parser": "^1.18.3",
"busboy": "^0.3.0",
"cors": "^2.8.5",
"express": "^4.16.4",
"fs-extra": "^7.0.1",
"reflect-metadata": "^0.1.13",
"serve-favicon": "^2.5.0",
"sharp": "^0.22.0",
"ws": "^6.1.4"
},
"devDependencies": {
"@types/sharp": "^0.22.1",
"@types/body-parser": "^1.17.0",
"@types/busboy": "^0.2.3",
"@types/cors": "^2.8.4",
"@types/express": "^4.16.1",
"@types/fs-extra": "^5.0.5",
"@types/node": "^11.9.4",
"@types/serve-favicon": "^2.2.30",
"concurrently": "^4.1.0",
"nodemon": "^1.18.10",
"typescript": "^3.3.3333"
}
}
TypeScript & Node.js
TypeScript unterstützt Node.js sehr gut. Wie wir weiter oben bei der Beschreibung der package.json gesehen haben, kann TypeScript durch die TypeScript Declaration Files Node.js-Elemente einer statischen Typisierung unterziehen. Die IDE kann dadurch nützliche Unterstützungen wie Intelli-Sense und Refactoring anbieten. Doch kommen wir zurück zu unserer obigen Herausforderung, wie man die client- und serverseitige Applikation so strukturiert, dass man Module ohne Mehraufwand am Client und am Server wiederverwenden kann. Werfen wir dazu einen Blick auf das TypeScript-Modulkonzept.
Modularisierung in TypeScript
TypeScript unterstützt die ES2015 Syntax für den Import von Modulen, z.B. import * as fs from "fs";. Dadurch wird das Filehandling-Modul von Node.js importiert und unter dem Namen fs verfügbar gemacht. Woher weiß TypeScript aber, wie es das Modul auflösen soll? Es könnte ja genauso gut AMD oder JSPM sein? Des Rätsels Lösung steckt in der tsconfig.json (s. Listing 2). Durch die Option module weiß TypeScript, welches Zielmodul erstellt werden soll.
Listing 2: tsconfig.json – Einstellungen für TypeScript
{
"compilerOptions": {
"module": "commonjs",
"noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"outDir": "./js",
"sourceMap": true,
"target": "es2015",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": [
"es2015"
]
},
"exclude": [
"**/*.spec.ts"
],
"include": [
"ts",
"node_modules"
]
}
Das "Utils"-Modul wiederverwendbar gestalten
Wie in der obigen Architekturskizze beschrieben, wollen wir dieses Modul sowohl am Client als auch am Server wiederverwenden. Dazu definieren wir in einer package.json (s. Listing 3) das Node.js-Modul mrutils und über die tsconfig.json (s. Listing 4) weisen wir TypeScript an, ein CommonJS-(Node)-Modul zu erzeugen. Wichtig sind hier die Einstellungen outDir, das den Zielort der transpilierten JS-Dateien festlegt und declaration, das dafür sorgt, dass TypeScript die Typescript Declaration Files erzeugt. In Listing 3 sehen wir, dass wir in der package.json unter types die Datei index.d.ts (s. Listing 6) referenzieren. Diese exportiert die Typescript Declaration Files und macht sie damit für andere Module zugänglich. In anderen Modulen verwenden wir dann z. B. einfach import { ILogger } from „mrutils“;.
Listing 3: package.json für das wiederverwendbare Modul mrutils
{
"name": "mrutils",
"version": "1.0.61",
"description": "",
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"caw": "tsc -w -p ./source"
},
"dependencies": {
"bufferutil": "^4.0.1",
"reflect-metadata": "^0.1.10",
"utf-8-validate": "^5.0.2",
"ws": "^6.1.0"
},
"devDependencies": {
"@types/node": "^8.10.42",
"@types/react": "^16.8.8",
"@types/react-dom": "^16.8.2",
"@types/reflect-metadata": "0.0.5",
"@types/ws": "0.0.38",
"concurrently": "^3.6.1",
"typescript": "^3.3.3333",
},
"author": "Thomas Mahringer",
"license": "ISC"
}
Listing 4: tsconfig.json für unser Utils-Modul
…
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"outDir": "dist",
"declaration": true,
"noResolve": false
},
…
Den Einsprungspunkt des Moduls definieren – index.js
Jede App und jedes Modul hat einen Einsprungspunkt. Da es sich bei mrutils um ein Modul handelt, folgen wir der Konvention und benennen den Einsprungspunkt index.js. Wenn wir das Modul in Node.js verwenden (z. B. const mrutils = require(„mrutils“)), weiß Node.js aufgrund der package.json, dass es die unter main stehende index.js laden muss. Letztere führt einfach einen Barrel-Export (gesamthaften Export) aller relevanten Klassen/Funktionen unseres Moduls durch, z. B. remoteControl, shortHash, logging und format.
Listing 5: index.js – Einsprungspunkt für Node.js
"use strict";
function __export(m) {
for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
}
Object.defineProperty(exports, "__esModule", { value: true });
__export(require("./dist/remoteControl"));
__export(require("./dist/shortHash"));
__export(require("./dist/logging"));
__export(require("./dist/format"));
Für TypeScript funktioniert es ganz ähnlich (s. Listing 6), nur eben in der TypeScript-Syntax. Wir exportieren damit alle .d.ts Dateien, die der Transpiler aufgrund der tsconfig-Option declaration im Verzeichnis dist erzeugt hat.
Listing 6: index.d.ts – Einsprungspunkt für den TS Transpiler
export * from "./dist/remoteControl ";
export * from "./dist/shortHash";
export * from "./dist/logging ";
export * from "./dist/format";
Das "Utils"-Modul in die serverseitige App einbinden
Welche Möglichkeiten gibt es nun, mrutils in unserer Server-App zu verwenden? Eine Variante wäre z. B., es bei jedem Build-Vorgang zu paketieren und auf ein Repository (z. B. die NPM Registry) hochzuladen und danach mittels npm install im aktuellen Projekt zu installieren. Das Paketieren erfolgt mittels npm pack und das Publizieren auf die npm Registry durch npm publish. Dieses Vorgehen wäre akzeptabel, wenn mrutils schon sehr stabil wäre und nur mehr wenigen Änderungen unterliegt. Doch was tun wir, wenn wir das Modul laufend weiterentwickeln, während wir die Applikation erstellen? Dann wäre das wiederholte Paketieren und Hochladen des Moduls ein Overkill. Zum Glück bietet npm eine pragmatische Möglichkeit, um lokal vorhandene Module direkt ohne Umweg einzubinden und zwar über npm link. Es erzeugt unterhalb von node_modules einen symbolischen Link auf das Originalmodul. In unserem Fall verwenden wir npm link ../mrutils und erhalten dadurch die Ordnerstruktur von Abb. 3. Das Praktische an dieser Vorgehensweise ist, dass jede Änderung, die wir am Modul vornehmen, automatisch in unserer Applikation verfügbar ist und umgekehrt. Im Entwicklungszyklus behandeln wir das Modul wie "normalen" Sourcecode, auch wenn mehrere Entwickler daran arbeiten.
Wie passt das in den Browser? Let’s pack it up!
Wir haben es fast geschafft: Wir haben ein wiederverwendbares Modul und die Wahlfreiheit, ob wir es paketieren oder direkt über npm link einbinden. Aber wie können wir unser Modul im Browser verwenden? Wir haben es als commonJS-Modul transpiliert und brauchen daher einen Mechanismus, der commonJS-Module versteht. Darüber hinaus wäre es effizient, wenn wir am Client auch den gleichen Packet-Manager verwenden wie am Server, d. h. npm. Das erreichen wir durch Bundling-Tools wie webpack [3]. webpack (s. Abb. 4) scannt sämtliche Dateien (JS, CSS, Sass, Less usw.) und löst die Abhängigkeiten auf. Es verarbeitet dabei ES2015 import statements, CommonJS (Node) require, AMD define und require und @import in css/sass/less. Danach generiert es statische Assets, z. B. eines für JavaScript (app.bundle.js), eines für CSS (styles.css oder styles.bundle.js) usw. (Die Anzahl, Benennung und Art der Aufteilung in Bundles ist dabei konfigurierbar). Anstatt einer Vielzahl von Dateien werden nur mehr wenige geladen, was Roundtrips und Latenzzeiten spart. Und wir haben damit vor allem unser Ziel erreicht, dass wir unser CommonJS-Modul mrutils nach dem Single-Source-Prinzip nur einmal warten müssen und mit npm den bewährten Packet-Manager verwenden. Listing 7 zeigt zum Abschluss noch die webpack-Konfigurationsdatei der Client-Single-Page-App, die festlegt, welche Dateiarten gescannt, welche Abhängigkeiten aufgelöst und welche Ziel-Bundles erzeugt werden.
Listing 7: webpack.config.js: Auflösen von TypeScript, Less und CSS-Modulen
const HtmlWebPackPlugin = require("html-webpack-plugin");
const CleanWebPackPlugin = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const path = require('path');
const htmlWebpackPlugin = new HtmlWebPackPlugin({
template: "./client/index.html",
filename: "./index.html"
});
const cleanWebpackPlugin = new CleanWebPackPlugin({ options: ["dist/*"] });
module.exports = {
node: {
fs: "empty",
<hier weisen wir webpack an, nodejs-module zu irgnorieren>
},
entry: {
app: './client/app/app.tsx',
styles: './client/app/styles.ts'
},
output: {
path: path.resolve("dist"),
filename: '[name].[contenthash].bundle.js',
publicPath: "/"
},
module: {
rules: [
{
test: /\.css$/,
use: [
{ // See "styles.ts": It imports the required CSSs
// By using this loader and the "new MiniCssExtractPlugin()"
// plugin below, we tell webpack
// to merge all CSSs to one.
loader: MiniCssExtractPlugin.loader,
options: {
// you can specify a publicPath here
// by default it use publicPath in webpackOptions.output
// publicPath: '../'
}
},
"css-loader"
]
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/,
},
{
enforce: 'pre',
test: /\.tsx?$/,
use: "source-map-loader",
},
{
test: /\.less$/,
use: ["style-loader",
{ loader: "css-loader", options: { sourceMap: true } },
{ loader: "less-loader", options: { sourceMap: true } }]
},
{
test: /\.(gif|png|woff(2)?|eot|ttf|jpe?g|svg)$/i,
use: [
{
loader: 'file-loader',
options: {
bypassOnDebug: true,
},
},
]
}
]
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
devtool: 'inline-source-map',
devServer: {
contentBase: path.join(__dirname, './client'),
historyApiFallback: true,
port: 8082,
inline: true
},
plugins:
[htmlWebpackPlugin, cleanWebpackPlugin,
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "[name].css",
chunkFilename: "[id].css"
})
],
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// get the name. E.g.
// node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName =
module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
// npm package names are URL-safe,
// but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
},
},
},
minimizer: [
new TerserPlugin({
terserOptions: {
ecma: undefined,
warnings: false,
parse: {},
compress: {},
mangle: true, // Note `mangle.properties` is `false` by default.
module: false,
output: null,
toplevel: false,
nameCache: null,
ie8: false,
keep_classnames: true,
keep_fnames: false,
safari10: false,
},
}),
],
}
};
Fazit
Mit Node.js, npm und webpack lassen sich client- und serverseitige Anwendungen und Libraries wiederverwendbar und mit einer einheitlichen Tool-Chain erstellen. Der Aufwand für den Application-Lifecycle wird dadurch geringer und das Onboarding neuer Projektmitglieder effizienter.
Ausblick – Node.js auf Azure
Im Rahmen des nächsten Artikels werden wir unsere Applikation auf Azure deployen und auf dabei auftretende Herausforderungen eingehen. Des Weiteren zeigen wir, wie man Visual Studio Team Services einbindet und mit den Azure-Kudu-Tools Fehler von Node.js-Apps analysiert.
- IEEE Spectrum: Interactive: The Top Programming Languages 2018
- PYPL: PopularitY of Programming Language Index
- TIOBE Index
- CommonJS-Konvention
- webpack