Web-1/Websockets
Websockets: inleiding
Voorkennis: HTML formulieren; server state (context); cookies; AJAX.
Het websockets-protocol biedt een verbinding tussen de client en de server, voor berichten in beide richtingen, met een lage overhead per bericht. In tegenstelling tot het normale HTTP-protocol kan het initiatief voor een bericht bij beide kanten liggen, dus ook bij de server. De server kan op een willekeurig moment een bericht "pushen" naar de client (browser).
Voor de chat-toepassing betekent dit dat de client niet steeds bij de server hoeft te vragen of er nog nieuws is: als bij de server een bericht van een gebruiker (browser) binnenkomt, kan de server dit direct doorgeven naar de andere gebruikers.
Een dergelijke bi-directionele verbinding, met een lage overhead, is ook van belang voor het Internet of Things. Immers, de aansturing vanuit het internet van actuatoren in de IoT-eindpunten (sensor/actuator-knopen) kan op elk moment plaatsvinden - niet alleen wanneer zo'n eindpunt er om vraagt. Ook gebruikersinterfaces waarmee je sensoren uitleest en actuatoren bestuurt kunnen door deze bi-directionele verbinding up-to-date gehouden worden.
- Met andere woorden: een dergelijke bi-directionele verbinding verlaagt de latency voor de berichten, in het bijzonder voor de berichten van de server naar de client.
http-protocol | websockets protocol |
---|---|
tijdelijke transactie (request-response) | "permanente" verbinding |
overhead van opbouwen voor elke transactie (bericht) | overhead voornamelijk bij opbouwen van verbinding mininale overhead per bericht |
geen resources nodig in rust | verbinding claimt permanent resources |
verbinding kan verbroken raken | |
direct door de browser af te handelen | altijd (client-side) Javascript nodig voor afhandeling |
Bij een websockets-verbinding moet je er altijd rekeningen mee houden dat deze verbroken kan worden: je moet er dan zelf voor zorgen dat deze weer opgebouwd wordt.
- Hoe weet je of de verbinding nog bestaat? Wordt er regelmatig een "ping" overgestuurd?
- dit geldt ook voor bijv. een Pub/Sub-protocol als MQTT.
Voor ons voorbeeld van de chat-toepassing betekent dit dat we in de client-code een JavaScript-programma moeten opnemen voor het opzetten van de websockets-verbinding, en voor het afhandelen van de server-berichten die via deze verbinding binnen komen. We kunnen eventueel ook de berichten van de client naar de server via deze verbinding versturen.
Een voorbeeld van een dergelijke situatie zien we bij een chat-server: meerdere clients zijn betrokken bij de chat, en op het moment dat één client een bericht ingevoerd heeft, moeten alle andere deelnemers aan de chat hiervan bericht krijgen. Het eerste bericht is van de client naar de server; de distributie van dit bericht is een communicatie van de server naar alle clients.
Ik weet niet precies hoe websockets werken in het geval van NodeRed.
In het geval van http is duidelijk waar de reponse naar toe moet.
- websockets geeft een verbinding - in plaats van een request/response;
- deze verbinding is bi-directioneel
- je moet ook aan de client-side (browser) iets speciaals doen;
- dit betekent meestal: JS voor het openen/afhandelen van de websockets-berichten
- dit kun je niet met het normale http-protocol doen
- we moeten dus een speciale html-pagina maken met zo'n stukje JS erin.
Voor de chat-server kunnen we websockets gebruiken op verschillende manieren:
- alleen voor de distributie van de nieuwe berichten (broadcast)
- moeten we dan ook volgnummers bijhouden?
- of ook voor het sturen van de berichten naar de server.
Server vs. client
In het geval van http is er duidelijk onderscheid tussen de server en de client. Maar ook in het geval van ws moet je dit onderscheid maken.
- de server heeft een URL; de client niet
- de server luistert naar deze URL (in NodeRed: "listener" node, dat is een verborgen configuratie-node)
- de client neemt het initiatief voor een nieuwe verbinding, door een bericht naar de server te sturen. Dit is dan een bi-directionele verbinding, tussen deze browser en de server. (Er vindt geen broadcast plaats naar alle browsers: dat moeten we door middel van een experiment laten zien.)
Wat is de betekenis van de verschillende nodes, en van de verschillende modes daarvan, in NodeRed?
NB: zonder handler voor "socket open" werkt het voorbeeld niet. (specificatie?) --- werkt kennelijk wel zonder deze handler. Maar het duurt soms wel lang voordat er een websockets-verbinding gemaakt is (minuut?). Soms geeft de open (connect) een time-out.
Een eerste websockets-experiment
NodeRed-code: /Nodered-websockets-test
In dit eerste experiment laten we het gebruik van websockets in NodeRed zien.
- de websocket wordt geopend door het client-side script, als de html-pagina (
.../ws-test
geladen is; - met de inject-node verstuur je een bericht vanuit de server, via de websocket-verbinding, naar de client (browser);
- met de button op de webpagina stuur je een bericht naar de server, die dit stuurt naar alle socket-clients (broadcast).
Het client-side script myscript
opent de websocket met als url /ws/test
, en definieert de functies voor het ontvangen en versturen van de berichten:
var socket = new WebSocket("wss://" + location.host + "/ws/test");
var logdiv = document.getElementById("logdiv");
var hiButton = document.getElementById("hibutton");
socket.onopen = function (event) {
logdiv.innerHTML = logdiv.innerHTML + "<p>[socket open]</p>";
};
socket.onmessage = function (event) {
logdiv.innerHTML = logdiv.innerHTML + ("<p>" + event.data + "</p>");
};
hiButton.onclick = function () {
socket.send("{{user}}: Hi");
}
De html-webpagina definieert de button voor het versturen van een bericht, en de log-div waar de ontvangen berichten getoond worden.
<!DOCTYPE html>
<html>
<head>
<title>Websockets test</title>
</head>
<body>
<h3>Websockets test</h3>
<p>Welcome {{user}}!</p>
<button id="hibutton">Click to send hello</button>
<div id="logdiv">{{{chatlog}}}</div>
<script>
{{{myscript}}}
</script>
</body>
</html>
De server-side functie get-name
maakt een nieuwe gebruikersnaam aan voor de client- tenzij deze al gedefinieerd is. We gebruiken hierbij de node-context om steeds een volgende naam te kunnen genereren (zie ook XXX). En we gebruiken cookies om een gebruikersnaam op te slaan en op te halen (zie ook YYY).
function newName () {
var names = ["Ad", "Bea", "Carel", "Diana",
"Eef", "Fien", "Gerard", "Hannie", "Iza", "Jo"];
var index = context.get("index") || 0;
context.set("index", (index + 1) % 10);
return names[ index ];
}
var user = msg.req.cookies["user"] || "";
if (user === "") {
user = newName();
}
msg.user = user;
msg.cookies = {user: user};
return msg;
Opdrachten
- maak een nieuwe flow-pagina aan, en kopieer daarop deze flow;
- test deze flow, door in twee verschillende browsers de html-pagina
.../ws-test
te openen.- stuur een bericht vanuit de server, via de inject-node, en controleer dat dit bij beide clients aankomt ("broadcast").
- stuur een bericht vanuit de client, en controleer dat dit bij beide clients aankomt;
- zorg ervoor dat een bericht van een client alleen bij de server en bij de client zelf aankomt:
- verwijder hiervoor in de functie-node
kill session
de regel:delete msg._session;
- controleer dat er nu geen broadcast van client-berichten meer plaatsvindt.
- verwijder hiervoor in de functie-node
Opmerkingen:
- de websocket-nodes maken gebruik van een "verborgen" configuratie-node: een Websockets-listener node, met als URL
/ws/test
.- configuratie-nodes vind je via "hamburger menu" -> Configuration nodes
- je moet ervoor zorgen dat zowel de websockets-input-node als de -output-node geconfigureerd zijn als "Listen on" voor dit pad/deze node.
- soms krijg je (door kopieer-acties e.d.) een tweede verborgen Websockets-listener node voor hetzelfde pad.
- Deze moet je dan verwijderen; zie https://groups.google.com/forum/#!topic/node-red/Aek6xPFpFqI
Een eenvoudige chat-toepassing
Als voorbereiding op de chat-uitbreiding van onze website maken we eerst een eenvoudige stand-alone chat-toepassing. Deze volgt in grote lijnen de flow van het eerste websockets-experiment.
We voegen aan de client-zijde een formulier toe waarin de tekst van het chatbericht geplaatst kan worden. De "submit" van het formulier moet dan resulteren in het versturen van het bericht naar de server.
Voor de eenvoud genereren we in deze versie de namen voor de gebruikers. In onze website zullen we uiteindelijke de naam van de ingelogde gebruiker willen gebruiken.
Opdrachten
We voeren de wijzingen voor de chat-toepassing stap voor stap uit.
Stap 1: begin met websockets-flow
(1) We beginnen met een kopie van de vorige websockets-flow.
- maak een nieuwe flow-pagina aan, en kopieer deze flow naar die pagina (>>>...of, aanpassen van de vorige flow?).
- noem de nieuwe pagina Simple chat;
- schakel de Websockets test flow uit.
- test de werking van de gekopieerde flow (als hierboven).
- ga na wat er gebeurt bij een refresh van de pagina
- welke gebruikersnaam verwacht je?
- wat verwacht je voor de chat-geschiedenis?
Stap 2: toevoegen van chat-invoer
(2) Als volgende stap voegen we een formulier toe voor het invoeren van de chat-tekst. Dit formulier bestaat uit een tekst-veld en een submit-button. De "submit" van het formulier gebeurt via de submit-button, of door de return-toets. De functie-aanroep preventDefault();
zorgt ervoor dat deze submit helemaal door vanuit het JavaScriptafgehandeld wordt, en niet op de "default" html-manier door de browser. Het html-template voor de chat-pagina wordt dan:
<!DOCTYPE html>
<html>
<head>
<title>Simple chat</title>
</head>
<body>
<h3>Simple chat</h3>
<p>Welcome {{user}}!</p>
<div id="logdiv"></div>
<form id="chatform">
{{user}}: <input type="text" name="chattext"></input>
<button type="submit">Send</button>
</form>
<script>
{{{clientscript}}}
</script>
</body>
</html>
Het bijbehorende script:
var socket = new WebSocket("wss://" + location.host + "/ws/test");
var logdiv = document.getElementById("logdiv");
var chatform = document.getElementById("chatform");
socket.onopen = function (event) {
logdiv.innerHTML = logdiv.innerHTML + "<p>[socket open]</p>";
};
socket.onmessage = function (event) {
logdiv.innerHTML = logdiv.innerHTML + ("<p>" + event.data + "</p>");
};
chatform.onsubmit = function (event) {
socket.send("{{user}}: " + chatform.chattext.value);
event.preventDefault();
chatform.chattext.value = "";
}
- ga na wat er gebeurt als je de
preventDefault();
weglaat.
Stap 3: bewaren van de chat-geschiedenis
(3) De chat-tekst gaat verloren als je in de browser de pagina refresht. Dit probleem kun je voorkomen door in de server de ontvangen berichten te bewaren. We gebruiken daarvoor de flow-context.
- vervang in de html-pagina
<div id="logdiv"></div>
door<div id="logdiv">{{{chatlog}}}</div>
- voeg aan de functie-node kill-session de code toe de binnengekomen berichten op te slaan in de flow-context:
var chatlog = flow.get("chatlog") || "";
flow.set("chatlog", chatlog + "<p>" + msg.payload + "</p>");
- voeg aan de functie-node get-user de code toe om de bewaarde chat-log op te halen:
msg.chatlog = flow.get("chatlog") || "";
- test deze flow: in dit geval moet de chat-geschiedenis bewaard blijven.
Stap 4: invoeren van de gebruikersnaam
(4) Tenslotte voegen we een extra pagina toe voor het aanmelden van gebruikers. Via het formulier van deze pagina kan een gebruiker zijn naam opgeven. Het formulier handelen we hier op de html-manier af, zonder client-side scripts. Het insturen van het formulier is dan een POST-request met als inhoud "username=..." (opgegeven invoer).
Het template login-form voor deze pagina:
<!doctype html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h3>Login</h3>
<p>Please enter your name.</p>
<div id="logdiv">{{{chatlog}}}</div>
<form method="post" action="login" >
Your name:
<input type="text" name="username"></input>
<button type="submit" ">Submit</button>
</form>
</body>
</html>
In de functie login-name verwerken we de opgegeven invoer. We zetten een cookie voor de gebruikersnaam. En we zetten de msg-velden voor het html-template van de pagina.
var user = msg.payload.username || "";
msg.user = user;
msg.cookies = {user: user};
msg.chatlog = flow.get("chatlog") || "";
return msg;
Tenslotte passen we het html-template voor de pagina aan: als een gebruiker nog niet ingelogd heeft krijgt deze een andere versie van de chat-pagina te zien. We maken daarvoor gebruik van condities in het pagina-template.
Voorbeeld van een conditioneel template:
{{^user}}
<p> Please <a href="/login">login</a>. </p>
{{/user}}
{{#user}}
<p> Welcome {{user}}! </p>
{{/user}}
Dit template resulteert in de html-tekst <p> Please <a href="/login">login</a>.</p>
als user een lege string als waarde heeft; in het andere geval is het resultaat: <p> Welcome {{user}}!</p>
Het volledige pagina-template:
<!DOCTYPE html>
<html>
<head>
<title>Simple chat</title>
</head>
<body>
<h3>Simple chat</h3>
{{^user}}
<p>Please <a href="/login">login</a>.</p>
{{/user}}
{{#user}}
<p>
Welcome {{user}}! <br>
<a href="/login">switch to other user</a>
</p>
<div id="logdiv">{{{chatlog}}}</div>
<form id="chatform">
{{user}}:
<input type="text" name="chattext"></input>
<button type="submit">Send</button>
</form>
{{/user}}
<script>
{{{myscript}}}
</script>
</body>
</html>
- voeg aan de pagina een formulier toe voor het invullen van de gebruikersnaam.
- handel dit formulier op de klassieke manier ("POST") af.
Je kunt je resultaat vergelijken met de voorbeeld-uitwerking: /Nodered-simple-chat
Opdrachten
- Ga na wat er gebeurt als je html-code gebruikt in een gebruikersnaam.
- Hoe verklaar je dit?
- Ga na wat er gebeurt als je html-code gebruikt in een chat-bericht.
- Hoe verklaar je dit?
- Is dit wenselijk? Wat zijn de mogelijke gevolgen?
- Hoe kun je dit veranderen - zodat het resultaat vergelijkbaar is met dat bij gebruikersnamen?
Meer over websockets
Websockets JavaScript API
Voor het gebruik van websockets in JavaScript hebben we de volgende opdrachten:
- openen van een websocket:
var socket = new WebSocket("wss://...url...");
- het protocol is
wss:
voor een beveiligde verbinding,ws:
voor een niet-beveiligde. - het socket-object gebruiken we dan in de volgende opdrachten
- het protocol is
- ontvangen van een websockets-bericht:
socket.onmessage = function(event) {...}
- het eigenlijke bericht vind je dan in
event.data
- het eigenlijke bericht vind je dan in
- versturen van een bericht:
socket.send(data);
- bij verandering van de toestand van de verbinding:
socket.onopen = function (event) {...}
socket.onclose = function (event) {...}
Opmerkingen
- nu we veel meer werk doen aan de client-kant, kunnen we ook het bijhouden van de discussie wel aan die kant doen: we hoeven niet steeds de complete discussie over te sturen. Eventueel kunnen we die via een "refresh" wel krijgen.
- kun je in de client (browser) eenvoudig nagaan of er een websocket-verbinding is? de status van het websocket-object suggereert van wel.
- je kunt een bestaand websockets-object niet opnieuw openen: je moet dan een nieuwe verbinding (met een nieuw object) aanmaken.