Skip to content

Instantly share code, notes, and snippets.

@henrik42
Last active April 21, 2024 18:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save henrik42/08e70e8fc0d9068e1fd40debec41ffdc to your computer and use it in GitHub Desktop.
Save henrik42/08e70e8fc0d9068e1fd40debec41ffdc to your computer and use it in GitHub Desktop.
Clojurescript auf einem Microcontroller

ClojureScript auf einem Mikrocontroller

Vor einiger Zeit haben ich den HuCon-Roboter-Bausatz erhalten. Der HuCon wird mit Blockly und Python programmiert. Ich programmiere schon lange auf PCs (vor allem Java und Clojure), aber mit Microcontrollern habe ich nur wenig Erfahrung. Als erstes habe ich mich mit der Installation von OpenWrt auf dem Onion Omega2+ beschäftigt.

Nun möchte ich gerne Clojure für die Programmierung verwenden. Clojure ist (meiner Meinung nach) eine tolle Programmiersprache, die auf der JVM läuft. Es gibt zwar eine JVM für OpenWrt für den Omega2+/mipsel_24kc und somit könnte man vielleicht auch Clojure/JVM auf dem Micro betreiben. Aber ich wollte ausprobieren, ob es möglich ist, mit weniger Ressourcen (RAM, CPU) auszukommen. Also habe ich mich mit ClojureScript beschäftigt. ClojureScript kann via Node.js auf vielen Microcontrollern ausgeführt werden.

In diesem Artikel beschreibe ich meine Erfahrungen und zeige, wie man einen Microcontroller interaktiv mit Clojure(Script) programmieren kann. Ich finde, dass Clojure sich gut als Lern- und Lehrsprache eignet (ich bin aber keine Pädagoge; das ist nur meine laienhafte persönliche Meinung), es macht extrem viel Spaß, Clojure zu programmieren und gerade die interaktive und explorative Art, wie man Clojure-Programme schreibt, passt super zur Programmierung von Microcontrollern. Man braucht keine langen Think-Code-Run-Zyklen, man kann seinen Code ständig anpassen und ausprobieren. Die Feedback-Schleife ist extrem kurz und unmittelbar.


Noch eine Bemerkung: das Schreiben dieses Artikels hat sich wie Yak Shaving angefühlt. Neben dem eigentlichen Ziel, ClojureScript auf einem Micro-Controller auszuführen, musste ich eine ganze Reihe weiterer Probleme lösen bzw. Informationen einholen: für den Emacs den Markdown-Mode installieren, mich mit Github-Gist-Checkouts beschäftigen, damit ich diesen Text überhaupt als Gist veröffentlichen kann, auf dem Micro den Swap aktivieren und extroot installieren, damit ich genug Platz auf dem Micro habe, mich mit den Firewall-Regeln meiner Fritzbox und meines Windows-PC auseinandersetzen, Man-Pages & Co. zu rsync, sshfs, samba lesen, auf dem Micro die opkg-Konfiguration anpassen, weil ich plötzlich keine Pakete mehr über https: Quellen installieren konnte, weil ich keine passenden CA-Zertifikate mehr hatte etc. etc. Das macht IT zum einen natürlich interessant -- und es macht auch Spaß! -- , aber es führt auch dazu, dass scheinbar leichte Dinge 5-mal länger dauern, als ursprünglich erwartet. Hoffentlich helfen dir die Informationen, die ich hier zusammengetragen habe.


Einleitung

In diesem Artikel beschreibe ich, wie man ClojureScript-Programme via Node.js auf einem Omega2+ laufen lassen kann. Hier findest du eine Einführung zu Omega2+ und Node.js.

ClojureScript ist ein Kompiler, der Clojure-Programme in JavaScript übersetzt. JavaScript-Programme können auf vielen Microcontrollern via Node.js ausgeführt werden.

Der Onion Omega2+ ist ein Microcontroller, auf dem das Betriebssystem OpenWrt läuft. Über den OpenWrt-Paketmanager opkg kann Node.js installiert werden.

Vorbereitung

Natürlich brauchst du einen Microcontroller, wenn du deine Programme wirklich auf dem Micro ausführen möchtest. Du kannst aber fast alle Sachen, die ich ihr beschreibe, auch ohne Micro durchführen. So kannst du dich mit den Tools, Clojure, ClojureScript usw. schon beschäftigen, bevor du dann später vielleicht wirklich dein Programm auf dem Micro ausführst.

  • Microcontroller: du brauchst einen SSH/Shell-Zugang zu deinem Micro. Am besten du verbindest ihn via WLAN mit deinem Hausnetz. Hier findest du eine Anleitung von mir, die dir vielleicht hilft.

  • Docker Desktop for Windows: Mit Docker kannst du auf viele vorgefertigte Programme zugreifen, ohne diese lokal auf einem PC installieren zu müssen. Ich werde in den folgenden Beispielen Docker verwenden. Du kannst die Programme aber auch einzeln lokal installieren, wenn du Docker nicht installieren möchtest.

  • Node.js: du benötigst Node.js sowohl auf deinem PC (z.B. um dort den ClojureScript-Code zu kompilieren) als auch auf dem Mirco (um den JavaScript-Code auszuführen).

  • npm: npm benutzt du, um vorgefertigte JavaScript-Pakete und deren Abhängigkeiten zu installieren. Wir brauchen es nur auf dem PC (nicht auf dem Micro).

  • JDK: der ClojureScript-Compiler ist in Clojure geschrieben und läuft auf der JVM. Daher benötigst du ein JDK (vielleicht reicht auch die JRE, das habe ich bisher nicht ausprobiert).

Hello, world!

Ich nutze shadow-cljs in dem Docker-Container theasp/clojurescript-nodejs:shadow-cljs-alpine, um unsere erste Anwendung zu bauen.

Du kannst die Tools auch einzeln auf deinem Windows/Linux PC installieren. Du musst nicht Docker verwenden.

Ich habe mir das (leere) Verzeichnis C:\henrik42\ als Basis-Verzeichnis für die folgenden Aktivitäten angelegt. Du solltest dir auch ein leeres Verzeichnis anlegen.

Damit du die Projekt-Dateien mit deinem Editor von Windows aus bearbeiten kannst, binden wir das Windows-Verzeichnis C:\henrik42\ in dem Docker-Container an /home/node.

Du wirst das Verzeichnis in Docker Desktop sharen müssen, damit du es an den Container binden kannst.

C:\henrik42>docker run --rm -it --name foobar --user node -v C:\henrik42\:/home/node -w /home/node theasp/clojurescript-nodejs:shadow-cljs-alpine bash

Ich lege explizit ein Verzeichnis projekte/ für die folgenden Aktivitäten an, damit ich nachvollziehen kann, welche Dateien anschließend genau wo durch die Tools erstellt werden. Gerade im Zusammenhang mit Node/npm durchschaue ich noch nicht, was die Tools ganz genau treiben.

bash-5.0$ mkdir -p projekte && cd projekte

Als erstes legen wir via npx create-cljs-project hello-world-app das Projekt hello-world-app an:

bash-5.0$ npx create-cljs-project hello-world-app
npx: installed 1 in 1.534s
shadow-cljs - creating project: /home/node/projekte/hello-world-app
Creating: /home/node/projekte/hello-world-app/package.json
Creating: /home/node/projekte/hello-world-app/shadow-cljs.edn
Creating: /home/node/projekte/hello-world-app/.gitignore
Creating: /home/node/projekte/hello-world-app/src/main
Creating: /home/node/projekte/hello-world-app/src/test

Installing shadow-cljs in project.

npm notice created a lockfile as package-lock.json. You should commit this file.
+ shadow-cljs@2.11.10
added 99 packages from 106 contributors and audited 99 packages in 20.176s
found 0 vulnerabilities

Done. Actual project initialization will follow soon.

Der Aufruf legt neben dem Projektverzeichnis /home/node/projekte/hello-world-app auch die Verzeichnisse /home/node/.config und /home/node/.npm an. Falls du direkt unter Windows auf deinem PC arbeitest (und nicht in dem Docker-Container), werden diese Verzeichnisse wahrscheinlich unter APPDATA=C:\Users\<deine-user-id>\AppData\Roaming angelegt.

In dem Projekt-Verzeichnis findest du unter /home/node/projekte/hello-world-app/node_modules schon eine Reihe von npm-Paketen.

Falls du den Docker-Container verwendest, findest du diese Dateien und Verzeichnisse auch unter c:\henrik42\projekte\hello-world-app\:

c:\>dir c:\henrik42\projekte\hello-world-app
 Datenträger in Laufwerk C: ist OSDisk
 Volumeseriennummer: 006E-D511

 Verzeichnis von c:\henrik42\projekte\hello-world-app

25.12.2020  12:39    <DIR>          .
25.12.2020  12:39    <DIR>          ..
25.12.2020  12:38               154 .gitignore
25.12.2020  12:39    <DIR>          node_modules
25.12.2020  12:39            33.446 package-lock.json
25.12.2020  12:39               152 package.json
25.12.2020  12:38               118 shadow-cljs.edn
25.12.2020  12:38    <DIR>          src
               4 Datei(en),         33.870 Bytes

Nun schreiben wir das erste Programm nach c:/henrik42/projekte/hello-world-app/src/main/hello_world/core.cljs:

Verzeichnisse, die zu Clojure Namespaces gehören, müssen anstatt des - (Minus) einen _ (Unterstrich) verwenden. Daher wird für den Namespace hello-world das Verzeichnis hello_world angelegt.

(ns hello-world.core)

(defn greatings []
  (println "Hello, world!"))

Als nächstes musst du festlegen, was mit dem Code passieren soll. Dazu konfigurierst du in C:\henrik42\projekte\hello-world-app\shadow-cljs.edn den Build :my-app:

Wir verwenden hier node-script als Build-Ziel. Der ClojureScript-Compiler kann auch JavaScript erzeugen, das im Browser ausgeführt werden kann. Mit ClojureScript kann man eben auch Single-Page-Webanwendungen bauen (z.B. mit React bzw. Reagent).

{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}}}

Nun kannst du das Clojure(Script)-Programm in JavaScript übersetzen. Bedenke, dass der Compiler, der ClojureScript nach JavaScript übersetzt, in Clojure/Java geschrieben ist und auf der JVM läuft.

bash-5.0$ cd /home/node/projekte/hello-world-app/
bash-5.0$ shadow-cljs compile my-app
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - updating dependencies
Retrieving thheller/shadow-cljs/2.8.76/shadow-cljs-2.8.76.pom from https://clojars.org/repo/
Retrieving org/clojure/clojure/1.10.1/clojure-1.10.1.pom from https://repo1.maven.org/maven2/
[...]
Retrieving thheller/shadow-cljsjs/0.0.21/shadow-cljsjs-0.0.21.jar from https://clojars.org/repo/
Retrieving edn-query-language/eql/0.0.3/eql-0.0.3.jar from https://clojars.org/repo/
shadow-cljs - dependencies updated
[:my-app] Compiling ...
[:my-app] Build completed. (41 files, 40 compiled, 0 warnings, 50.16s)

Bei diesem Build passieren eine ganze Menge Dinge:

  • die Bibliotheken, die der Compiler benötigt (Maven-Artefakte), werden nach /home/node/.m2/repository bzw. c:\henrik42\.m2\repository\ geladen und dort gecacht (lokales Maven-Repository). Dieser Download kann eine Zeit lang dauern. Wenn du den Compiler in Zukunft nochmal ausführst, erfolgt der Download nicht erneut, solange die JAR-Dateien noch im lokalen Maven-Repo vorhanden sind.

Wenn du mit Docker-Containern arbeitest, solltest du darauf achten, dass du diese Caches über Bind-Mounts immer an dasselbe Windows-Verzeichnis bindest. Ansonsten würden die Downloads immer wieder erfolgen und das kostet dann unnötig Zeit.

  • der Compiler speichert sich Informationen über den Build in c:\henrik42\projekte\hello-world-app\.shadow-cljs\. Dadurch benötigen anschließend weitere Builds viel weniger Zeit. In dem Beispiel oben hat der Compiler 50,16 Sekunden gebraucht. Wenn ich ihn nun erneut ausführe, benötigt er nur 3,1 Sekunden.
bash-5.0$ shadow-cljs compile my-app
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
[:my-app] Compiling ...
[:my-app] Build completed. (41 files, 0 compiled, 0 warnings, 3.10s)
  • das Ergebnis des Builds wird nach C:\henrik42\projekte\hello-world-app\hello-world.js geschrieben.

Nun können wir das Programm ausführen.

bash-5.0$ node hello-world.js
Hello, world!

Mit diesem Test prüfe ich nur, ob der Build im Prinzip funktioniert. Schließlich wollen wir unser Programm ja nicht auf unserem PC sondern auf dem Microcontroller ausführen. Und falls unser Programm versuchen würde, etwas zu tun, was nur auf dem Micro geht (z.B. irgendwelche GPIOs zu setzen), dann könnten wir das Programm auch nicht auf dem PC ausprobieren.

Zwischenstand

OK, was haben wir bisher geschafft?

Wir haben den ClojureScript-Compiler und npm am Laufen (inkl. Download der Clojure(Script) Dependencies/Bibliotheken) und können das Kompilat (also die JavaScript-Datei) mit node auf dem PC ausführen.

Moduleabhängigkeiten

Der Test sieht erstmal gut aus. Aber wenn wir mal in C:\henrik42\projekte\hello-world-app\hello-world.js schauen, erkennen wir, dass das Programm scheinbar weiteren Code nachlädt:

#!/usr/bin/env node
(function(){
var shadow$provide = {};

var SHADOW_IMPORT_PATH = __dirname + '/.shadow-cljs/builds/my-app/dev/out/cljs-runtime';
if (__dirname == '.') { SHADOW_IMPORT_PATH = "/home/node/projekte/hello-world-app/.shadow-cljs/builds/my-app/dev/out/cljs-runtime"; }
global.$CLJS = global;
global.shadow$provide = {};
try {require('source-map-support').install();} 
catch (e) {console.warn('no "source-map-support" (run "npm install source-map-support --save-dev" to get it)');}
[...]

Ich benutze folgenden Aufruf, um zu ermitteln, was wir wirklich benötigen, um hello-world.js auszuführen. Über -v C:\henrik42\projekte\hello-world-app\hello-world.js:/the-app/hello-world.js binden wir ausschließlich diese eine Datei in den Container nach /the-app/hello-world.js und führen sie aus.

C:\>docker run --rm -it --user node -v C:\henrik42\projekte\hello-world-app\hello-world.js:/the-app/hello-world.js -w /the-app/ theasp/clojurescript-nodejs:shadow-cljs-alpine node hello-world.js
no "source-map-support" (run "npm install source-map-support --save-dev" to get it)
internal/fs/utils.js:220
    throw err;
    ^

Error: ENOENT: no such file or directory, open '/the-app/.shadow-cljs/builds/my-app/dev/out/cljs-runtime/goog.debug.error.js'
    at Object.openSync (fs.js:440:3)
    at Object.readFileSync (fs.js:342:35)
    at global.SHADOW_IMPORT (/the-app/hello-world.js:52:15)
    at /the-app/hello-world.js:2206:1
    at Object.<anonymous> (/the-app/hello-world.js:2247:3)
    at Module._compile (internal/modules/cjs/loader.js:1121:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1160:10)
    at Module.load (internal/modules/cjs/loader.js:976:32)
    at Function.Module._load (internal/modules/cjs/loader.js:884:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:67:12) {
  errno: -2,
  syscall: 'open',
  code: 'ENOENT',
  path: '/the-app/.shadow-cljs/builds/my-app/dev/out/cljs-runtime/goog.debug.error.js'
}

Die Fehlermeldung legt nahe, dass versucht wird, die Datei /the-app/.shadow-cljs/builds/my-app/dev/out/cljs-runtime/goog.debug.error.js zu laden. Scheinbar benötigen wir also auch Teile aus c:\henrik42\projekte\hello-world-app\.shadow-cljs\ für die Ausführung und binden dies nun via -v c:\henrik42\projekte\hello-world-app\.shadow-cljs\:/the-app/.shadow-cljs in den Container:

C:\>docker run --rm -it --user node -v C:\henrik42\projekte\hello-world-app\hello-world.js:/the-app/hello-world.js -v c:\henrik42\projekte\hello-world-app\.shadow-cljs\:/the-app/.shadow-cljs -w /the-app/ theasp/clojurescript-nodejs:shadow-cljs-alpine node hello-world.js 
no "source-map-support" (run "npm install source-map-support --save-dev" to get it)
Hello, world!

Schon besser. Aber scheinbar wird das npm Module source-map-support vermisst. Dies finden wir unter c:\henrik42\projekte\hello-world-app\node_modules\source-map-support\. Wir wissen nicht, ob und welche weiteren node_modules unser Programm benötigt, also stellen wir sie alle via -v c:\henrik42\projekte\hello-world-app\node_modules\:/the-app/node_modules zur Verfügung:

C:\>docker run --rm -it --user node -v C:\henrik42\projekte\hello-world-app\hello-world.js:/the-app/hello-world.js -v c:\henrik42\projekte\hello-world-app\.shadow-cljs\:/the-app/.shadow-cljs -v c:\henrik42\projekte\hello-world-app\node_modules\:/the-app/node_modules -w /the-app/ theasp/clojurescript-nodejs:shadow-cljs-alpine node hello-world.js
Hello, world!

OK, das sieht ganz gut aus. Das Thema Module-Abhängigkeiten-zur-Laufzeit müssen wir uns später nochmal anschauen.

EDIT 2021-01-04: nach ein paar Hinweisen auf Slack bin ich auf eine weitere Lösung gekommen. Die findest du am Ende des Artikels.

Auf den Micro!

Wir wissen nun, welche Dateien wir auf den Micro übertragen müssen. Auf dem Micro benötigen wir 55,7 MB Plattenplatz.

bash-5.0$ du -sch hello-world.js .shadow-cljs/ node_modules/
72.0K   hello-world.js
34.0M   .shadow-cljs/
21.6M   node_modules/
55.7M   total

Ich verpacke und komprimiere die Dateien via tar und übertrage die Dateien via scp.

Zuvor habe ich mir einen Non-Root-User henrik42 auf dem Omega2+ angelegt. Auf dem PC bauchen wir tar und scp und auf dem Micro brauchen wir tar und Dropbear oder OpenSSH.

C:\>tar -C C:/henrik42/projekte/hello-world-app -zcf - hello-world*.js node_modules .shadow-cljs | ssh henrik42@Omega-DC43 "tar -C /home/henrik42 -vzxf -"

Nun auf den Micro. Die Ausführungszeit von etwa 17 Sekunden ist für ein Hello-World erst einmal ernüchternd.

henrik42@Omega-DC43:~$ ls -Al
drwxr-xr-x    4 henrik42 henrik42      4096 Dec 26 11:20 .shadow-cljs
-rw-r--r--    1 henrik42 henrik42     73584 Dec 26 11:24 hello-world.js
drwxr-xr-x   84 henrik42 henrik42      4096 Dec 26 11:10 node_modules
henrik42@Omega-DC43:~$ time node hello-world.js
Hello, world!

real    0m17.252s
user    0m15.756s
sys     0m1.046s

Auf dem PC (im Docker Container) sehen die Zeiten (etwa 0,3 Sekunden) besser aus.

bash-5.0$ time node hello-world.js
Hello, world!

real    0m0.259s
user    0m0.156s
sys     0m0.025s

Zwischenstand

OK, was haben wir bisher geschafft?

Wir können auf dem PC ClojureScript nach JavaScript kompilieren, das Kompilat auf den Micro-Controller kopieren und dort via Node.js ausführen.

Eigentlich sind wir jetzt schon fertig ;-)

Build-Option release

shadow-cljs bietet die Möglichkeit, den Build so durchzuführen, dass alle Abhängigkeiten direkt in das Build-Target eingebaut werden, anstatt auf externe Dateien zu verweisen. Vielleicht hat das Einfluss auf die Ausführungsgeschwindigkeit.

Dies ist mein zweiter Build. Der erste hat deutlich länger als 11,63 Sekunden gedautert.

bash-5.0$ shadow-cljs release my-app
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
[:my-app] Compiling ...
[:my-app] Build completed. (41 files, 0 compiled, 0 warnings, 11.63s)

Wir wiederholen den Test und binden nur hello-world.js in den Container:

C:\>docker run --rm -it --user node -v C:\henrik42\projekte\hello-world-app\hello-world.js:/the-app/hello-world.js -w /the-app/ theasp/clojurescript-nodejs:shadow-cljs-alpine node hello-world.js
Hello, world!

Diesmal brauchen wir also weder c:\henrik42\projekte\hello-world-app\.shadow-cljs\ noch c:\henrik42\projekte\hello-world-app\node_modules für die Ausführung.

Diesen Stand kopiere ich nun nochmal auf den Micro:

C:\henrik42\projekte\hello-world-app>scp hello-world.js henrik42@Omega-DC43:

Und dort messen wir wieder die Ausführungszeit. Scheint mit 12 Sekunden etwa 5 Sekunden schneller zu sein.

henrik42@Omega-DC43:~$ time node hello-world.js
Hello, world!

real    0m12.302s
user    0m11.500s
sys     0m0.634s

Nachgemessen

Ich wollte es nochmal etwas genauer wissen. Daher habe ich dieses kleine Programm run-hello-world.js geschrieben:

startTime = new Date();
require("./hello-world.js");
console.log((new Date() - startTime) + " Millisekunden");

Auf dem Micro liefert die Ausführung für die release-Version:

henrik42@Omega-DC43:~$ time node run-hello-world.js
Hello, world!
510 Millisekunden

real    0m12.243s
user    0m11.496s
sys     0m0.595s

Und auf dem PC im Docker-Container:

bash-5.0$ time node run-hello-world.js
Hello, world!
13 Millisekunden

real    0m0.051s
user    0m0.039s
sys     0m0.010s

Und als Vergleich nochmal die Zeiten für die ursprüngliche compile-Version auf dem Micro und dem PC.

henrik42@Omega-DC43:~$ time node run-hello-world.js
Hello, world!
5443 Millisekunden

real    0m17.392s
user    0m15.875s
sys     0m0.969s
bash-5.0$ time node run-hello-world.js
Hello, world!
182 Millisekunden

real    0m0.219s
user    0m0.132s
sys     0m0.028s

Die lange Ausführungszeit auf dem Micro ergibt sich also vor allem aus der etwa 12 Sekunden langen Start-Up-Zeit von node. Die Ausführungszeiten von etwa 5400 Millisekunden für die compile und 500 Millisekunden für release Variante reichen zwar immer noch nicht an die Zeiten auf dem PC heran, aber das war auch nicht zu erwarten.

Falls wir also eine lang laufende Anwendung (wie z.B. eine Robotersteuerung) mit ClojureScript & Node.js auf den Micro bringen wollen, müssen wir mit einer 12 Sekunden Start-Up-Zeit und einer gewissen Ladezeit (min. 0,5 Sekunden) rechnen. Wie flüssig die Anwendung anschließend weiterläuft, müssen wir erst noch herausbekommen.

Schneller entwickeln

Bisher haben wir den Compiler einfach über shadow-cljs compile my-app aufgerufen. Da der Compiler in einer JVM läuft, muss man bei jedem Compile auf die Start-Up-Zeit der JVM warten.

Der folgende compile dauert nur 3 Sekunden, die ganze Programmausführung jedoch 23 Sekunden.

bash-5.0$ time shadow-cljs compile my-app
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
[:my-app] Compiling ...
[:my-app] Build completed. (41 files, 0 compiled, 0 warnings, 3.03s)

real    0m23.109s
user    0m26.261s
sys     0m1.870s

shadow-cljs bietet aber die Möglichkeit, in einem Server-Modus zu starten. D.h., wir starten die JVM einmalig und anschließend triggern wir den Compile zwar explizit von außen, aber wir brauchen nicht mehr auf den Start-Up der JVM zu warten.

bash-5.0$ shadow-cljs server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - server version: 2.8.76 running at http://localhost:9630
shadow-cljs - nREPL server started on port 43999

Und in einer zweiten Shell starten wir nun den Compile. Wichtig ist, dass der Prozess, der hier gestartet wird, den zuvor gestarteten Server unter localhost:9630 erreichen kann.

Da ich mit Docker arbeite, starte ich diese zweite Shell in demselben Container via docker exec -it -w /home/node/projekte/hello-world-app/ foobar bash. Das muss man nicht so machen, aber so ist es am einfachsten.

bash-5.0$ time shadow-cljs compile my-app
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - connected to server
[:my-app] Compiling ...
[:my-app] Build completed. (41 files, 0 compiled, 0 warnings, 2.93s)

real    0m3.187s
user    0m0.098s
sys     0m0.009s

Viel besser! Nun brauchen wir nur 3 Sekunden anstatt 23 Sekunden.

Zwischenstand

OK, was haben wir bisher geschafft?

Wir haben die Round-Trip-Zeit für unseren Think-Code-Run-Zyklus weiter verkürzt. Leider benötigt das Kopieren auf den Micro via tar/scp und das erneute Ausführen inkl. Start-Up-Zeit für node immer noch zu viel Zeit.

interaktive Entwicklung

Wenn man Clojure-Programme schreibt, möchte man während des Programmierens schon direkt den geschriebenen Code ausprobieren und damit auch testen. Wir möchten also, dass Änderungen an unserem Programm c:/henrik42/projekte/hello-world-app/src/main/hello_world/core.cljs sofort dazu führen, dass durch den Compiler der zugehörige JavaScript-Code erzeugt wird. Und ich möchte, dass der neue Code auch sofort von der laufenden node-Anwendung geladen und wirksam wird (Hot-Code-Reload).

shadow-cljs unterstützt dieses Vorgehen durch watch. Aus Gründen (die weiter unten erläutert werden) erweitern wir die :builds in C:\henrik42\projekte\hello-world-app\shadow-cljs.edn um :with-server, um damit den anschließenden Compile in die Ziel-Datei hello-world-with-server.js zu lenken.

{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}
  :with-server {:target :node-script
                :main hello-world.core/greatings
                :output-to "hello-world-with-server.js"}}}

Nun starten wir wieder einen Server-Prozess, nur diesmal über die Aktion watch with-server anstatt server.

Falls du noch den Server aus dem oben aufgeführten Beispiel laufen hast, solltest du ihn nun beenden, bevor du fortfährst.

bash-5.0$ shadow-cljs watch with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - server version: 2.8.76 running at http://localhost:9630
shadow-cljs - nREPL server started on port 35703
shadow-cljs - watching build :with-server
[:with-server] Configuring build.
[:with-server] Compiling ...
[:with-server] Build completed. (60 files, 1 compiled, 0 warnings, 4.52s)

Nun kannst du das Programm wieder auf dem PC testen (dazu musst du eine zweite Shell aufmachen; vgl. oben):

bash-5.0$ node hello-world-with-server.js
Hello, world!

Das Programm beendet sich nicht! Stattdessen verbindet es sich mit dem zuvor gestarteten Server (via localhost:9630) und wartet (das kannst du mit netstat prüfen; vgl. unten).

Nun ändern wir C:\henrik42\projekte\hello-world-app\src\main\hello_world\core.cljs und fügen eine Zeile hinzu.

(ns hello-world.core)

(defn greatings []
  (println "Hello, world!"))

(println "Geladen")

Nachdem die Datei gespeichert ist, setzt sofort der Compile ein:

[:with-server] Compiling ...
[:with-server] Build completed. (60 files, 1 compiled, 0 warnings, 0.42s)

Und in node-Shell sehen wir:

bash-5.0$ node hello-world-with-server.js
Hello, world!
Geladen

Die node-Anwendung hat eine WebSocket zum shadow-cljs-Server aufgebaut. Über diese WebSocket (auf Server-Port 9630) veranlasst der Server die node-Anwendung, den neu kompilierten Code (d.h. die Datei) zu laden.

In diesem Fall wird der Clojure-Namespace neu geladen. Es wird nicht erneut die Funktion greatings ausgeführt.

Wir können mit netstat -na die Verbindungen aufführen:

bash-5.0$ netstat -na
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 127.0.0.1:39597         0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:40661         0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:44617           0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:9630            0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:9630          127.0.0.1:47248         ESTABLISHED
tcp        0      0 127.0.0.1:47248         127.0.0.1:9630          ESTABLISHED

Der Server öffnet drei weitere Ports, die wir uns später anschauen.

Wenn du jetzt den shadow-cljs-Server beendest, indem du CTRL-C drückst, erfolgt folgende Ausgabe in der node-Shell und die Anwendung beendet sich.

REPL client disconnected

Wenn du nun die node-Anwendung erneut startest (ohne den Server erneut zu starten), siehst du eine Fehlermeldung und die Anwendung beendet sich sofort:

bash-5.0$ node hello-world-with-server.js
Geladen
Hello, world!
REPL client error Error: connect ECONNREFUSED 127.0.0.1:9630
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1134:14) {
  errno: -111,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: '127.0.0.1',
  port: 9630
}
REPL client disconnected

Dies ist der Grund dafür gewesen, dass ich das watch-Compile-Ergebnis in eine andere Datei schreiben lassen wollte. Du kannst mit diff hello-world.js hello-world-with-server.js die beiden Dateien vergleichen und wirst ein paar Unterschiede feststellen, auf die ich hier aber nicht im Detail eingehen möchte. Entscheidend ist, dass du verstehst, dass sich die node-Anwendung via HTTP/WebSocket-Verbindung mit dem Server verbindet. Und diese Logik ist in das Kompilat eingebaut worden. Falls du also node hello-world-with-server.js ausführst und keinen Server am laufen hast, wirst du immer eine Fehlermeldung erhalten.

Nun kopieren wir die node-Anwendung wieder auf den Micro:

C:\>tar -C C:/henrik42/projekte/hello-world-app -zcf - hello-world*.js node_modules .shadow-cljs | ssh henrik42@Omega-DC43 "tar -C /home/henrik42 -vzxf -"

Und starten erst den watch-Server auf dem PC und dann die Anwendung auf dem Micro:

henrik42@Omega-DC43:~$ node hello-world-with-server.js
Geladen
Hello, world!
REPL client error { Error: connect ECONNREFUSED 127.0.0.1:9630
    at Object._errnoException (util.js:1022:11)
    at _exceptionWithHostPort (util.js:1044:20)
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1198:14)
  code: 'ECONNREFUSED',
  errno: 'ECONNREFUSED',
  syscall: 'connect',
  address: '127.0.0.1',
  port: 9630 }
REPL client disconnected

Das klappt natürlich nicht! Denn der shadow-cljs-Server läuft ja auf meinem PC und die node-Anwendung versucht die WebSocket unter 127.0.0.1:9630 zu erreichen.

Wir müssen der node-Anwendung also sagen, unter welcher URL sie die WebSocket des shadow-cljs-Server erreicht. Das machen wir in C:\henrik42\projekte\hello-world-app\shadow-cljs.edn über :devtools-url:

IP/Hostname: du musst hier die URL eintragen, unter der dein Micro-Controller den Server auf deinem PC erreichen kann. Da ich manchmal über mein Heim-WLAN (FritzBox) mit dem Micro arbeite und mich ein anders Mal direkt über den WLAN-AP des Omega2+ mit dem Micro verbinden kann, hat mein PC aus Sicht des Micro mal die eine, mal die andere IP (und eine Route zum PC braucht der Micro auch! D.h. auch, dass die FritzBox die Route unterstützen muss!!). Außerdem möchte ich die node-Anwendung natürlich auch weiterhin auf meinem PC direkt testen können (so wie unsere ersten Tests oben) - d.h. der PC muss sich selber auch unter der angegebenen IP/Hostnamen kennen. Bisher habe ich noch keine smarte Idee, wie man diese Einstellungen alle am besten macht.

Windows-Defender-Firewall: damit der Micro (bzw. überhaupt irgendein Rechner) auf deinem PC Port 9630 zugreifen kann, muss du den Port in deiner Firewall auf dem PC freischalten. Dazu habe ich auf dem PC den Micro WLAN-AP als privates Netzwerk ausgewiesen und eine eingehende Regel für Port 9630 eingerichtet.

Docker: falls du mir Docker arbeitest, musst du nun noch via -p 9630:9630 den Port 9630 in den Container durchleiten:

C:\henrik42>docker run --rm -it --name foobar --user node -v C:\henrik42\:/home/node -w /home/node -p 9630:9630 theasp/clojurescript-nodejs:shadow-cljs-alpine bash
{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}
  :with-server {:target :node-script
                :devtools {:devtools-url "http://192.168.3.142:9630"}
                :main hello-world.core/greatings
                :output-to "hello-world-with-server.js"}}}

Nun also wieder den watch-Server starten ...

bash-5.0$ shadow-cljs watch with-server

... und sobald der Compile erfolgt ist (denn die Verbindungs-Logik/Konfiguration steckt im Kompilat!), die Dateien auf den Micro kopieren ...

C:\>tar -C C:/henrik42/projekte/hello-world-app -zcf - hello-world*.js node_modules .shadow-cljs | ssh henrik42@Omega-DC43 "tar -C /home/henrik42 -vzxf -"

... und auf dem Micro das Programm starten:

henrik42@Omega-DC43:~$ node hello-world-with-server.js
Geladen
Hello, world!

Nun ändern wir die letzte Zeile in unserem Programm C:\henrik42\projekte\hello-world-app\src\main\hello_world\core.cljs:

(ns hello-world.core)

(defn greatings []
  (println "Hello, world!"))

(println "Geladen Interaktiv!")

Sobald du die Datei speicherst, erscheint die Compile-Meldung und auf dem Micro wird die Zeile Geladen ausgegeben.

Warum wird nicht Geladen Interaktiv! ausgegeben?

Wir haben zwar eine WebSocket-Verbindung zwischen node-Anwendung und watch-Server, aber über diese wird die node-Anwendung durch den watch-Server nur angewiesen, die Dateien neu zu laden. Und die neuen Dateien (bzw. die neue Version dieser Dateien) befinden sich eben bisher nur auf dem PC und nicht auf dem Micro (weil wir sie bisher nicht erneut dorthin kopiert haben). Solange man die node-Anwendung lokal auf dem PC testet, funktioniert das tadellos, weil dann sowohl der watch-Server als auch die node-Anwendung auf dieselben Dateien zugreifen. Nur durch die Trennung auf zwei Rechner (bzw. durch die Trennung der Dateisysteme) entsteht das Problem.

Mir fallen dazu folgende Lösungen ein:

  • Rsync: immer, wenn der watch-Server einen Compile durchgeführt hat, ruft er ein rsync auf, um die neuen Stände der Dateien auf den Micro zu bringen, bevor er anschließend die node-Anwendung veranlasst, die Dateien neu zu laden. Dadurch würde die node-Anwendung immer den neuen/aktuellen Stand lesen. Für diese Lösung bräuchten wir aber eine Möglichkeit, dem watch-Server zu sagen, dass er zum entscheidenden Zeitpunkt rsync aufrufen soll.

  • Samba: anstatt die Dateien vom PC auf den Micro zu kopieren, greift der Micro einfach direkt über einen SAMBA-Mount auf die Dateien des PC zu. In diesem Szenario entstehen gar keine Kopien auf dem Micro (wir bräuchten die Dateien auch nicht mehr zu kopieren), der Micro greift einfach automatisch immer auf den aktuellensten Stand der Dateien zu. Diese Lösung führt jedoch dazu, dass du nun immer den PC benötigst, wenn du das Programm auf dem Micro ausführen möchtest (für die Entwicklung ist das ok. Um dann in Produktion zu gehen, müsstest du die Dateien dann einmalig auf den Micro kopieren).

  • SSHFS: diese Option verhält sich wie die Anbindung via SAMBA. Leider kann ich SSHFS wohl leider nicht installieren, weil mein Kernel zu alt ist.

root@Omega-DC43:~# opkg install sshfs
Installing sshfs (2.10-1) to root...
Downloading https://downloads.openwrt.org/releases/18.06.6/packages/mipsel_24kc/packages/sshfs_2.10-1_mipsel_24kc.ipk
Collected errors:
 * satisfy_dependencies_for: Cannot satisfy the following dependencies for sshfs:
 *      kernel (= 4.14.81-1-d8e1d03c55ce087331756a8674577a86) *
 * opkg_install_cmd: Cannot install package sshfs.

Rsync

Die Übertragung der Dateien via rsync ist für sich schon eine Verbesserung zu dem Verfahren via tar/scp, das ich bisher verwendet habe: es geht einfach viel schneller!!

Ich verwende einen rsync-Daemon auf dem Micro. Hier /root/rsyncd.conf:

gid = henrik42
uid = henrik42
read only = false
use chroot = true
transfer logging = true
log format = %h %o %f %l %b
log file = /root/rsync.log

[henrik42]
        path = /home/henrik42

Nun den rsync-Daemon starten:

WARNUNG: Ich führe das rsync als root aus. Das ist nicht gut. Das Programm sollte aus Sicherheitsgründen als User node ausgeführt werden.

root@Omega-DC43:~# rsync --daemon --address=0.0.0.0 --config=/root/rsyncd.conf --no-detach --port 4567 -v

Und auf dem PC die Dateien übertragen. Zuvor habe ich auf dem Micro alle Dateien gelöscht, um die Übertragungszeiten messen zu können.

henrik42@Omega-DC43:~$ rm -rf .shadow-cljs/ node_modules/ *.js

Hier der erste Aufruf:

$ time rsync --stats --times --inplace -r .shadow-cljs node_modules hello-world*.js rsync://henrik42@192.168.3.1:4567/henrik42/

Number of files: 1855
Number of files transferred: 1571
Total file size: 93938849 bytes
Total transferred file size: 93938849 bytes
Literal data: 93938849 bytes
Matched data: 0 bytes
File list size: 38476
File list generation time: 0.001 seconds
File list transfer time: 0.000 seconds
Total bytes sent: 94056255
Total bytes received: 30993

sent 94056255 bytes  received 30993 bytes  2162925.24 bytes/sec
total size is 93938849  speedup is 1.00

real    0m42,617s
user    0m0,046s
sys     0m0,062s

Und nun gleich nochmal. Einfach um zu sehen, wie lange es dauert festzustellen, dass nichts übertragen werden muss:

$ time rsync --stats --times --inplace -r .shadow-cljs node_modules hello-world*.js rsync://henrik42@192.168.3.1:4567/henrik42/

Number of files: 1855
Number of files transferred: 0
Total file size: 93938849 bytes
Total transferred file size: 0 bytes
Literal data: 0 bytes
Matched data: 0 bytes
File list size: 38476
File list generation time: 0.001 seconds
File list transfer time: 0.000 seconds
Total bytes sent: 38769
Total bytes received: 292

sent 38769 bytes  received 292 bytes  26040.67 bytes/sec
total size is 93938849  speedup is 2404.93

real    0m0,643s
user    0m0,031s
sys     0m0,031s

Und nun mache ich eine Änderung am Code und übertrage den auf den Micro:

$ time rsync --stats --times --inplace -r .shadow-cljs node_modules hello-world*.js rsync://henrik42@192.168.3.1:4567/henrik42/

Number of files: 1855
Number of files transferred: 7
Total file size: 93938861 bytes
Total transferred file size: 88478 bytes
Literal data: 12384 bytes
Matched data: 76094 bytes
File list size: 38476
File list generation time: 0.001 seconds
File list transfer time: 0.000 seconds
Total bytes sent: 51884
Total bytes received: 1207

sent 51884 bytes  received 1207 bytes  106182.00 bytes/sec
total size is 93938861  speedup is 1769.39

real    0m0,677s
user    0m0,000s
sys     0m0,015s

OK, das sind Zeiten, mit denen man arbeiten kann.

Ich habe eine ganze Weile rumprobiert, bis es wirklich gut geklappt hat. Entscheidend ist, dass für die Ermittlung, welche Dateien sich geändert haben und übertragen werden müssen, der Zeitstempel verwendet wird. Wenn ich auf CHECKSUM-Überprüfung wechsel, dauert es 100x länger. Das ist einfach zu viel für den Micro (CPU, Dateizugriff). Deswegen ist --times auch entscheidend. Die Option --inplace bringt nochmal etwas Zeit, wenn tatsächlich Dateien übertragen werden.

:build-hooks

Nun müssen wir nur noch den watch-Server dazu bringen, rsync aufzurufen, nachdem er die neuen Dateien geschrieben hat und bevor er die node-Anwendung dazu auffordert, diese Dateien neu zu laden.

Wenn du mit dem Docker-Container theasp/clojurescript-nodejs:shadow-cljs-alpine arbeitest, musst du noch rsync installieren bzw. am besten baust du dir ein eigenes Image und verwendest das.

Das geht über den Eintrag :build-hooks in C:\henrik42\projekte\hello-world-app\shadow-cljs.edn:

{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}
  :with-server {:target :node-script
                :build-hooks [(rsync.rsync/rsync)]
                :devtools {:devtools-url "http://192.168.3.142:9630"}
                :main hello-world.core/greatings
                :output-to "hello-world-with-server.js"}}}

Und in C:\henrik42\projekte\hello-world-app\src\dev\rsync\rsync.clj implementieren wir den Hook in Clojure.

(ns rsync.rsync
  (:require [clojure.java.shell :as sh]))

(defn rsync
  {:shadow.build/stage :flush}
  [build-state & args]
  (sh/sh "rsync" "--stats" "--times" "--inplace" "-r" 
         ".shadow-cljs" 
         "node_modules" 
         "hello-world-with-server.js" 
         "rsync://henrik42@192.168.3.1:4567/henrik42/")
  build-state)

Nun muss einmal der watch-Server neu gestartet werden. Wenn du nun die letzte Codezeile in C:\henrik42\projekte\hello-world-app\src\main\hello_world\core.cljs in (println "Geladen Interaktiv! WOW!") änderst, solltest du nach wenigen Augenblicken die Ausgabe auf dem Micro sehen.

Zwischenstand

OK, was haben wir bisher geschafft?

Wir haben auf unserem PC einen ClojureScript-zu-JavaScript-Compiler-Server-Prozess, der unseren ClojureScript-Code, jedesmal wenn wir ihn ändern/speichern, sofort kompiliert, die Dateien via rsync auf den Micro kopiert und der laufenden node-Anwendung über eine WebSocket einen Trigger gibt, so dass die node-Anwendung die soeben kopierten Dateien in-flight neu liest/auswertet. Der neue Code wird in der node-Anwendung sofort wirksam, ohne dass wir die node-Anwendung durchstarten müssten.

Aber es geht noch weiter.

Die REPL

Bisher müssen wir Code, den wir während des Programmierens ausprobieren wollen, als Funktion in eine der Quelldateien schreiben und warten, dass der Compiler die Quellen übersetzt, die Dateien dann via rsync übertragen werden und dann innerhalb der laufenden node-Anwendung ausgeführt werden. D.h. auch, dass der Code, den wir ausprobieren wollen, entweder durch das Neuladen eines Namespaces oder durch einen langlaufenden Server-Prozess innerhalb der node-Anwendung angestoßen werden können muss. Diese Einschränkung ist etwas sperrig und umständlich.

In der Clojure-Welt verwendet man daher gerne die REPL. Auch shadow-cljs bietet REPL-Unterstützung. Dabei musst du berücksichtigen, dass du es mit (mindestens) zwei Laufzeiten zu tun hast, mit denen du via REPL interagieren kannst: zum einen kannst du auf den watch-Server via REPL zugreifen und zum anderen kannst du auf die node-Anwendung via REPL zugreifen. Wir schauen uns beide Optionen an.

Es gibt weitere Konstellationen, bei denen z.B. auf deinem PC ein JVM-Prozess inkl. REPL-Prompt gestartet werden und zusätzlich auch ein node-Prozess auf dem PC gestartet wird und die JVM-REPL dann mit dem node-Prozess via WebSocket gekoppelt sind. Aber ich will mich hier um die Anbindung an den Micro konzentrieren.

Als erstes bauen wir eine Clojure REPL zu dem laufenden watch-Server auf. Du kannst anhand von (type 1) leicht sehen, dass dies eine Clojure-REPL und nicht eine ClojureScript-REPL ist.

bash-5.0$ shadow-cljs clj-repl with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - connected to server
shadow-cljs - REPL - see (help)
To quit, type: :repl/quit
shadow.user=> (type 1)
java.lang.Long

Du kannst auch verifizieren, dass die REPL wirklich in dem watch-Server läuft, indem du einfach etwas ausgibt. Die Ausgabe sollte in der Konsole/Shell des watch-Servers erscheinen.

shadow.user=> (.println System/out "boo!")
nil

Mit dieser REPL kannst du nun Clojure programmieren. Z.B. könntest du den Code von rsync.rsync/rsync interaktiv entwickeln und testen.

Du kannst aber nun auch eine ClojureScript REPL zu der node-Anwendung aufbauen und auf dem Mirco etwas ausgeben. Du hast jetzt vollen Zugriff auf die node-Laufzeit.

(shadow/repl :with-server)
cljs.user=> (type 1)
#object[Number]
cljs.user=> (println "boo!")
nil

Du kannst jetzt auch schon über die ClojureProgramme auf dem Omega2+ Micro aufrufen - wie z.B. fast-gpio set 44 1 um die LED zu steuern.

Die Anbindung zwischen node-Anwendung und watch-Server ist recht robust. Du kannst z.B. die node-Anwendung stoppen und wenn du dann versuchst, über die CLJS REPL etwa auszuwerten, bekommst du eine freundliche Fehlermeldung. Wenn du die node-Anwendung dann wieder startest, kannst du die CLJS REPL weiter verwenden. Du hast dann natürlich den "Zustand" der zuvor gestoppten Laufzeit verloren.

> (type 1)
No application has connected to the REPL server. Make sure your JS environment has loaded your compiled ClojureScript code.

Anstatt erst die Clojure-REPL zu starten und dann erst die CLJS-REPL, kannst du auch gleich die CLJS-REPL starten:

bash-5.0$ shadow-cljs cljs-repl with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - connected to server
cljs.user=> (type 1)
#object[Number]

Zwischenstand

OK, was haben wir bisher geschafft?

Wir können nun nicht nur unseren Code in unseren Quelltext-Dateien ständig ändern, kompilieren und auf dem Micro testen/ausführen, sondern wir haben auf unserem PC eine interaktive Shell (REPL) in unsere node-Anwendung. Über diese REPL haben wir volle Kontrolle über die node-Anwendung. Auf diese Weise können wir Code interaktiv ausprobieren und den Zustand unseres Programms inspizieren und kontrollieren/ändern.

Es fehlt nun noch ein letzter Schritt.

nREPL

Eine REPL ist super, um über einen Prompt/Shell interaktiv mit einer Clojure(Script)-Laufzeit zu interagieren (z.B. um eine Funktion mit bestimmten Argumentwerten aufzurufen). Wenn man aber z.B. eine neue Funktion ausprobieren möchte, so müsste man sie vollständig in die REPL eingeben. Das ist umständlich.

Ich könnte die Funktion natürlich auch in eine der ClojureScript-Dateien schreiben und den Kompiler die Datei übersetzen lassen. Aber dann würde der gesamte Namespace in der node-Laufzeit neu geladen werden. Ich möchte aber nur eine Funktion zufügen bzw. ändern.

Es wäre viel schöner, wenn man den Clojure-Code in dem eigenen Lieblingseditor schreiben könnte und den Code direkt aus dem Editor heraus in die REPL "übergeben" könnte.

Genau dafür gibt es nREPL.

Hier musst du zwei Szenarien auseinanderhalten: du kannst mit irgendeinem Editor deine ClojureScript-Quelltext-Dateien editieren und diese werden durch den watch-Server sofort kompiliert, sobald du die Datei speicherst. Dazu brauchst du weder die REPL noch nREPL.

nREPL ist eine Schnittstelle, die es Editoren und anderen Tools ermöglicht, sich über ein Netzwerk mit einer laufenden Clojure-Laufzeit zu verbinden. Über diese Verbindung können dann bestimmte Aktionen erfolgen -- wie z.B. die Auswertung von Clojure-Ausdrücken.

Der watch-Server startet einen solchen nREPL-Server (vgl. nREPL server started on port 37973). Du kannst den Port fix einstellen, musst es aber nicht.

~/projekte/hello-world-app $ shadow-cljs watch with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - server version: 2.8.76 running at http://localhost:9630
shadow-cljs - nREPL server started on port 37973
shadow-cljs - watching build :with-server
[:with-server] Configuring build.
[:with-server] Compiling ...
[:with-server] Build completed. (60 files, 1 compiled, 0 warnings, 7.08s)

Da ich den watch-Server in einem Docker-Container laufen lasse, mein Emacs-Editor aber unter Windows läuft, muss ich den nREPL-Server auf einem fixen Port starten, damit ich ihn dann in meinem Docker-Container via -p <port>:<port> nach außen publizieren kann.

In C:\henrik42\projekte\hello-world-app\shadow-cljs.edn trage ich :nrepl {:port 9000} ein:

{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :nrepl {:port 9000}

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}
  :with-server {:target :node-script
                :build-hooks [(rsync.rsync/rsync)]
                :devtools {:devtools-url "http://192.168.3.142:9630"}
                :main hello-world.core/greatings
                :output-to "hello-world-with-server.js"}}}

Und ich starte den Docker-Container neu. Der nREPL-Server wird im Container auf Port 9000 gebunden. Und ich publiziere ihn nach außen ebenfalls auf Port 9000. Ich hatte bisher das rsync immer manuell unter Windows gestartet. Da ich nun aber das rsync durch den watch-Server starten werde (vgl. unten), muss ich rsync noch via apk add rsync explizit installieren.

C:\>docker run --rm -it --name foobar --user root -v C:\henrik42\:/home/node -w /home/node -p 9630:9630 -p 9000:9000 theasp/clojurescript-nodejs:shadow-cljs-alpine bash
bash-5.0# apk add rsync
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/community/x86_64/APKINDEX.tar.gz
(1/4) Installing libacl (2.2.52-r6)
(2/4) Installing libattr (2.4.48-r0)
(3/4) Installing popt (1.16-r7)
(4/4) Installing rsync (3.1.3-r1)
Executing busybox-1.30.1-r2.trigger
OK: 115 MiB in 67 packages
bash-5.0# su node
~ $ cd projekte/hello-world-app/
~/projekte/hello-world-app $ shadow-cljs watch with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - server version: 2.8.76 running at http://localhost:9630
shadow-cljs - nREPL server started on port 9000
shadow-cljs - watching build :with-server
[:with-server] Configuring build.
[:with-server] Compiling ...
[:with-server] Build completed. (60 files, 1 compiled, 0 warnings, 7.72s)

Es gibt für viele Editoren nREPL-Integrationen. Ich benutze Emacs/CIDER. Nachdem ich den Emacs mit localhost:9000 verbunden habe, bin ich zwar mit der nREPL verbunden, aber dies ist eine Clojure-nREPL.

Du kannst dich gleichzeitig mit einer REPL (vgl. oben) und via nREPL mit dem watch-Server verbinden.

shadow.user> (type 1)
java.lang.Long
shadow.user> 

Nun kannst du die nREPL aber umschalten auf CLJS-nREPL:

shadow.user> (shadow/repl :with-server)
To quit, type: :cljs/quit
[:selected :with-server]
cljs.user> 

Wenn du nun Clojure-Ausdrücke auswertest, erfolgt die Auswertung in der node-Anwendung. Der Compile erfolgt natürlich weiterhin im ClojureScript-Compiler, der in der JVM läuft. D.h. in diesem Fall wird der Clojure(Script)-Code weiterhin in der JVM nach JavaScript kompiliert. Der Kopierschritt via rsync ist in diesem Fall aber nicht nötig. Stattdessen wird der JavaScript-Code über die WebSocket in die node-Anwendung gebracht und dort ausgeführt.

cljs.user> (type 1)
#object[Number]

Zusammenfassung

Pew! Das war ein langer Weg! Wo sind wir angekommen?

  • Wir können mit unserem Lieblingseditor (z.B. Notepad++, Emacs, vi, VSCode, Eclipse) ClojureScript-Programme schreiben.

  • Diese werden bei jedem "Speichern" direkt von einem Kompiler, der auf unserem PC (optional in einem Docker-Container) in einer JVM läuft, in JavaScript übersetzt und dieser JS-Code wird in Dateien geschrieben.

  • Diese JavaScript-Dateien können wir via rsync (oder tar/scp) auf einen Micro-Controller (oder anderen Rechner/PC/Server) bringen und dort unser ClojureScript-Programm via Node.js ausführen.

Für ein besseres Programmier-Erlebnis haben wir weitere Optionen:

  • Wir können während der Entwicklung lokal auf unserem PC Node.js benutzen, um unsere Anwendung auszuführen (also ohne den Micro). Jedesmal, wenn der Compiler neuen JavaScript-Code produziert, wird dieser Code sofort von unserer Anwendung geladen und ist dann in der Anwendung wirksam (Hot-Code-Reload).

  • Wir können uns über eine REPL mit unserer Node.js-Anwendung verbinden und interaktiv mit unserem Programm kommunizieren (wie in einer Windows/UNIX-Shell). So können wir u.a. Funktionen definieren und aufrufen.

  • Das alles können wir aber auch mit unserem Micro machen! Dazu übertragen wir automatisch den gerade neu kompilierten JavaScript-Code via rsync auf den Micro und dort läuft dann die Node.js-Anwendung, die wir mit der REPL auf unserem PC verbinden können. Wir haben somit auf dem PC eine Shell in unsere Node.js-Anwendung hinein, die auf dem Micro läuft.

  • Für eine noch direktere Think-Code-Run-Feedback-Schleife können wir die nREPL-Integration unseres Editors verwenden, um Funktionen und ganze Dateien (Namespace) direkt aus dem Editor heraus in der Node.js-Anwendung auszuwerten.

That's all Folks! Viel Spaß!

Moduleabhängigkeiten 2.0

Anstatt mehrere Verzeichnisse auf den Micro-Controller via rsync zu kopieren, können wir den gesamten Code, den wir genötigen, in einem einzigen Verzeichnis zusammenfassen.

Dazu tragen wir in C:\henrik42\projekte\hello-world-app\shadow-cljs.edn das Zielverzeichnis :output-dir ein und passen :output-to an:

{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []

 :nrepl {:port 9000}

 :builds
 {:my-app {:target :node-script
           :main hello-world.core/greatings
           :output-to "hello-world.js"}
  :with-server {:target :node-script
                :build-hooks [(rsync.rsync/rsync)]
                :devtools {:devtools-url "http://192.168.3.142:9630"}
                :main hello-world.core/greatings
                :output-dir "hello-world-target"
                :output-to "hello-world-target/hello-world-with-server.js"}}}

In C:\henrik42\projekte\hello-world-app\src\dev\rsync\rsync.clj passen wir die rsync-Anweisung an. Außerdem müssen wir wegen eines kleinen Bugs den Hook mit den nebenläufigen Builds-Tasks via shadow.build.async/wait-for-pending-tasks! synchronisieren.

(ns rsync.rsync
  (:require [clojure.java.shell :as sh]
            [shadow.build.async]))

(defn rsync
  {:shadow.build/stage :flush}
  [build-state & args]
  (let [cmd ["rsync" "--stats" "--times" "--inplace" "-r" 
         "hello-world-target"
         "rsync://henrik42@192.168.3.1:4567/henrik42/"]]
    (println "Synchronisiere mit Build-Tasks ....")
    (shadow.build.async/wait-for-pending-tasks! build-state)
    (println (apply str "exec: " (interpose " " cmd)))
    (println (apply sh/sh cmd))
    build-state))

Nun musst du einmalig das Verzeichnis c:\henrik42\projekte\hello-world-app\hello-world-target\ erstellen und folgende Befehle ausführen:

npm init -y
npm install ws source-map-support

Anschließend kannst du den watch-Server neu starten.

~/projekte/hello-world-app $ shadow-cljs watch with-server
shadow-cljs - config: /home/node/projekte/hello-world-app/shadow-cljs.edn  cli version: 2.8.76  node: v13.2.0
shadow-cljs - server version: 2.8.76 running at http://localhost:9630
shadow-cljs - nREPL server started on port 9000
shadow-cljs - watching build :with-server
[:with-server] Configuring build.
[:with-server] Compiling ...
Synchronisiere mit Build-Tasks ....
exec: rsync --stats --times --inplace -r hello-world-target rsync://henrik42@192.168.3.1:4567/henrik42/
{:exit 0, :out
Number of files: 179 (reg: 169, dir: 10)
Number of created files: 179 (reg: 169, dir: 10)
Number of deleted files: 0
Number of regular files transferred: 169
Total file size: 7,024,417 bytes
Total transferred file size: 7,024,417 bytes
Literal data: 7,024,417 bytes
Matched data: 0 bytes
File list size: 0
File list generation time: 0.002 seconds
File list transfer time: 0.000 seconds
Total bytes sent: 7,036,915
Total bytes received: 3,303

sent 7,036,915 bytes  received 3,303 bytes  1,564,492.89 bytes/sec
total size is 7,024,417  speedup is 1.00
, :err }
[:with-server] Build completed. (60 files, 1 compiled, 0 warnings, 8.79s)

Auf dem Micro startest du die Anwendung inkl. REPL/nREPL-Anbindung mit

henrik42@Omega-DC43:~$ node hello-world-target/hello-world-with-server.js

Alles zusammen braucht nun 7,1 MB. Das ist erheblich weniger als die 55,7 MB, die wir ursprünglich übertragen hatten, bevor wir :output-dir verwendet hatten.

henrik42@Omega-DC43:~$ du -sh hello-world-target
7.1M    hello-world-target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment