8. Elm - Eine funktionale Programmiersprache zur Entwicklung von Web-Apps

8.7 Mit der Außenwelt kommunizieren: Ports

Was tun Sie, wenn Sie nicht die ganze Funktionalität Ihrer App in Elm schreiben können oder wollen? Vielleicht wollen Sie ja eine externe Api verwenden, die nun mal nur in Javascript existiert. Hierfür gibt es in Elm das Konzept der Ports. Ein ausgehender Port ist eine Funktion (z.B. schickeDatenAnJS), die Ihr Argument an die Javascript-Laufzeitumgebung sendet. Diese kann dann einen Callback definieren, d.h. eine Funktion processWhateverDataFromElm, die jedes Mal aufgerufen wird, wenn im Elm-Code per schickeDatenAnJS ein Cmd erzeugt wird. Demonstrieren wir es an einem kleinen Beispiel (lesen Sie auf jeden Fall auch das Kapitel in elmprogramming; meine Beispiele bauen stark auf denen aus diesem Buch auf).

Als erstes müssen wir die Rahmenbedingungen schaffen, um mit Javascript zu kommunizieren. Erinnerin Sie sich daran, dass Elm entworfen wurde, um Web-Apps zu erstellen. Sie können ja nicht erwarten, dass jeder User Elm installiert hat. Daher können Sie Ihre Seite von Elm nach Html kompilieren:

elm make src/Website03ExpandList.elm                     
Success! Compiled 1 module.

    Website03ExpandList ───> index.html
                

Führen Sie das mit einem Ihrer Elm-Programme durch und schauen in die index.html. Sie sehen hier den zu Javascript kompilierten Elm-Code. Wenn Sie selbst zusätzlich noch Javascript schreiben wollen, dann bietet es sich an, das kompilierte Elm von der Html-Datei zu trennen. Dies geht so:

elm make src/Website03ExpandList.elm --output mycompiledfile.js                    
Success! Compiled 1 module.

    Website03ExpandList ───> mycompiledfile.js

Jetzt müssen Sie aber noch "per Hand" die umgebende Html-Seite schreiben.

<!DOCTYPE html>
<html>
<body>
    <div id="elm-code-is-loaded-here"></div>
		
    <script src="./mycompiledfile.js"></script>
    <script>
      var app = Elm.Website03ExpandList.init({
        node: document.getElementById("elm-code-is-loaded-here")
      });      
    </script>

</body>
</html>

Die rot markierten Stellen müssen Sie natürlich an Ihre Gegebenheiten anpassen. Sie können jetzt ganz normal diese Html-Datei erweitern, Css oder weitere Javascript-Dateien einbinden und so weiter.

Ausgehende Nachrichten: von Elm nach Javascript

Öffnen Sie die Datei Page11Outgoing.elm. Ich repetiere hier kurz die wichtigsten Zeilen.

port module Page11Outgoing exposing (..)

Die Zeile, die den Modulnamen festlegt, muss mit port module statt module beginnen;

port sendData : String -> Cmd msg
                

Das port ist ein Keyword; sendData ist der von uns festgelegte Name einer Funktion. Die Funktion existiert nur als Signatur und hat keinen Körper; grund dafür ist, dass Elm automatisch den Code intern erzeugt, und auch schon festgelegt ist, was diese Funktion machen soll: sie schickt ihr Argument an die Javascript-Laufzeitumgebung, die ihn dann "abhören" kann. Der Rückgabewert von sendData ist ein Cmd, den sie z.B. in der Funktion update als Rückgabewert zurückliefern können, so wie in diesen Zeilen:

        SendDataToJS ->
            ( model, sendData model.inputString )

Ein Druck auf den Knopf verändert das Modell nicht, erzeugt aber einen Command: den model.inputString an die Javascript-Laufzeitumgebung zu schicken.

Wenn die Funktion sendData gar keinen Körper hat, warum braucht sie dann überhaupt einen Namen? Warum können wir nicht einfach port model.inputString statt sendData model.inputString schreiben, wenn eh klar ist, was das auslöst? Die Antwort wird klar, wenn Sie sehen, wie wir von der Javascript-Seite aus die Nachricht empfangen: hierfür brauchen wir den Namen der Funktion; anders ausgedrückt, wenn Sie mehrere Kanäle wollen, um von Elm Daten nach Javascript zu schicken, dann würden Sie mit dem Schlüsselwort port mehrere Port-Funktionen definieren, z.B.

port sendUsername : String -> Cmd msg
port sendLoginTime : Date -> Cmd msg
port sendFilenames : List String -> Cmd msg
Wie lesen wir per Javascript aus Ports? Kompilieren Sie die Datei Page11Outgoing.elm:
elm make ports/Page11Outgoing.elm --output page11outgoing.js
und speichern Sie die Datei page11outgoing.html. Öffnen Sie sie und fügen Sie die rot markierten Codezeilen ein:
<!DOCTYPE html>
<html>
<body>
    <div id="elm-code-is-loaded-here"></div>
		
    <script src="./page11outgoing.js"></script>
    <script>
      var app = Elm.Page11Outgoing.init({
        node: document.getElementById("elm-code-is-loaded-here")
      });    
      app.ports.sendData.subscribe(function(data) {
        console.log("Data from Elm: ", data);
		document.querySelector("#message-from-port").textContent = JSON.stringify(data); 
      });  
    </script>
    <hr>
    <h2>This is javascript</h2>
    <p>Message from elm: <span style="color:blue" id="message-from-port"></span></p>
</body>
</html>

In Zeile 11 sprechen Sie über app.ports.sendData die in Ihrem Elm-Code definierte Funktion port sendData : String -> Cmd msg an. Der Javascript-Funktion subscribe überreichen Sie eine Callback-Funktion (im obigen Beispiel anonym per function(data) {...} definiert); diese wird dann jedes Mal ausgeführt, wenn der Elm-Code einen mit sendData erzeugten Command schickt.

Vorsicht: Elm ist immer noch eine funktionale Sprache. Wenn Sie also zum Beispiel sendData "useless message" an anderer Stelle Ihres Codes erzeugen, dann erzeugt Ihnen das ein Cmd-Objekt; das wird aber nicht von sich aus der Laufzeitumgebung

Übungsaufgabe Experimentieren Sie. Was geschieht, wenn Sie andere Datentypen als String über den Port schicken? Insbesondere, wenn Sie rekursive Datentypen wie zum Beispiel BinaryTree = Leaf Int | Brach BinaryTree BinaryTree über den Port schicken?

Übungsaufgabe Erweitern Sie den Code. Dieser soll nun zwei ausgehende Ports definieren. Der erste soll wie oben funktionieren. Über den neuen soll auf Knopfdruck eine Nachricht geschickt werden; Javascript soll dann in einem Counter die Anzahl dieser Nachrichten zählen und ausgeben.

Subscriptions

Subscriptions sind ein den Commands verwandtes Konzept. Erinnern Sie sich an Cmd: hiermit bitten wir die Laufzeitumgebung, etwas zu tun, was Elm-Code nicht direkt schafft, z.B. einen Http-Request abzuschicken; der jeweilige Cmd braucht dann üblicherweise als Argument eine Message, damit er weiß, was er unserem Elm-Code schicken soll, wenn der Command ausgeführt ist (also beispielsweise der Http-Server geantwortet hat). Subscriptions sind ähnlich, unterscheiden sich aber dadurch, dass sie sozusagen "andauernde" Commands sind; einfachstes Beispiel wäre wahrscheinlich "schicke mir alle 500 Millisekunden die Message ClockTick". Hierfür müssen Sie das Paket elm/time installieren:

elm install elm/time

Lesen und starten Sie dann bitte . Die entscheidenden Codezeilen sind

listenToClockTicks : Model -> Sub Message
listenToClockTicks model =
    Time.every 500 ClockTick

in der ein Sub-Objekt erzeugt wird, und

main : Program () Model Message
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = listenToClockTicks
        }

wo wir per subscriptions = listenToClockTicks unsere Subscription der Außenwelt mitteilen.

Übungsaufgabe Schreiben Sie eine Elm-App, die im Sekundentakt eine Liste von Fibonacci-Zahlen erweitert. Beginnen soll sie mit 0 und 1.

Eingehende Nachrichten mit Ports

Erweitern wir nun unser altes Beispiel; jetzt sollen auch Daten von Javascript nach Elm hereingeschickt werden können. Wie können wir der Laufzeitumgebung mitteilen, dass immer, wenn Javascript uns etwas schickt, wir die Nachricht XYZ erwarten? Wenn Sie immer-dann-wenn eine Nachricht empfangen wollen, machen Sie das mit Subscriptions: Sub msg, so wie im letzten Beispiel listenToClockTicks. Schauen Sie sich Page12SendReceive.elm an, insbesondere folgende Zeilen:

Wir brauchen eine neue Nachricht für eingehende Nachrichten:
type Message
    = SendDataToJS
    | InputChanged String
    | ReceivedDataFromJS String
In update legen wir fest, was mit dieser Nachricht zu tun ist:
update : Message -> Model -> ( Model, Cmd Message )
update msg model =
    case msg of
...
        ReceivedDataFromJS data ->
            ( { model | messageFromJavascript = data }, Cmd.none )                        
                        
Wir erschaffen den Port:
port receiveData : (String -> msg) -> Sub msg
und registrieren ihn schließlich als Subscription:
main : Program () Model Message
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = _ -> receiveData ReceivedDataFromJS
        }                        
                    

Wir kompilieren:

elm make ports/Page12SendReceive.elm --output page12sendreceive.js 
und passen die Html-Seite an (port-spezifische Passagen sind rot).
<!DOCTYPE html>
<html>

<body>
  <hr>
  <h2>Here is elm.</h2>
  <div id="elm-code-is-loaded-here"></div>

  <script src="./page12sendreceive.js"></script>
  <script>
    app = Elm.Page12SendReceive.init({
      node: document.getElementById("elm-code-is-loaded-here")
    });

    app.ports.sendData.subscribe(function (data) {
      console.log("Data from Elm: ", data);
      document.querySelector("#messages-from-port").textContent = JSON.stringify(data);
    });
  </script>
  <div>
    <hr>
    <h2>Here is javascript</h2>
    <p>You can enter a new Elm model in json: <span id="messages-from-port" style="color:blue"></p>
    <p><textarea cols="40" rows="5" id="message-to-port" placeholder="elm model as json"></textarea>
      <button onClick="buttonClicked()">Send to elm</button>
    </p>
  </div>
  <script>
    function buttonClicked() {
      let data = document.querySelector("#message-to-port").value;
      app.ports.receiveData.send(data);
    }  
  </script>
</body>

</html>
                    

Übungsaufgabe Schreiben Sie eine Elm-App, auf der man per Knopfdruck die Cookie-Daten anzeigen lassen kann und per Knopfdruck auch setzen kann.

Übungsaufgabe Schreiben Sie eine App mit Elm-Frontend und einem Server. Das Frontend soll eine Websocket-Verbindung mit dem Server aufbauen. Der Server liest von der Konsole Zeilen ein und schickt die dann über den Websocket. Das Frontend soll die dann anzeigen.

Hierfür brauchen Sie einen eingehenden Port. Das Javascript in der finalen Html-Seite soll dann beim Laden der Seite einen Websocket zum Server eröffnen und alle eingehenden Nachrichten der Elm-App über den Port schicken.

Übungsaufgabe Fügen Sie der App aus der letzten Aufgabe eine Gegenrichtung hinzu. Der User soll auf dem Frontend einen Text eingeben können, der dann nicht per Http-Request sondern per Websocket an den Server übertragen werden soll. Hierfür brauchen Sie einen ausgehenden Port. Der umgebenden Html-Seite müssen Sie nun Javascript-Code app.ports.YOUR_PORT_NAME.subscribe(...) hinzufügen und in ... eine Funktion schreiben, die die Daten über den Websocket verschickt.

Übungsaufgabe Schreiben Sie eine Elm-App mit einem Svg-Objekt und MouseEvents. Jede Mausbewegung über dem Svg-Objekt soll über Websockets an einen Server übertragen werden. Den Websocket müssen Sie im Javascript-Code einrichten und dann über ausgehende Ports bedienen.

Übungsaufgabe Erweitern Sie Ihre Seite aus der letzten Aufgabe, so dass eingehende Nachrichten vom Websocket laufend angezeigt werden. Es steht Ihnen frei, zu entscheiden, welche Nachrichten der Server über den Websocket schicken soll. Zufallszahlen, um es einfach zu halten?