Powered by TCPDF (www.tcpdf.org)
Academiejaar 2013–2014
Faculteit Ingenieurswetenschappen en Architectuur Valentin Vaerwyckweg 1 – 9000 Gent
Schaalbare real-time interactiviteit in het Zentrick-videoplatform
Masterproef voorgedragen tot het behalen van het diploma van Master in de industriële wetenschappen: informatica
Laurent DE SMET Promotoren: dr. Veerle ONGENAE ing. Pieter DELBEKE (Zentrick) ing. Tim DE PAUW (Zentrick)
Voorwoord Vooraleer we ons verdiepen in interactieve video, software-architecturen en technische uiteenzettingen over asynchroon programmeren, wil ik eerst nog enkele mensen bedanken. Ten eerste wil ik mijn twee externe promotoren, Pieter en Tim, heel hartelijk bedanken voor hun begeleiding van deze masterproef. Hun expertise was van onschatbare waarde en uit de talrijke gesprekken die we voerden omtrent de problemen waarop ik stuitte, vloeide altijd wel iets waardevol voort. Naast hen bedank ik ook de andere collega’s van Zentrick voor het cre¨eren van een creatieve omgeving waar ik in optimale condities mijn masterproef kon uitvoeren. Vervolgens wil ik dr. Veerle Ongenae bedanken om de rol op te nemen van interne promotor. Zij heeft de taak van Tim prima overgenomen na zijn carri`ereswitch en leverde daarbij altijd waardevolle feedback. Als voorlaatste bedank ik mijn ouders omdat ze mij de kans hebben gegeven om te studeren en mezelf verder te ontwikkelen. Ze hebben lang moeten wachten vooraleer hun investering in mijn studies een teken van rendement gaf, maar toch bleven ze altijd in mij geloven. Als mijn hevigste supporters zullen zij meer dan wie ook blij zijn, dat de eindstreep na negen jaar hoger onderwijs eindelijk bereikt is. Tot slot bedank ik mijn vriendin, Ellen. Ondertussen zijn we al meer dan zes jaar samen en zij was bv. een bepalende factor die leidde tot de positieve ommekeer in mijn carri`ere als student. Daarnaast wil ik ook onze kat, Ludo, bedanken voor de vele uren dat hij slapend vanop het bed waakte over mij, terwijl ik naarstig aan het studeren was.
Laurent De Smet, september 2014
Abstract NEDERLANDS. Zentrick is een bedrijf gespecialiseerd in online video waarbij een video geannoteerd wordt met interactieve apps. In deze masterproef wordt een oplossing uitgewerkt om deze interactiviteit in real-time aan te sturen bij live-streams met een groot aantal kijkers. Daarvoor wordt gebruikgemaakt van een gedistribueerde versie van het publish/subscribepattern door het uitbouwen van een event-driven architectuur. De architecturale componenten worden daarbij gehuisvest binnen het cloudplatform van Amazon. Een combinatie van HTTPlong-polling en WebSocket zorgt voor de bidirectionele communicatie tussen deze componenten. Verder werd gekozen voor Node.js als JavaScript-gebaseerde webservertechnologie omwille van zijn event-driven concurrency model dat goed samengaat met een groot aantal simultane verbindingen. Het gebruik van JavaScript in een grootschalig project wordt daarbij uitgebreid behandeld met bijzondere aandacht voor asynchrone programmeertechnieken. Testen wijzen aan dat ´e´en applicatieserver binnen ons systeem in staat is om 1000 simultane clients te behandelen. De architectuur kan ook horizontaal uitgeschaald worden om extra capaciteit te voorzien bij een toenemend aantal clients. ENGLISH. Zentrick is a company targeting online videos that are augmented with interactive apps. In this master dissertation a solution is developed to control the interactivity in real time of a live stream with a huge amount of viewers. Therefore an event-driven architecture is used to host a distributed version of the publish/subscribe pattern. The different components of the architecture reside within the cloud infrastructure from Amazon. We use a combination of HTTP long polling and WebSocket to take care of the bidirectional communication channels between these components. Node.js was chosen as the webserver technology based on JavaScript because of its event-driven concurrency model which is a good match for a large number of simultaneous connections. We extensively investigate the use of JavaScript in large-scale projects with special attention to asynchronous programming. Tests indicate the ability of one application server to handle 1000 simultaneous clients. The architecture can also scale horizontally to manage an increasing amount of clients.
INHOUDSOPGAVE
i
Inhoudsopgave 1 Inleiding
1
1.1
Situering . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1
1.2
Zentrick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.3
Real-time interactiviteit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
1.4
Overzicht . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
1.4.1
Vereistenanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
1.4.2
Onderzoeksvragen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7
1.4.3
Implementatie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
1.4.4
Performantie en schaalbaarheid . . . . . . . . . . . . . . . . . . . . . . . .
10
1.4.5
Besluit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
10
2 Vereistenanalyse 2.1
2.2
11
Publish/subscribe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
2.1.1
Performantie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
2.1.2
Schaalbaarheid . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
2.1.3
Parallellisme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
15
Observable model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
3 Architectuur
18
3.1
Cloud computing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
3.2
Architecturale componenten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
19
3.2.1
Applicatieservers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
19
3.2.2
Load balancer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
21
3.2.3
Eventbus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
23
3.2.4
Databank . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
25
3.2.5
Content distribution network . . . . . . . . . . . . . . . . . . . . . . . . .
27
Synopsis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
30
3.3
INHOUDSOPGAVE
ii
4 Real-time web 4.1
4.2
4.3
32
HTTP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
32
4.1.1
Polling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
33
4.1.2
Long-polling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
34
4.1.3
Streaming . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
37
WebSocket
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
40
4.2.1
WebSocket-protocol . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
40
4.2.2
WebSocket-API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
4.2.3
Deployment . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
44
Synopsis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
45
5 Schaalbare webserverarchitecturen 5.1
Concurrency
47
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
47
5.1.1
Thread-based . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
48
5.1.2
Event-driven . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
51
5.1.3
Metafoor van het restaurant . . . . . . . . . . . . . . . . . . . . . . . . . .
53
5.2
Node.js . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
53
5.3
Synopsis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
6 Professioneel JavaScript 6.1
58
Overzicht . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
58
6.1.1
Primitieve types en object types . . . . . . . . . . . . . . . . . . . . . . .
59
6.1.2
Functies . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
61
Modulariteit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
63
6.2.1
Module pattern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
6.2.2
Asynchronous module definition . . . . . . . . . . . . . . . . . . . . . . .
65
6.2.3
CommonJS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
67
6.2.4
Zentrick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
68
Paradigma’s . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
69
6.3.1
Objectgeori¨enteerd programmeren . . . . . . . . . . . . . . . . . . . . . .
70
6.3.2
Functioneel programmeren
. . . . . . . . . . . . . . . . . . . . . . . . . .
75
Kwaliteitscontrole . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
79
6.4.1
Linting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
79
6.4.2
Testing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
80
6.5
Build-automatisering . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
82
6.6
Synopsis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
84
6.2
6.3
6.4
INHOUDSOPGAVE
7 Asynchroon programmeren
iii
86
7.1
Synchroon programmeren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
86
7.2
Continuation passing style . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
89
7.3
Event-based . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
93
7.4
Promises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
94
7.5
Promises en generators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
98
7.6
Synopsis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
8 Implementatie 8.1
8.2
8.3
108
Publish/subscribe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108 8.1.1
Client . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
8.1.2
Server . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Observable model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123 8.2.1
Client . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
8.2.2
Server . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Project DigitasLBi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
9 Performantie en schaalbaarheid
135
9.1
Load-distributie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 135
9.2
Load-testing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136 9.2.1
Performantie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
9.2.2
Schaalbaarheid . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
10 Besluit
142
A Klasse in JavaScript
146
B Client-side real-time module
148
C Resultaten load-testing
150
INLEIDING
1
Hoofdstuk 1
Inleiding 1.1
Situering
In het begin van de jaren 90 vond Tim Berners-Lee het web uit met als doel een beter communicatiesysteem voor het CERN te ontwerpen. Al snel werd duidelijk dat dit systeem van statische HTML-pagina’s die aan elkaar gelinkt worden door hyperlinks veel meer toepassingen had dan enkel het delen van onderzoekspapers. Het web groeide en werd al snel ´e´en van de belangrijkste toepassingen van het internet. Met de ontwikkeling van de scripttaal JavaScript in 1995 werd het mogelijk om pagina’s dynamisch in de client aan te passen via de DOM-API. Deze API stelt de webpagina voor als een boomstructuur waarbij veranderingen aan de UI kunnen doorgevoerd worden door de onderliggende boomstructuur aan te passen. Dit werd toen dynamic HTML (DHTML) genoemd, een term die nu nauwelijks nog gebruikt wordt. DHTML werd echter enkel gebruikt om webpagina’s op te fleuren met animaties, maar de samenstelling van HTML-pagina’s gebeurde volledig op de server door templating-engines. Wanneer een gebruiker bepaalde informatie wou doorsturen, resulteerde dit in een HTTP-POST-request, waarbij de server deze informatie verwerkte om vervolgens een volledig nieuwe pagina samen te stellen a.d.h.v. de template-engine. Deze pagina werd dan teruggestuurd naar de browser die deze opnieuw ging renderen. Dit wordt een page-refresh genoemd. De browser speelde dus de rol van een thin client waarbij die enkel diende om de view te renderen en gebruikersinvoer naar de server door te sturen. Dit model heeft als nadeel dat voor iedere kleine wijziging telkens de volledige HTML-pagina wordt teruggestuurd naar de browser. Deze HTML-pagina moet vervolgens geparset worden tot een DOM-boomstructuur die telkens opnieuw moet worden gerenderd. Het voordeel van dit model is dat alle logica zich op ´e´en plaats bevindt, aan de server-side. Omstreeks het jaar 2000 werd een eerste aardverschuiving opgetekend door de komst van het
1.1 Situering
2
XMLHttpRequest-object in de JavaScript runtime-omgeving van de browser1 . Dit bracht de mogelijkheid om een HTTP-request te sturen in de achtergrond zonder daarbij automatisch een page-refresh te triggeren. Het werd nu mogelijk om stukken data van de server af te halen in de achtergrond en de pagina aan te passen met deze data a.d.h.v. DHTML. Deze techniek werd Ajax genoemd, wat oorspronkelijk een acroniem was voor Asynchronous JavaScript and XML. In 2008 werd met de komst van Google Chrome en zijn JavaScript-engine V8 aangetoond dat JavaScript ook snel kon zijn. Al snel werden er voor alle grote browsers snelle JavaScriptengines ontwikkeld die toelieten om heel performante client-side code te schrijven. Met HTML5 verschenen er ook nog eens extra API’s die het mogelijk maakten om bv. het bestandssysteem te benaderen, data op de client op te slaan of GPS-co¨ordinaten te bepalen. Dankzij Ajax, HTML5 en de snelle JavaScript-engines werd de overgang van websites naar webapplicaties mogelijk. Deze overgang van websites naar webapplicaties is er ook ´e´en van een thin client model naar een fat client model. Moderne webapplicaties die geen enkele page-refresh triggeren worden daarbij single-page web applications (SPWA) genoemd. In tegenstelling tot native applicaties bieden webapplicaties het voordeel dat ze cross-platform zijn, aangezien nagenoeg alle huidige gebruikersapparaten beschikken over een browser. Native applicaties bieden echter het voordeel dat ze sneller zijn en meer mogelijkheden hebben. SPWA’s hebben geen enkele van de effici¨entienadelen die besproken werden bij de klassieke webpagina’s. Daarbij neemt de complexiteit wel toe aangezien de logica nu verdeeld wordt over de client en de server. Bepaalde onderdelen zoals validatie worden zelfs uitgevoerd op zowel de client als de server: client-side validatie komt de responsiviteit van de applicatie ten goede, maar aangezien we een client nooit kunnen vertrouwen dienen we ook server-side validatie uit te voeren. Door de toegenomen complexiteit aan de client-side van een SPWA, dient er nu ook meer aandacht besteed te worden aan het ontwerp van de client-side code. Daarbij denken we aan het separation of concerns-principe en design patterns die zich al meermaals bewezen hebben aan de server-side, zoals o.a. het gebruik van Model-View-Controller (MVC). Met de komst van SPWA’s zijn er dan ook talloze client-side MV*-frameworks ontstaan die als gemeenschappelijke eigenschap hebben dat ze allemaal een scheiding aanbieden tussen de view en het model. 1
Het XMLHttpRequest-object ontstond in 1999 binnen Internet Explorer 5.0 voor de Microsoft Office Web
Access-applicatie onder de naam XMLHTTP.
1.2 Zentrick
1.2
3
Zentrick
Zentrick is een jong bedrijf opgericht in 2010 met als doel om online videobeleving voorgoed te veranderen. Daarbij wordt een online videoplatform aangeboden dat bedrijven in staat stelt om video’s interactief te maken en deze vervolgens te publiceren op het web. Dankzij deze interactiviteit is video niet langer een eenrichtingscommunicatiekanaal maar een medium waarbij kijkers aan de slag kunnen gaan met het product binnen de video. Een interactieve video kan daarbij beschouwd worden als een SPWA die ge¨ıntegreerd kan worden in bestaande websites of -applicaties. Een interactieve video (zie figuur 1.1) bestaat uit een videofragment waarbij verschillende interactieve componenten boven het beeld gelegd worden, waardoor de kijker allerlei acties kan ondernemen zonder de video te verlaten. Deze interactieve componenten worden apps genoemd en Zentrick levert zelf al enkele standaard apps aan. Een voorbeeld daarvan zijn de social apps die het mogelijk maken om via ´e´en enkele muisklik een video te delen op een sociaal netwerk naar keuze. De combinatie van een video met interactieve apps wordt binnen Zentrick een experience genoemd.
Figuur 1.1: Interactieve video
Bedrijven kunnen hun apps op een intu¨ıtieve manier samenstellen dankzij de Zentrick Studio. Deze webapplicatie maakt het mogelijk om apps boven de video te positioneren en hun gedrag aan te passen via een drag-and-drop-interface. Dankzij een tijdlijn kan het gedrag van de apps aangepast worden aan het tijdstip binnen de video om op die manier bv. bepaalde apps enkel zichtbaar te maken binnen een bepaald tijdsinterval.
1.2 Zentrick
4
Bedrijven kunnen via de Zentrick Studio ook apps op maat bouwen indien meer geavanceerde interactiveit nodig is. Daarbij bevat de Zentrick Studio een online programmeeromgeving die het mogelijk maakt om apps samen te stellen met een combinatie van JavaScript, Scalable Vector Graphics (SVG) en Cascading Style Sheets (CSS). Deze standaarden werden gekozen omdat het webontwikkelaars mogelijk maakt om hun bestaande kennis in te zetten voor het defini¨eren van Zentrick-apps. Net zoals bij standaard webapplicaties wordt de gebruikersinterface gedefinieerd met een combinatie van declaratieve talen voor de structuur (SVG) en de opmaak (CSS). JavaScript doet dan dienst als programmeertaal om het gedrag van de apps te bepalen. Wanneer alle interactiviteit is toegevoegd binnen de Zentrick Studio kan de klant overgaan tot de publicatie van de video op het web. Daarvoor dient hij enkel een iframe-tag te plaatsen (zie codevoorbeeld 1.1) met de juiste attributen binnen de HTML-code van zijn webapplicatie. Zentrick zorgt vervolgens voor een volledige cross-platform-ervaring. Aangezien SVG en het HTML5-video-element slechts deels of helemaal niet ondersteund worden in oudere browsers, wordt daar Adobe Flash gebruikt. Zentrick heeft het daarbij mogelijk gemaakt om de apps geschreven in JavaScript, SVG en CSS uit te voeren binnen de Flash-runtime. < iframe width = ' 560 ' height = ' 315 ' src = ' http: // watch . zentrick . com /.../ ' frameborder = '0 ' allowfullscreen > iframe > Codevoorbeeld 1.1: iframe-tag De videoplayer van Zentrick bestaat dus enerzijds uit een implementatie voor moderne browsers, die gebruikmaakt van de native mogelijkheden van de browser om SVG te renderen en video af te spelen; deze zullen we verder de HTML5-omgeving noemen. Anderzijds wordt er voor oudere browsers gebruikgemaakt van de Flash-plugin; deze noemen we de Flash-omgeving. Het script dat ingeladen wordt in de iframe-tag zal initieel bepalen welke omgeving gebruikt wordt om de interactieve video af te spelen. Op het eerste gezicht lijkt het niet vanzelfsprekend dat de JavaScript-code die het gedrag van de apps bepaald ook kan draaien binnen de Flash-plugin. JavaScript is echter een implementatie van de ECMAScript-standaard, waarbij moderne browsers de vijfde versie (ES5) hiervan ondersteunen. ES5 is backwards compatible met de vorige versie, namelijk ECMAScript 3 (ES3), die in oudere browsers nog gebruikt wordt. Flash-applicaties worden op hun beurt gedefinieerd a.d.h.v. ActionScript 3 (AS3), en deze taal is ook compatibel met de ES3-standaard [52]. AS3 voegt daarnaast ook nog zaken toe zoals klassen en packages; AS3 kan dus beschouwd worden als een superset van ES3. Dit betekent dat we code kunnen schrijven voor zowel AS3 als JavaScript, zolang we ons aan de
1.3 Real-time interactiviteit
5
ES3-standaard houden. Deze eigenschap wordt uitgebuit door Zentrick om hun interactieve apps zowel in de browser als binnen een Flash-plugin te draaien. Opdat de code ook hetzelfde gedrag zou hebben in beide omgevingen, wordt iedere API twee keer ge¨ımplementeerd, ´e´en keer per omgeving. Dit heeft als nadeel dat elke nieuwe feature binnen het Zentrick-platform twee keer moet worden gedefinieerd door de ontwikkelaars van Zentrick; dat is de prijs die Zentrick betaalt om een cross-platform-ervaring te kunnen garanderen. Zo zal men bv. voor alle SVG-elementen een implementatie voorzien in Flash waarmee ieder element ook kan gerenderd worden binnen Flash. Het positieve gevolg is uiteraard dat de klanten van Zentrick slechts ´e´enmaal hun interactieve apps hoeven te defini¨eren. Daarbij hoeven ze zich geen zorgen te maken over cross-platformproblemen. Deze worden namelijk door Zentrick aangepakt en verborgen achter een uniforme API die verder de Zentrick-API wordt genoemd en ook online gedocumenteerd is [51]. Zentrick tracht hierbij ook zoveel mogelijk de standaarden te volgen van het web. Dit heeft als voordeel dat webontwikkelaars al vertrouwd zijn met deze API’s waardoor de leercurve minder steil is. Het uitvoeren van een HTTP-request zal bv. gebeuren a.d.h.v. de XMLHttpRequest API i.p.v. de URLRequest API van Flash. In de Flash-omgeving wordt dan een XMLHttpRequest klasse aangemaakt volgens de interface vastgelegd door het W3C 2 . De implementatie van deze klasse zal de aanvragen dan verder delegeren naar de URLRequest API van Flash.
1.3
Real-time interactiviteit
Zentrick biedt de mogelijkheid aan zijn klanten om live-evenementen te streamen die ook geannoteerd kunnen worden met apps. In tegenstelling tot gewone video’s kan de interactiviteit die inspeelt op de gebeurtenissen van een livestream niet op voorhand vastgelegd worden, aangezien de inhoud van een live-video dan nog niet gekend is. Er moet dus binnen het Zentrick-platform een mogelijkheid ingebouwd worden waarmee nieuwe informatie in real-time naar de videoplayer gepusht kan worden. De combinatie van deze real-time informatie en interactiviteit noemen we real-time interactiviteit. Dankzij deze feature zal een kijker gedurende een livestream kunnen genieten van interactiviteit die inspeelt op de inhoud van de video. De opdracht van deze masterproef bestaat daarbij uit twee grote delen. Ten eerste moet er een software-module worden ontworpen die binnen een webclient draait. Enerzijds moet deze module het mogelijk maken om nieuwe informatie te verpreiden naar alle kijkers tijdens het streamen van een live-evenement. Deze module moet dus kunnen worden ge¨ıntegreerd in een browser-omgeving, namelijk de reeds bestaande Zentrick Studio. Dit is de omgeving vanwaar de klanten van Zentrick de real-time interactiviteit zullen aansturen. 2
Het World Wide Web Consortium is een organisatie die instaat voor het bepalen van verschillende standaarden
binnen het web.
1.4 Overzicht
6
Anderzijds moet deze module ook toelaten om de nieuwe informatie te ontvangen in de Zentrickvideoplayer. Binnen het Zentrick-platform bestaat de mogelijkheid om modules te defini¨eren die dan gebruikt kunnen worden door andere modules of apps; deze zullen we verder Zentrickmodules noemen. Ondanks het feit dat de Zentrick-player binnen een browser draait is de runtime-omgeving niet dezelfde als bij een browser en moet er geprogammeerd worden naar de Zentrick-API. Het moet dus mogelijk zijn om de module te compileren tot zowel een browsermodule als een Zentrick-module. Ten tweede moet er een software-module ontworpen worden aan de server-side die instaat voor de distributie van de nieuwe informatie vanaf ´e´en webclient (die aangestuurd wordt door de klant van Zentrick) naar alle andere andere webclients (de kijkers van de livestream). Deze real-time service moet voldoen aan twee belangrijke niet-functionele vereisten: schaalbaarheid en performantie. Dit wil zeggen dat de service in staat moet zijn om een grote hoeveelheid clients op een snelle manier te voorzien van nieuwe informatie. De service zal dus ook worden onderworpen aan enkele load-testen om na te gaan in welke mate de niet-functionele vereisten worden ingevuld. Deze real-time interactiviteit kan dan handig ingezet worden tijdens bv. een livestream van een voetbalwedstrijd. Wanneer er een gebeurtenis optreedt, zoals bv. het scoren van een doelpunt, kan deze in real-time toegevoegd worden aan een tijdlijnapp die alle interessante gebeurtenissen van de wedstrijd visueel voorstelt. Een kijker kan dan verder klikken op een gebeurtenis om meer informatie te verkrijgen over bv. de betrokken speler. Om de mogelijkheden van de real-time interactiviteit te demonstreren werd samen met Zentrick en het bedrijf DigitasLBi een project uitgewerkt dat ervan gebruikmaakt.
1.4
Overzicht
Het laatste deel van deze inleiding geeft een overzicht van de verschillende hoofdstukken van deze masterproef. De inhoud van ieder hoofdstuk wordt kort besproken en dient als smaakmaker voor de eigenlijke inhoud.
1.4.1
Vereistenanalyse
Deze masterproef is opgebouwd rond vijf onderzoeksvragen die dienen als rode draad bij het oplossen van ons probleem. Vooraleer we de onderzoeksvragen kunnen bepalen, zullen we in hoofdstuk 2 via de vereistenanalyse de belangrijkste functionele en niet-functionele vereisten bepalen van ons probleem. Daarbij worden de functionele vereisten ondergebracht in twee verschillende delen. Ons probleem bestaat in essentie uit het distribueren van nieuwe informatie vanaf ´e´en client
1.4 Overzicht
7
naar een verzameling clients via een tussenliggende service. Deze definitie sluit nauw aan bij het publish/subscribe-pattern en het eerste deel van de functionele vereisten bestaat uit het ontwerp van een event-driven architectuur die hiervoor ondersteuning biedt. Het publish/subscribepattern beschrijft een API waarbij een publisher een message verstuurt naar een verzameling subscribers die zich ingeschreven hebben op een bepaald channel. Het tweede deel bestaat uit het concept van een observable model dat gebruik zal maken van deze API. Via dit observable model kunnen we op een heel gemakkelijke manier veranderingen opvangen van het model dat de toestand van onze applicatie voorstelt. We zullen beide API’s onderbrengen in een aparte module, waarbij we de koppeling tussen de twee modules minimaal houden. Zoals eerder al vermeld, zal deze functionaliteit nog verder opgedeeld worden in een stuk aan de client-side en een stuk aan de server-side. Daarnaast worden in dit hoofdstuk ook de belangrijkste niet-functionele vereisten beschreven, namelijk performantie en schaalbaarheid. Aangezien deze begrippen voortdurend terugkomen gedurende deze masterproef worden ze hier duidelijk op voorhand gedefinieerd.
1.4.2
Onderzoeksvragen
De uitwerking van de functionele vereisten brengt een aantal problemen met zich mee, waarbij ieder probleem wordt samengevat in een onderzoeksvraag. Daarbij kunnen we een onderscheid maken tussen kwantitatieve en kwalitatieve onderzoeksvragen. Een kwantitatieve onderzoeksvraag heeft een antwoord dat duidelijk meetbaar is en waarbij we empirisch te werk kunnen gaan. Daarbij stellen we eerst een hypothese op waarvan we vermoeden dat ze waar is om deze vervolgens te verifi¨eren of te falsifi¨eren. Het antwoord op een kwalitatieve onderzoeksvraag is daarentegen niet meetbaar. Om die reden zullen we de antwoorden op de kwalitatieve vragen onderbouwen met krachtige argumenten. In deze masterproef is de eerste onderzoeksvraag een combinatie van beide, de overige vier zijn enkel kwalitatief. In de hoofdstukken 3 t.e.m. 7 wordt een antwoord gezocht op de verschillende onderzoeksvragen, waarbij ´e´en hoofdstuk telkens overeenkomt met ´e´en onderzoeksvraag. Ieder hoofdstuk bevat het antwoord op de overeenkomstige onderzoeksvraag, waarbij het kwantitatieve deel van de eerste vraag pas op het einde in hoofdstuk 9 wordt behandeld. Architectuur Uit welke mogelijke componenten bestaat de real-time architectuur en welke scoort het best op het gebied van schaalbaarheid, performantie en robuustheid? Tijdens de uitwerking van deze masterproef gaan we top-down te werk. In hoofdstuk 3 bepalen we dus eerst de grootschalige componenten van de architectuur. Het is belangrijk dat we hier-
1.4 Overzicht
8
mee starten, aangezien bepaalde architecturale beslissingen een invloed hebben op het ontwerp en de implementatie van de eerder besproken modules. In hoofdstuk 9 wordt dan experimenteel bepaald hoe goed de ontworpen architectuur scoort op het gebied van schaalbaarheid en performantie. Real-time web Wat zijn de technische mogelijkheden om een event van de server naar de clients te pushen binnen een webomgeving? Het web is gebouwd rond het HTTP-protocol, dat bestaat uit een HTTP-request van de client gevolgd door een HTTP-response van de server. In dit server-pull -model start de communicatie altijd op het initiatief van de client en nooit op het initiatief van de server.
Ons
publish/subscribe-systeem moet echter over een mogelijkheid beschikken om op initiatief van de server events naar de clients te transporteren, dit wordt dan server-push genoemd. Hoofdstuk 4 geeft een overzicht van de verschillende mogelijkheden om aan server-push te doen, gebruikmakend van HTTP-long-polling of HTTP-streaming. Verder wordt ook het WebSocketprotocol besproken, dat deel uitmaakt van de HTML5-specificatie. WebSocket biedt bidirectionele communicatie aan voor het web en is daarbij de ideale oplossing voor ons probleem. Het protocol is echter enkel beschikbaar binnen de laatste nieuwe browsers en is in de aanwezigheid van proxy’s niet altijd bruikbaar. Schaalbare webserver architecturen Wat is de meest geschikte webserver-architectuur om tweewegscommunicatie te realiseren voor een groot aantal clients? Aangezien de verzameling subscribers heel groot kan worden, hebben we een webserver nodig die in staat is om een groot aantal connecties te onderhouden. In hoofdstuk 5 zien we dat een klassieke webserver iedere connectie zal mappen op een apart proces of thread. Daarbij zullen we concluderen dat dit niet zo schaalbaar is vanwege de overhead van threads en een foutgevoelig programmeermodel. We zullen zien dat een event-driven webserver een schaalbaar alternatief vormt waarbij Node.js gekozen wordt als implementatie van dit model. Professioneel JavaScript In welke mate kan JavaScript geprofessionaliseerd worden om grote projecten op een kwaliteitsvolle manier aan te pakken? De klassieke webomgevingen bestaan enerzijds uit een webserver zoals Apache Web Server of IIS en anderzijds uit een platform om de webtoepassingen op te bouwen zoals PHP of Java
1.4 Overzicht
9
EE. Deze laatste maken daarbij gebruik van de diensten van de webserver. Node.js is echter zowel een webserver als een platform om netwerktoepassingen te bouwen a.d.h.v. JavaScript. Aangezien JavaScript oorspronkelijk niet gebouwd is om grootschalige applicaties te bouwen, mist het enkele concepten die nodig zijn om zulke projecten onderhoudbaar te maken. Voor ieder van deze problemen bestaan oplossingen en deze worden allen overlopen. Bepaalde concepten zijn tevens ook bruikbaar voor client-side JavaScript. Ten eerste worden de verschillende vormen van modulariteit besproken waarbij de AMD- en de CommonJS-standaard bekeken worden. Daarna bekijken we de verschillende programmeermodellen die mogelijk zijn binnen JavaScript. Zo ondersteunt de taal zowel de mogelijkheid tot objectgeori¨enteerd programmeren met overerving a.d.h.v. prototypes en functioneel programmeren dankzij de aanwezigheid van first-class-functies. We vervolgen met kwaliteitscontrole, waarbij linting en testing de belangrijkste onderdelen vormen. Dit hoofdstuk wordt afgesloten met een beschrijving van Grunt, een tool voor build-automatisering. Asynchroon programmeren Wat is de krachtigste abstractie om asynchroon te programmeren binnen JavaScript? Node.js maakt gebruik van een event-driven concurrency model, dat in essentie uit een nonblocking event-loop bestaat, waarbij iedere vorm van I/O asynchroon behandeld wordt. Dankzij de first-class-functies binnen JavaScript is het heel gemakkelijk om asynchrone API’s te defini¨eren a.d.h.v. callbacks. Deze continuation passing style (CPS) heeft als nadeel dat vele sequenti¨ele operaties aanleiding geven tot een groot aantal geneste callback-functies die de code onleesbaar maken. Daarnaast verliezen we met CPS ook de kracht van elementaire constructies zoals bv. het trycatch-statement voor error-handling, het while-statement voor loops, enz. Hiervoor zijn oplossingen bedacht, maar in het algemeen zijn deze zeer foutgevoelig en/of onleesbaar. Naast CPS zijn er ook nog andere mogelijkheden tot asynchroon programmeren, zoals het gebruik van events, maar deze hebben ook hun beperkingen. Hoofdstuk 7 toont aan dat asynchrone code leesbaar en krachtig kan zijn door gebruik te maken van de juiste abstracties. We zullen zien dat het concept van een promise in combinatie met generator -functies aanleiding geeft tot een heel krachtig hulpmiddel om asynchroon te programmeren.
1.4.3
Implementatie
Nadat we iedere onderzoeksvraag kwalitatief behandeld hebben, wordt in hoofdstuk 8 de implementatie besproken van de verschillende modules. Dit bevat de publish/subscribe-module en
1.4 Overzicht
10
de module die ondersteuning biedt voor het observable model, en dit zowel aan de client- als server-side. Daarnaast wordt in dit hoofdstuk ook het project besproken waarin de real-time interactiviteit werd toegepast. Dit project kwam tot stand in samenwerking met de ontwikkelaars van Zentrick en het bedrijf DigitasLBi.
1.4.4
Performantie en schaalbaarheid
De eerste onderzoeksvraag bevat ook een kwantitatief deel dat in dit hoofdstuk wordt besproken. Daarbij wordt in hoofdstuk 9 een mogelijke variant op de architectuur getest door het gedrag van het content delivery network van Amazon af te toetsen. Daarnaast wordt er ook aandacht besteed aan het opzetten van een testomgeving waarmee de performantie en de schaalbaarheid van de architectuur worden nagegaan.
1.4.5
Besluit
In hoofdstuk 10 ronden we deze masterproef af met een overzicht van de antwoorden op de verschillende kwalitatieve onderzoeksvragen. Daarnaast maken we ook een algemeen besluit wat betreft de performantie en schaalbaarheid van het ge¨ımplementeerde systeem. Tot slot willen we in deze inleiding ook nog meegeven dat alle codevoorbeelden in deze masterproef in JavaScript zullen gegeven worden. De keuze voor JavaScript volgt uit het feit dat dit deze taal de standaard is voor client-side development binnen het web en bijgevolg iedere webontwikkelaar ermee vertrouwd is. JavaScript wordt ook gebruikt aan de server-side van onze implementatie dankzij Node.js, wat onze keuze enkel maar bevestigt.
VEREISTENANALYSE
11
Hoofdstuk 2
Vereistenanalyse In dit hoofdstuk wordt het probleem geanalyseerd en worden de belangrijkste functionele en niet-functionele vereisten toegelicht.
2.1
Publish/subscribe
Het probleem bestaat in essentie uit het distribueren van informatie in real-time vanaf ´e´en webclient via een tussenliggende service naar een verzameling webclients die potentieel heel groot kan zijn. Een goede kandidaat om deze functionaliteit te implementeren is het publish/subscribepattern [62]. Dit pattern is een manier om informatie in de vorm van messages te verspreiden vanaf een publisher naar een verzameling subscribers. Het begrip ‘real-time’ geeft aan dat de distributie van deze events zo snel mogelijk moet gebeuren; het systeem moet dus performant zijn. Ten slotte kan de verzameling van subscribers heel groot zijn; het systeem moet dus ook schaalbaar zijn. Het publish/subscribe-pattern vertoont duidelijke overeenkomsten met het observer-pattern dat we kennen van de Gang of Four [64]. In de literatuur worden ze soms ook gelijkgesteld aan elkaar, maar in deze context willen we toch een onderscheid maken tussen beide. Bij het observerpattern schrijven (zie codevoorbeeld 2.1) ´e´en of meerdere observers zich in bij het object dat events genereert, het subject genaamd. Dit vereist dat het subject toegankelijk moet zijn voor de observers. Bij het publish/subscribe-pattern wordt de rol van de observers overgenomen door de subscribers en het subject wordt vervangen door de publisher. De publisher wordt daarbij volledig ontkoppeld van de subscribers en een tussenliggende eventservice zorgt voor de distributie van de messages van de publisher naar de subscribers. 1 // Observer - pattern . 2 3 // Een observer schrijft zich in bij het subject .
2.1 Publish/subscribe
12
4
subject . on ( ' event ' , function observer ( event ){
5
console . log ( ' Nieuw event : ' + event );
6 }); 7 8 // Het subject triggert een event . 9
subject . emit ( ' event ' , ' Hello world . ' ); Codevoorbeeld 2.1: Observer-pattern Zoals [62] aangeeft, zijn er verschillende variaties van het publish/subscribe-pattern. Een subscriber heeft meestal geen interesse in alle events en zal daarom enkel interesse tonen in een bepaalde deelverzameling van alle events. De verschillende variaties onderscheiden zich door de manier waarop zij deze deelverzameling bepalen. Zo bestaan er channel-based (of topic-based), content-based en type-based systemen. Het meest eenvoudige systeem is channel-based en deelt de eventspace op in verschillende channels die voorgesteld worden door een string. Aangezien een channel-based publish/subscribe-systeem volstaat voor ons probleem zullen we dit dan ook verder gebruiken. Het gebruik van strings heeft ook het voordeel van interoperabiliteit aangezien elk computersysteem met dit concept vertrouwd is.
1 // Publish / subscribe - pattern . 2 3 // Een client schrijft zich in bij een bepaald channel en neemt 4 // daarbij de rol op van subscriber . 5 client . subscribe ( ' channel ' ); 6 7 // De client wordt op de hoogte gebracht van nieuwe events . 8 client . on ( ' message ', function ( channel , message ){ console . log ( ' Nieuwe boodschap ' + message +
9
' op kanaal ' + channel );
10 11 }); 12
13 // Een client publiceert een nieuwe boodschap op een aangegeven 14 // channel en neemt daarbij de rol op van publisher . 15 client . publish ( ' channel ' , ' Hello world . ' ); Codevoorbeeld 2.2: Publish/subscribe-pattern Publish/subscribe biedt ons dus publishers en subscribers die ontkoppeld worden van elkaar door een tussenliggende eventservice. Een subscriber kan zich vervolgens inschrijven bij ´e´en of meer-
2.1 Publish/subscribe
13
dere channels waarbij een publisher kan adverteren naar verschillende groepen van subscribers, wat in het klassieke observer-pattern niet mogelijk is. Door te werken met channels kunnen we binnen het Zentrick-platform eenvoudig het bereik bepalen van de events die verstuurd worden. Zo kan een channel overeenkomen met ´e´en enkele app, met alle apps van een experience, of met een willekeurige andere verzameling van apps. Het publish/subscribe-pattern is dus heel soepel en het laat ten slotte ook nog toe dat een client tegelijkertijd publisher en subscriber is. Codevoorbeeld 2.2 geeft een voorbeeld van het publish/subscribe-pattern, waarbij het verschil duidelijk merkbaar is in vergelijking met het observer-pattern uit codevoorbeeld 2.1. Het publish/subscribe-pattern kan lokaal ge¨ımplementeerd worden, maar voor onze use case hebben we nood aan een gedistribueerde versie. De publisher en de verschillende subscribers vormen de clients, waarbij een event van de publisher via een centrale eventservice tot bij alle subscribers terecht moet komen. Om dit mogelijk te maken, zal er een event-driven architectuur uitgewerkt worden waarbij de eventservice een webservice is die de messages voor een bepaald channel tot bij de ge¨ınteresseerde subscribers brengt. Bij het ontwerpen van de architectuur moet rekening worden gehouden met de eerder genoemde vereisten op het gebied van performantie en schaalbaarheid. Aangezien er nogal wat verwarring bestaat tussen deze twee begrippen en ze in de literatuur ook verschillend gebruikt worden, zullen we ze in het kader van deze masterproef eerst duidelijk defini¨eren.
2.1.1
Performantie
De performantie van een systeem bestaat uit enkele verschillende meetbare componenten. Ten eerste hebben we de doorvoercapaciteit van ons systeem: deze drukken we uit in het aantal aanvragen per seconde dat het systeem kan verwerken. Een andere belangrijke metriek is de latency van een aanvraag, die uitgedrukt wordt in seconden. Daarbij is de latency het tijdsinterval tussen de start van een aanvraag en het ontvangen van een antwoord gemeten aan de client-side. Deze performantiekenmerken hebben allemaal als eigenschap dat ze duidelijk meetbaar zijn. Dit zal ons in staat stellen om bij de evaluatie van onze architectuur een duidelijk oordeel te vellen wat betreft de performantiekarakteristiek. Hetzelfde geldt voor de schaalbaarheid die hierna beschreven wordt.
2.1.2
Schaalbaarheid
De schaalbaarheid van een systeem wordt binnen deze masterproef gedefinieerd als het verband tussen de aanwezige resources van een systeem en de overeenkomstige capaciteit. De capaciteit zal in deze masterproef bepaald worden door het aantal simultane clients dat verbonden is met
2.1 Publish/subscribe
14
het systeem. De resources kunnen uitgedrukt worden in gelijk welke eenheid afhankelijk van de use case. In ons geval zullen we de resources voornamelijk uitdrukken in het aantal nodes waarbij een node een eenheid vormt met een welbepaalde rekenkracht. Een node is in deze context veelal een webserver, maar kan evengoed een thread, een proces of nog iets anders zijn. Wanneer we merken dat de load op ons systeem toeneemt, zullen we extra resources toevoegen. Met deze verhoging in resources hopen we dan een verhoging in capaciteit te krijgen. Wanneer deze verhouding zich uitdrukt in een lineair verband, spreken we over een systeem dat lineair schaalt en dit is meestal het ideale scenario1 . Indien er bij een toename van resources geen enkele capaciteitsverhoging optreedt, noemen we een systeem in het geheel niet schaalbaar. Het slechtst mogelijke scenario is negatieve schaalbaarheid, waarbij een afname aan capaciteit optreedt bij een toename aan resources. Een scenario van negatieve schaalbaarheid is bv. mogelijk bij het oplossen van een parallel probleem waarbij vanaf een bepaalde hoeveelheid nodes de overhead door de communicatie tussen de verschillende nodes groter wordt dan de totale snelheidswinst die individueel per node geboekt wordt. Het verhogen van de capaciteit kan op twee manieren gebeuren. Enerzijds kan de rekenkracht van een node vergroot worden (door bv. de hardware te upgraden); dit noemen we verticaal schalen of opschalen. Anderzijds kunnen de nodes in aantal toenemen; dit noemen we horizontaal schalen of uitschalen. Verticale schaalbaarheid heeft als voordeel dat er geen enkele wijziging nodig is aan bestaande applicaties. Een upgrade van de hardware veroorzaakt dan ook een directe toename aan capaciteit. Het heeft echter als nadeel dat het begrensd is: een systeem dat beschikt over de beste hardware kan daarna geen toename aan capaciteit meer leveren. De wet van Moore [68] stelt ook dat het aantal transistoren in een processor elke twee jaar verdubbelt, wat een theoretische bovengrens oplevert voor de toename van de capaciteit van ons systeem. Daarbij moet ook opgemerkt worden dat er omwille van fysische beperkingen een einde is gekomen aan frequency scaling [55], waarbij men de frequentie van een processor opdrijft om zijn snelheid te vergroten. Dit heeft als gevolg dat de extra transistoren niet langer dienen om de kloksnelheid op te drijven maar om meerdere cores per processor te voorzien. De evoluties op het gebied van hardware wijzen dus duidelijk in de richting van horizontale schaalbaarheid. Tot slot merken we ook nog op dat de kost om verticaal uit te schalen meestal ook niet lineair is in verhouding met de toename aan capaciteit. Horizontale schaalbaarheid is in theorie onbegrensd en de kost om uit te schalen is lineair in verhouding met de toename aan resources. In de praktijk wordt dit laatste model dan ook heel 1
Er bestaan ook systemen die superlineair schalen door bv. de aanwezigheid van caches. Extra nodes zorgen
ervoor dat er meer data in de caches terechtkomt die dan veel sneller kan opgevraagd worden.
2.1 Publish/subscribe
15
vaak toegepast: bedrijven zoals bv. Google beschikken over clusters met miljoenen nodes die gebruikmaken van standaard hardware. Het nadeel is echter dat een applicatie niet zomaar uitgeschaald kan worden. Horizontale schaalbaarheid vereist namelijk dat het werk verdeeld kan worden over meerdere nodes; het probleem moet dus parallelliseerbaar zijn.
2.1.3
Parallellisme
Parallellisme is de mogelijkheid om meerdere taken simultaan uit te voeren. Een probleem kan in het geheel geen parallellisatie toelaten of kan bestaan uit bepaalde delen die parallelliseerbaar zijn en andere delen die enkel sequentieel kunnen uitgevoerd worden. We kunnen ons dan de vraag stellen hoe groot de snelheidswinst is voor een probleem bij een toename met een gegeven aantal nodes. We stellen het deel van het probleem dat niet geparallelliseerd kan worden daarbij voor door α met α ∈ [0, 1]. Het aantal nodes stellen we voor door n met n ∈ N. De wet van Amdahl [54] zegt dan dat de snelheidswinst S voor het probleem met n nodes gelijk is aan: S(n) =
1 α + 1−α n
Via deze wet komen we tot de vaststelling dat er ook een theoretisch maximale snelheidswinst Smax bestaat die we vinden door de limiet te nemen voor n → ∞: Smax (n) = lim
n→∞
1 1 1−α = α α+ n
Dit is een enigszins pessimistisch resultaat aangezien we voor bepaalde problemen duidelijk op een grens botsen waarbij het toevoegen van extra nodes geen bijkomende snelheidswinst meer oplevert. Gelukkig is α in veel problemen een heel kleine waarde en hoeft dit dus geen limiterende factor te zijn. Vele webapplicaties blijken in de praktijk ook een heel kleine α te hebben, dit worden ook wel embarrassingly parallel applications genoemd. Daarnaast zijn er ook verschillende vormen van parallellisme mogelijk zoals data-parallellism en task-parallellism. Data-parallellism is het uitvoeren van dezelfde taak op verschillende stukken data. Task-parallellism is daarentegen het uitvoeren van verschillende taken op dezelfde of verschillende stukken data. In hoofdstuk 7 over asynchroon programmeren zullen we nog handige technieken zien om aan zowel data- als task-parallellism te doen. Bij het ontwerpen van de event-driven architectuur zullen we proberen te streven naar een horizontaal schaalbare architectuur. Webapplicaties zijn over het algemeen gemakkelijk om te parallelliseren aangezien hun α meestal klein is. De verschillende binnenkomende requests kunnen dan ook gemakkelijk geparallelliseerd worden a.d.h.v. combinatie van data-parallellism en task-parallellism.
2.2 Observable model
2.2
16
Observable model
Het publish/subscribe-pattern biedt de subscribers het concept van een event, waarbij dit event telkens een message bevat. Wanneer we echter real-time apps willen bouwen, zullen deze events telkens de state veranderen van onze applicatie. Aangezien clients op een willekeurig moment tijdens een evenement kunnen afstemmen op de livestream, leidt dit tot twee problemen. Ten eerste moeten alle clients zich in dezelfde state bevinden; er moet dus ook rekening gehouden worden met events die opgetreden zijn vooraleer de kijker naar de video begon te kijken. Het moet dus mogelijk zijn om alle reeds gegenereerde events op te vragen van een video. Dit leidt dan tot het tweede probleem, waarbij eerder opgetreden events anders afgehandeld kunnen worden dan events die live optreden. We verduidelijken dit even met een voorbeeld: stel dat we een live sportwedstrijd streamen en we daarbij twee apps gedefinieerd hebben. De eerste app laat een animatie over het scherm rollen bij het scoren van een doelpunt en de tweede app toont in de hoek van de video de huidige stand. Als er dan een event optreedt met de boodschap dat er een doelpunt is gescoord, is de animatie enkel zinvol wanneer dit event live optreedt. Wanneer de kijker later inschakelt, heeft het geen zin meer om deze animatie te tonen; de app die de huidige stand weergeeft moet echter wel aangepast worden. We zouden de API van het publish/subscribe-pattern kunnen aanvullen met de mogelijkheid om eerder gegenereerde events op te vragen. Dit leidt echter tot twee problemen die we doorschuiven naar de gebruikers van onze API. Ten eerste staan zij dan zelf in voor hun applicatie in de juiste state te brengen a.d.h.v. de reeds opgetreden events. Ten tweede moeten zij dan zelf ook instaan voor het maken van het onderscheid bij het afhandelen van eerder opgetreden events en liveevents. Dit zijn twee problemen die we door de juiste abstractie zelf kunnen oplossen. Wanneer een nieuwe client zich aanmeldt voor het bekijken van een video, kan die eerst de huidige state van de applicatie opvragen i.p.v. de reeds opgetreden events die tot deze state leiden. Deze state kunnen we bundelen in een datastructuur die we het model noemen. Een nieuwe client vraagt dan de huidige snapshot van het model op, waarmee hij zijn view initialiseert. De events van ons publish/subscribe-model vertalen zich dan in wijzigingen van bepaalde delen van ons model. Om deze wijzigingen op te vangen, moet het mogelijk zijn om event-handlers aan de verschillende delen van het model te hangen. De combinatie van een model en de mogelijkheid tot eventhandling via het observer-pattern noemen we een observable model. De module van de publisher moet dan ook in staat zijn om veranderingen op het model te detecteren en deze te publiceren naar de subscribers. Telkens wanneer het model wijzigt, wordt er ook een snapshot van het model aangemaakt die door de nieuwe clients zal opgevraagd worden.
2.2 Observable model
17
De API voor het observable model zullen we onderbrengen in een aparte module, die dan gebruikmaakt van de publish/subscribe-module. Door de interfaces van deze modules duidelijk te defini¨eren, kunnen we later eventueel de publish/subscribe-module vervangen door een equivalent dat dezelfde interface aanbiedt, zonder gevolgen voor andere modules. Met het publish/subscribe-pattern en het observable model hebben we de twee grote functionaliteiten beschreven die ons systeem moet aanbieden. Daarnaast hebben we met performantie en schaalbaarheid ook al twee belangrijke niet-functionele vereisten aangestipt. Het volgende hoofdstuk zal starten met het ontwerp van de event-driven architectuur die als basis dient voor ons systeem.
ARCHITECTUUR
18
Hoofdstuk 3
Architectuur We moeten een event-driven architectuur ontwerpen die volgens het publish/subscribe-pattern in staat is om events te distribueren op een performante en schaalbare manier. De clients die subscriber en/of publisher zijn, zullen daarvoor een verbinding leggen met een eventservice. Daarnaast moeten zij ook een snapshot van het huidige model kunnen opvragen. Iedere component van de architectuur zal bepaald worden, waarbij deze eerst zo generiek mogelijk omschreven wordt. Het doel is om ons daarbij niet vast te pinnen op een bepaalde implementatie. Daarna zullen we iedere component mappen op de specifieke cloudinfrastructuur die Zentrick gebruikt voor het aanbieden van zijn diensten, namelijk Amazon Web Services (AWS) [7].
3.1
Cloud computing
Aangezien we een architectuur willen uitbouwen die horizontaal schaalbaar is en daarbij gemakkelijk kan groeien en weer krimpen, ligt het gebruik van cloud computing voor de hand. Cloud computing biedt ons namelijk de volgende voordelen: Cloud computing stelt ons in staat om onze applicatie automatisch horizontaal te schalen.
Het laat ons toe om de resources van ons systeem automatisch aan te passen aan de load, waardoor er nooit een over- of ondercapaciteit ontstaat. Deze eigenschap noemen we de elasticiteit van cloud computing. Elasticiteit biedt het bedrijfseconomische voordeel dat we enkel hoeven te betalen voor
wat we effectief gebruiken. Cloud computing laat ons toe om te focussen op de componenten van onze architectuur,
waarbij we onszelf niet verliezen in het onderhouden van deze componenten. Zentrick maakt gebruik van AWS als cloudplatform en het is dan ook logisch dat we deze dienst kiezen voor de uitwerking van onze event-driven architectuur. AWS biedt ondersteuning voor
3.2 Architecturale componenten
19
verschillende cloud-modellen waaronder Infrastructure as a Service (IaaS) en Platform as a Service (PaaS). Het IaaS-model biedt virtuele instanties aan met een besturingssysteem naar keuze. Dit heeft als nadeel dat alle software zelf moet worden geconfigureerd. Het voordeel is dat er dan ook meer controle is over de configuratie. PaaS daarentegen zorgt voor een volledige host-environment waarbinnen een applicatie kan draaien. Het voordeel is dat er meer gefocust kan worden op het ontwikkelen van de applicatie en de verschillende architecturale componenten. Het nadeel is de beperkte vrijheid in vergelijking met IaaS waar er volledige controle is over de configuratie. AWS biedt het IaaS-model aan onder de naam Elastic Compute Cloud (EC2) [4]. Het biedt daarbij de keuze tussen verschillende types instanties die verschillen in o.a. geheugen, processorkracht en netwerksnelheid. Daarnaast heeft AWS ook een PaaS-model met Elastic Beanstalk (EB) [3] waarbij er de keuze is tussen verschillende applicatiecontainers voor de meest voorkomende programmeerplatformen. Aangezien we ons in deze masterproef vooral focussen op de architecturale concepten en het ontwerp en de implementatie van de gevraagde functionaliteit zullen we gebruikmaken van EB. De huidige services van Zentrick draaien ook op EB, wat de drempel om EB te gebruiken verder verlaagt.
3.2
Architecturale componenten
Iedere component van onze architectuur (zie figuur 3.1) moet aan een aantal voorwaarden voldoen. Ten eerste moet elke component horizontaal schaalbaar zijn, zodat we een toenemend aantal clients probleemloos kunnen opvangen. Ten tweede moet iedere component bestand zijn tegen een zekere vorm van falen. Wanneer een node crasht, moeten de andere nodes het werk vlot kunnen overnemen. Dit proces wordt failover genoemd en dit draagt bij aan de high availability van onze architectuur.
3.2.1
Applicatieservers
Een nieuwe subscriber meldt zich aan bij de eventservice om aan te geven dat hij ge¨ınteresseerd is in de events van een bepaald channel. Daarvoor zal hij een verbinding leggen met een server waarop onze applicatie draait. Deze bidirectionele verbinding zal het mogelijk maken om events van de applicatieserver naar de client te pushen. Hiervoor zal gebruikgemaakt worden van het WebSocket of HTTP-long-polling, waarbij de keuze bepaald wordt door zowel de browser als de huidige netwerktopologie. Deze technieken om bidirectionele communicatie binnen het web toe te laten worden uitgebreid besproken in het volgende hoofdstuk. Wanneer ´e´en server niet meer in staat is om de nodige capaciteit te leveren, hebben we nood aan meerdere servers om de load te dragen. We zullen dus een cluster van applicatieservers
3.2 Architecturale componenten
20
opnemen in onze architectuur die automatisch kan groeien en krimpen afhankelijk van de load op ons systeem. Een subscriber zal dan een verbinding hebben met ´e´en van de applicatieservers uit onze cluster.
Figuur 3.1: Architectuur
AWS biedt de mogelijkheid om een cluster van servers aan te maken via de Auto Scaling service [1]. Deze service laat toe om een groep te defini¨eren met EC2-instanties die volgens bepaalde thresholds zal groeien of krimpen. Dankzij deze eigenschap is de eerste voorwaarde i.v.m. ho-
3.2 Architecturale componenten
21
rizontale schaalbaarheid vervuld voor deze component. De Auto Scaling service detecteert ook gecrashte nodes door op regelmatige tijdstippen een health check uit te voeren op iedere node en deze te vervangen door een nieuwe EC2-instantie indien nodig. Via deze health checks is de tweede voorwaarde i.v.m. failover ook voldaan. De introductie van een cluster met applicatieservers heeft ook enkele gevolgen. De verbindingen van meerdere clients moeten namelijk vanaf ´e´en toegangspunt verdeeld worden over de applicatieservers. Hiervoor zullen we gebruikmaken van een load balancer.
3.2.2
Load balancer
De load balancer zorgt er voor dat meerdere inkomende verbindingen verspreid worden over een cluster van servers. We overlopen eerst enkele technieken volgens [72] die gebruikt worden bij load balancing om op die manier een beter inzicht te krijgen in het concept. Round-robin DNS Round-robin DNS vergt geen speciale hardware of software en maakt gebruik van het DNSprotocol om een elementaire vorm van load balancing aan te bieden. DNS laat immers toe om meerdere IP-adressen aan ´e´en domeinnaam te koppelen. Bij iedere DNS-request zal de DNSserver de verschillende IP-adressen teruggeven in een roterende volgorde. Dit heeft als gevolg dat iedere client een verbinding zal leggen met een willekeurige server uit de volledige verzameling van servers. Het voordeel van deze methode is dat deze opstelling heel gemakkelijk te configureren is. Het nadeel is dat er geen vorm van failover is. Wanneer een server gecrasht is, zullen alle verbindingen met deze server falen. Er moet dus nog een extra oplossing bedacht worden om de IP-adressen die naar een niet-functionerende server leiden, toe te wijzen aan een andere server die wel beschikbaar is. Hardware load balancing Indien we geen gebruikmaken van DNS om de gebruikers te spreiden over de cluster van servers hebben we nood aan een fysieke load balancer. Deze kan enerzijds bestaan uit speciale hardware ontworpen voor load balancing of anderzijds uit speciale software die draait op een aparte server. De verbindingen worden dan via deze load balancer gemapt op een server uit de cluster. Hardware load balancing werkt in het algemeen op de netwerklaag volgens het OSI-model. Deze load balancers worden daarom ook wel layer 3 load balancers genoemd. De technieken die gebruikt worden voor hardware load balancing zijn o.a. direct routing en network address translation (NAT).
3.2 Architecturale componenten
22
Direct routing verandert niets aan de IP-structuur van de pakketten en hetzelfde IP-adres zal toegekend worden aan de verschillende servers uit de cluster. De load balancer zal dan de pakketten naar ´e´en van de servers uit de cluster routeren. Het voordeel is dat deze server zijn antwoord direct naar de client kan sturen zonder weer langs de load balancer te gaan. Het nadeel is dat een grondige kennis van het TCP/IP-model nodig is om de hardware en de servers juist te configureren. NAT zal het virtueel IP-adres waarmee de client verbinding maakt, vertalen naar een IP-adres van ´e´en van de servers uit de cluster en het pakket dan verder routeren. Een pakket dat wordt teruggestuurd van de server naar de client gaat weer langs de load balancer die het oorspronkelijke IP-adres terug zal plaatsen. Deze methode is enerzijds gemakkelijker om op te zetten dan direct routing, maar vergt anderzijds wel meer van de load balancer, die nu een volledige session table moet bijhouden met een entry voor iedere verbonden client. Ook komt het IP-adres van de server niet meer overeen met het publiek IP-adres waarmee de client zich verbindt. Software load balancing Software load balancers gedragen zich in het algemeen zoals een reverse proxy, waarbij zij meestal opereren op de transportlaag of hoger. Aangezien zij op een hogere laag inwerken, vergen zij meer rekenkracht dan de load balancers op hardware-niveau. Maar dankzij hun kennis van de hogere protocollagen bieden ze ook meer mogelijkheden zoals SSL-offloading, URL-filtering en persistentie via cookies. Een layer 4 load balancer die opereert volgens het TCP-protocol kan de mogelijkheid aanbieden om SSL-verkeer te decrypteren. Dit wordt SSL-offloading genoemd en dit zorgt ervoor dat de backend servers meer rekenkracht overhouden voor andere zaken. Dit maakt het verder ook mogelijk om protocollen boven TCP zoals HTTP te interpreteren en features aan te bieden waaronder URL-filtering en persistentie. URL-filtering zorgt ervoor dat requests enkel worden doorgelaten indien ze gericht zijn
naar een geldige URL. Persistentie is een techniek die ervoor zorgt dat het verkeer van een client altijd naar
dezelfde server gaat. Een layer 7 load balancer die HTTP spreekt kan bv. gebruikmaken van cookies om de HTTP-requests van een client steeds naar dezelfde server te sturen. Persistentie kan nodig zijn indien er per client een bepaalde context op de server wordt bijgehouden. Zo’n stateful design kan echter meestal vervangen worden door een stateless design door de context op de client en in de databank bij te houden en vervolgens alle benodigde informatie te bundelen in iedere request.
3.2 Architecturale componenten
23
Amazon Elastic Load Balancing Onze implementatie zal gebruikmaken van de load balancing service die Amazon aanbiedt, namelijk Elastic Load Balancing [5] (ELB). ELB biedt een service aan die geconfigureerd kan worden als een layer 4 load balancer die TCP spreekt of een layer 7 load balancer die HTTP spreekt. Daarbij is het ook mogelijk om aan SSL-offloading te doen door een SSL-certificaat en bijhorende private sleutel op de load balancer te configureren. Verder kan ook persistentie toegevoegd worden a.d.h.v. cookies indien de load balancer gebruikmaakt van HTTP. Zoals eerder al vermeld, zullen de clients ´e´en van de twee mogelijke protocollen gebruiken om zich te verbinden met de eventservice, namelijk WebSocket en HTTP. WebSocket is net zoals HTTP een protocol op de applicatielaag maar wordt niet ondersteund door de layer 7 load balancer van ELB. Daarom zullen we de ELB configureren als een layer 4 load balancer, wat als gevolg heeft dat we geen gebruik kunnen maken van persistentie binnen onze architectuur (aangezien die binnen de ELB-service enkel beschikbaar is via HTTP-cookies). De ELB kan geconfigureerd worden zodat de binnenkomende requests doorgestuurd worden naar een server uit een Auto Scaling groep. Dit geeft ons de gecombineerde functionaliteit van een load balancer en elasticiteit waarbij de load van de clients verspreid wordt over de verschillende applicatieservers. De verzameling applicatieservers zal daarbij automatisch groeien en krimpen afhankelijk van de load. Tot slot merken we nog op dat de ELB voor de gebruiker een zwarte doos is waarvan de implementatie waarschijnlijk een combinatie is van de hiervoor besproken technieken. Amazon zorgt daarbij voor horizontale schaalbaarheid en failover. Dit bewijst nogmaals het grote voordeel van cloud computing aangezien horizontale schaalbaarheid en failover zeker niet triviaal zijn om te implementeren in het geval van een load balancer.
3.2.3
Eventbus
Tot nu toe laat onze architectuur toe dat een client een verbinding maakt met ´e´en van de applicatieservers via de load balancer. Wanneer een publisher een event wil verspreiden naar alle ge¨ınteresseerde subscribers, zal hij een bericht versturen naar een van de servers. Aangezien een ge¨ınteresseerde subscriber ook met een andere applicatieserver kan verbonden zijn moet er een manier zijn om berichten uit te wisselen tussen de verschillende servers uit de cluster. Om dit probleem op te lossen, zullen we een bus opnemen in onze architectuur waarop iedere applicatieserver berichten kan plaatsen. Aangezien deze bus verantwoordelijk is voor de uitwisseling van events, wordt deze verder de eventbus genoemd. Wanneer een subscriber zich inschrijft voor een bepaald channel, zal de applicatieserver zich op zijn beurt inschrijven voor dit channel
3.2 Architecturale componenten
24
bij de eventbus indien dat nog niet het geval is. De applicatieserver kan al ingeschreven zijn voor een bepaald channel indien een andere subscriber al interesse toonde voor dit channel. De applicatieserver zal zich weer uitschrijven voor een channel wanneer geen enkele van zijn clients meer ge¨ınteresseerd is in dit channel. Dit wil zeggen dat onze eventbus ondersteuning moet bieden voor het publish/subscribe-pattern waarbij de verzameling publishers en subscribers nu gevormd worden door de applicatieservers. De load op de eventbus hangt daarbij af van het aantal applicatieservers en niet van het aantal webclients, aangezien een gemeenschappelijke interesse in een channel gebundeld wordt tot ´e´en subscription per applicatieserver. Dit reduceert de load op de eventbus aanzienlijk wanneer veel clients zich voor hetzelfde channel hebben ingeschreven. De live-events die gebruik zullen maken van onze architectuur voldoen aan deze laatste eigenschap. Redis We hebben nood aan een implementatie van een eventbus die ondersteuning biedt voor het publish/subscribe-pattern. Redis [37] is een NoSQL-databank die ook een publish/subscribeinterface aanbiedt en is binnen AWS beschikbaar via de ElastiCache [6] service. De mogelijkheden van Redis als databank worden onder de loep genomen in de volgende paragraaf. Redis heeft het voordeel dat het heel performant is en ondersteuning biedt voor het channel-based publish/subscribe-pattern. Hierbij rest ons nog om de horizontale schaalbaarheid en failover van de eventbus te analyseren. ElastiCache staat in voor de failover indien de eventbus zou crashen dus hoeven we enkel nog de schaalbaarheid onder handen te nemen. Daarbij maken we eerst de belangrijke opmerking dat de eventservice slechts een klein aantal simultane live-events moet verzorgen; meestal zal dit slechts ´e´en event zijn. E´en live-event komt overeen met ´e´en channel, dus dit betekent dat de load op de eventbus evenredig zal zijn met het aantal applicatieservers en het aantal gegenereerde events. Zelfs met een heel groot aantal clients (in de grootteorde van een miljoen) zal ´e´en Redis-node een typische load op de eventservice perfect onder controle kunnen houden. Dit zal later in hoofdstuk 9 nog gestaafd worden dankzij de resultaten van de load-testen. Dit heeft als gevolg dat onze architectuur slechts ´e´en Redis-node zal voorzien. Indien het toch nodig zou zijn om uit te schalen, is dit o.a. mogelijk via client-side sharding [58]. Deze techniek zal op de client de hashwaarde nemen van de string die het channel voorstelt. Via deze hashwaarde wordt dan de juiste Redis-node uit de cluster bepaald om vervolgens een verbinding op te zetten. Alle applicatieservers die ge¨ınteresseerd zijn in hetzelfde channel komen dus bij dezelfde node terecht en de eventspace wordt mits een goede hashfunctie evenredig verdeeld over
3.2 Architecturale componenten
25
de cluster. Redis-commando’s die gebruikmaken van meerdere sleutels kunnen in dit geval niet meer gebruikt worden, maar dit vormt voor onze use case geen probleem. Dit vereist wel dat de cluster een vaste grootte heeft, anders zijn nog bijkomende inspanningen vereist. Tot slot vermelden we ook nog dat er een oplossing komt die ingebouwd is in Redis zelf dankzij Redis Cluster [38], deze is momenteel echter nog in b`eta.
3.2.4
Databank
In principe bevat onze architectuur nu alle componenten om events op een schaalbare manier te distribueren van de publisher naar de subscribers. Het is echter mogelijk dat de verbinding tussen de eventservice en de subscriber tijdelijk wordt verbroken, waardoor de subscriber zich opnieuw moet verbinden met de eventservice. In het volgende hoofdstuk zullen we zien dat in het geval van een verbinding via HTTP-long-polling een periodieke korte onderbreking zelfs onvermijdelijk is. Wanneer een onderbreking optreedt, moet de client eens terug verbonden in staat zijn om alle events op te vragen die ondertussen zijn opgetreden. We hebben dus nood aan een databank om events tijdelijk te persisteren. Aangezien onze eventservice in real-time werkt, willen we dat zowel het wegschrijven en het ophalen van een event heel performant is. Redis werd al vermeld in de vorige paragraaf als eventbus maar het is in de eerste plaats een databank. Binnen de verzameling van NoSQL-databanken maakt Redis deel uit van de key-value stores. Een key-value store mapt een string (de sleutel) op een bepaalde waarde (de value), meestal ook een string. Opdat het opslaan en opvragen van een sleutel in O(1) zou kunnen gebeuren, gebeurt de implementatie a.d.h.v. een hashtabel. Redis is echter meer dan een triviale key-value store aangezien de waarde bestaat uit een volwaardige datastructuur. Bij iedere nieuwe sleutel moet er een keuze gemaakt worden uit vijf verschillende datastructuren. Iedere datastructuur biedt specifieke operaties aan en afhankelijk van de applicatie kan een geschikte keuze gemaakt worden. De documentatie van Redis vermeldt per operatie de tijdscomplexiteit a.d.h.v. de Big-O-notatie, waardoor we meer inzicht krijgen in de snelheid van onze query’s. De uitstekende performantie van Redis komt voort uit de eigenschap dat de volledige dataset in het primair geheugen wordt gehouden. Iedere operatie kan dus rechtstreeks uitgevoerd worden in het RAM-geheugen, waardoor trage toegangstijden naar de harde schijf worden vermeden. Door deze eigenschap toont Redis een opvallende gelijkenis met in-memory cache systemen zoals memcached [27]. Memcached is in essentie ook een key-value store met als voordeel dat het standaard een oplossing biedt om uit te schalen. De nadelen zijn dat het enkel strings bezit als value en aangezien het specifiek is ontworpen als een cache-service biedt het ook geen persistentie. Memcached wordt
3.2 Architecturale componenten
26
dan ook meestal gebruikt als een extra cache layer tussen de applicatieservers en de databank met als doel de performantie op te drijven. Redis voldoet aan de atomicity, consistency en isolation eigenschappen van ACID. De durability eigenschap is instelbaar maar indien we de typische performantie van Redis willen behouden, moet hier een toegeving gedaan worden. We zullen Redis instellen zodat deze gebruikmaakt van een append only file (AOF) [40], de default-instelling binnen ElastiCache. Daarbij wordt iedere wijziging sequentieel toegevoegd aan een logbestand. Indien de node crasht, kan de databank opnieuw aangemaakt worden in het primair geheugen a.d.h.v. dit logbestand. Het gebruik van de AOF-persistentie vereist nog een bijkomende configuratieparameter, namelijk het interval waarmee wijzigingen worden weggeschreven naar het logbestand. Standaard is dit 1 seconde en dit is ook de instelling die wij zullen gebruiken. Dit wil zeggen dat in het geval van een crash alle wijzigingen van ten hoogste ´e´en seconde verloren gaan. Dit is de prijs die we betalen om een goede performantie te garanderen, zowel bij lees- als schrijfoperaties. Een wijziging kan ook direct gepersisteerd worden naar het logbestand, maar dan gaat het performantievoordeel van Redis verloren voor schrijfoperaties. We merken nog op dat Redis niet geschikt is voor heel grote datasets aangezien de dataset volledig in het primair geheugen moet passen1 . Dit is geen probleem voor onze use case aangezien gegenereerde events slechts een beperkte tijd beschikbaar moeten zijn, aangezien anders bezwaarlijk over een real-time systeem kan gesproken worden. Wanneer een client er niet in slaagt om binnen een bepaalde tijd opnieuw te connecteren, zal onze client-side API een notificatie sturen waarop de client zijn initialisatieprocedure opnieuw zal moeten uitvoeren. Tot slot vermelden we nog de verschillende datastructuren die Redis aanbiedt. Wanneer we in hoofdstuk 8 de implementatie bespreken, zullen we een keuze maken uit dit aanbod. Uitgebreide informatie over deze datatypes kan gevonden worden via [39]. De verschillende mogelijkheden zijn: String: Dit is het meest eenvoudige type waarop een sleutel kan gemapt worden. Redis
biedt operaties aan om een string toe te voegen aan een andere string. Verder zijn er ook operaties die toelaten om een string te interpereteren als een integer en deze te laten toenemen of afnemen met een bepaalde waarde in ´e´en atomische operatie. Hash: Een hash laat toe om een string te mappen op een andere string en zijn daarom
ideaal om objecten voor te stellen. De implementatie maakt uiteraard gebruik van een hashtabel. 1
We kunnen uiteraard horizontaal uitschalen via oplossingen zoals het eerder genoemde client-side sharding.
De opslagruimte per node zal echter sowieso kleiner zijn in vergelijking met een traditionele databank die zijn volledige dataset niet in het primair geheugen opslaat.
3.2 Architecturale componenten
27
List: Een lijst in Redis is een dubbel gelinkte lijst waardoor het slechts O(1) tijd kost om
een element aan de uiteindes van de lijst toe te voegen of te verwijderen. Een operatie op een element in het midden van de lijst is daarentegen O(n) met n het aantal elementen in de lijst waardoor zulke operaties niet aan te raden zijn. Set: Een set is een ongeordende collectie van strings zonder dubbels en laat toe om ele-
menten toe te voegen, te verwijderen of te testen op hun bestaan in O(1) tijd. Conceptueel komt deze datastructuur overeen met een verzameling zoals we die kennen uit de relationele algebra en Redis voorziet dan ook operaties om elementaire relationele bewerkingen uit te voeren zoals de unie, de doorsnede en het verschil van twee sets. Een set wordt ge¨ımplementeerd a.d.h.v. een hashtabel. Sorted Set: Een sorted set is een geordende collectie van strings zonder dubbels waarbij
iedere string ook een score heeft die gebruikt wordt om de ordening te defini¨eren. Het laat operaties toe om elementen toe te voegen, te verwijderen of te updaten in O(log n) tijd met n het totaal aantal elementen. Verder is het ook mogelijk om range-query’s uit te voeren. De eigenschappen van deze datastructuur doen vermoeden dat de implementatie gebeurt a.d.h.v. een gebalanceerde zoekboom maar bij Redis werd echter gekozen voor een skip list [71]. Een skip list is een randomized datastructuur waarbij de tijdscomplexiteit van de verschillende operaties een verwachtingswaarde is en dus geen garantie levert. De implementatie is echter veel eenvoudiger dan een gebalanceerde zoekboom en een skip list levert in de praktijk een performantie die kan wedijveren met gebalanceerde zoekbomen. De horizontale schaalbaarheid en failover van Redis werden al besproken in de vorige paragraaf over de eventbus waardoor we kunnen overgaan naar de laatste component van onze architectuur.
3.2.5
Content distribution network
Wanneer we informatie zo snel mogelijk van de server tot de client willen transporteren, botsen we op een bepaald ogenblik op fysische beperkingen. Informatie kan zich volgens de relativiteitstheorie immers niet sneller voortbewegen dan het licht en dit vormt dus een absolute bovengrens voor de snelheid van onze data. In de praktijk gaat de informatie over glasvezel waarbij de lichtsnelheid trager is dan in een vacu¨ um. Verder gaat de data ook over tragere koperlijnen en zal er bij iedere hop van het routingpad van een pakket tijd verloren gaan door routing, processing, queuing en transmission delays. Een content distribution network (CDN) biedt een oplossing voor deze fysische beperking door de informatie dichter bij de clients te brengen a.d.h.v. edge servers. Deze edge servers worden geplaatst op strategische plaatsen verspreid over de aarde, waarbij zij een kopie van de data bevatten. Een client vraagt dan de benodigde informatie op aan de dichtst gelegen edge server.
3.2 Architecturale componenten
28
Indien deze edge server over deze informatie beschikt zal hij deze teruggeven. Indien de edge server niet beschikt over de gevraagde informatie zal hij deze op zijn beurt opvragen bij de origin server om de verkregen informatie daarna terug te geven aan de client. Een edge server speelt dus de rol van een cache en dit levert naast de genoemde snelheidswinst nog enkele voordelen op. Ten eerste vermijden we redundante data transfers aangezien de informatie slechts ´e´en keer getransporteerd moet worden van de origin servers naar de edge server. Dit heeft dan als gevolg dat bottlenecks binnen het internet worden gereduceerd en er zo meer bandbreedte overblijft voor andere netwerkapplicaties. Nog een voordeel is dat de load op de origin servers afneemt aangezien deze nu verspreid wordt over de verschillende edge servers. Onze architectuur kan op twee manieren baat hebben bij de aanwezigheid van een CDN. Ten eerste hebben we zoals vermeld bij de vereistenanalyse nood aan een observable model, waarbij eerst een snapshot van het huidige model wordt opgevraagd. Als we de opeenvolgende snapshots nummeren met een id, kunnen we achteraf het juiste snapshot opvragen met dit id. Aangezien een snapshot een statische resource is, kan deze zonder problemen gecachet worden door de CDN wat ons de eerder genoemde voordelen oplevert. Het tweede voordeel dat we uit de CDN kunnen halen speelt zich af bij de verspreiding van de events en wordt afgebeeld in figuur 3.2. Het slagen van deze methode zal echter afhangen van de CDN-implementatie van Amazon. Een subscriber heeft namelijk een permanente verbinding nodig met de server waarlangs nieuwe events kunnen worden gepusht. In hoofdstuk 4 zullen we zien dat HTTP-long-polling ´e´en van de mogelijkheden is.
Figuur 3.2: HTTP-long-polling via CDN
Deze techniek bestaat er in essentie uit dat een client een HTTP-request initieert om het volgende event te verkrijgen. De server zal daarbij niet direct een antwoord sturen maar zal wachten tot hij op de hoogte wordt gebracht van een nieuw event via de eventbus. Vervolgens zal hij een
3.2 Architecturale componenten
29
HTTP-response terugsturen met de informatie van dit event. Dezelfde cyclus herhaalt zich opnieuw door de client die een nieuw HTTP-request verstuurt. Het nieuwe event dat kan optreden stelt een resource voor en deze wordt adresseerbaar gemaakt a.d.h.v. een URL. Het stuk van de URL dat ons hierbij interesseert is het pad. Dat kunnen we vormen door een hi¨erarchische structuur te vormen waarbij eerst het channel vernoemd wordt en vervolgens het id van het volgende event. Nu kunnen we de CDN opnemen in onze architectuur waarbij iedere aanvraag voor een nieuw event langs de edge servers gaat. Wanneer een client als eerste een HTTP-request verstuurt voor het volgende event van een channel, zal de edge server na het raadplegen van zijn cache de aanvraag doorsturen naar de origin server. In ons geval wordt de origin server voorgesteld door de load balancer. De load balancer zal de aanvraag doorsturen naar een van de applicatieservers die zich zal inschrijven op het channel bij de eventbus, indien dat nog niet het geval is. Daarna zal de applicatieserver wachten met het sturen van een HTTP-response tot hij een event ontvangt voor dit channel via de eventbus. Ondertussen blijft de HTTP-request ook openstaan bij de edge server. In het geval van een live-event kunnen er vervolgens nog vele andere clients zijn die een aanvraag doen voor hetzelfde event en dus dezelfde resource. Aangezien de edge server reeds een aanvraag heeft openstaan voor deze resource bij de origin server, zal hij geen nieuwe aanvraag voor deze resource indienen. Wanneer er een nieuw event is opgetreden of het long-poll interval is afgelopen zal hij een HTTPresponse krijgen op de initi¨ele HTTP-request. Dit antwoord zal hij dan vervolgens terugsturen naar alle ge¨ınteresseerde clients, waarna een nieuwe cyclus gestart wordt. Onze use case bestaat uit live-events waarbij alle clients zich zullen inschrijven voor hetzelfde channel. Dit wil zeggen dat de HTTP-requests van alle clients die zich met dezelfde edge server verbinden gebundeld worden tot ´e´en HTTP-request naar de origin server. Op die manier verleggen we de load op ons systeem van de origin server naar de edge servers. Dit zou als gevolg hebben dat de load op onze origin server zo laag is dat zelfs een load balancer overbodig wordt. Amazon CloudFront Amazon biedt met CloudFront [2] een CDN aan met verschillende edge locaties verspreid over de wereld. CloudFront laat toe om een distributie te defini¨eren waarbij de origin server opgegeven wordt en zal vervolgens een uniek subdomein aanmaken van het cloudfront.net domein. Dit subdomein kan dan gebruikt worden om de resources overal ter wereld te adresseren waarbij via het DNS-protocol de IP-adressen van de edge servers van de dichtst gelegen edge locatie zullen teruggegeven worden.
3.3 Synopsis
30
Verder laat CloudFront ook toe om een keuze te maken uit het HTTP/HTTPS-protocol of het RTMP-protocol van Adobe. RTMP wordt gebruikt voor het streamen van media en Zentrick gebruikt het bv. voor het streamen van video naar de Flash-clients. CloudFront heeft daarnaast nog een hele resem opties waaronder o.a. het defini¨eren van custom caching-rules i.p.v. de standaard Cache-Control header van HTTP te gebruiken. Als laatste willen we nog even stilstaan bij het tweede voordeel van de CDN dat we hiervoor beschreven hebben. Daarbij gingen we ervan uit dat de edge server geen nieuwe aanvraag zou sturen in het geval hij reeds een aanvraag heeft openstaan voor dezelfde resource. Verder veronderstelden we ook dat de edge server het antwoord op deze aanvraag doorstuurt naar alle clients die ondertussen een aanvraag deden voor deze resource. Dit zijn twee veronderstellingen die niet gedocumenteerd zijn binnen CloudFront en we bevinden ons hier dan ook in een grijze zone. Deze piste dient dan ook te worden uitgetest waarbij het resultaat wordt besproken in hoofdstuk 9. De potenti¨ele winst in schaalbaarheid is echter te groot om deze mogelijkheid niet verder te onderzoeken. Tot slot vermelden we nog dat de horizontale schaalbaarheid en failover van deze component volledig verzorgd worden door Amazon, waardoor we hiervoor geen bijkomende maatregelen moeten treffen.
3.3
Synopsis
Uit welke mogelijke componenten bestaat de real-time architectuur en welke scoort het best op het gebied van schaalbaarheid, performantie en robuustheid? Deze onderzoeksvraag was het vertrekpunt van dit hoofdstuk, waarbij we getracht hebben om een antwoord te geven op het kwalitatieve deel van deze vraag. Dit deel bestaat uit de beschrijving van alle componenten van onze event-driven architectuur die samen een gedistribueerde implementatie vormen van het publish/subscribe-pattern. Alle componenten zijn zichtbaar op figuur 3.1 waarbij de interacties tussen de verschillende componenten hieronder worden samengevat. Iedere interactie start met een client die ge¨ınteresseerd is in een bepaalde deelverzameling van alle gegenereerde events. Deze deelverzameling wordt gedefinieerd a.d.h.v. een channel. Een client legt daarbij een bidirectionele verbinding met de load balancer die vervolgens deze verbinding zal mappen op ´e´en van de applicatieservers. Daarbij vermeldt de client ook het channel waarop hij zich wil inschrijven. De applicatieserver zal zich vervolgens in naam van de client inschrijven voor dit channel bij de eventbus indien dit nog niet het geval is. Een andere client kan dan een event publiceren op dit channel en zal hiervoor een bericht sturen dat via de load balancer tot bij een applicatieserver terechtkomt. Deze applicatieserver zal
3.3 Synopsis
31
het bericht publiceren op de eventbus, waarlangs het verspreid wordt naar alle ge¨ınteresseerde applicatieservers. Deze applicatieservers zullen dan op hun beurt alle betrokken subscribers verwittigen via de bidirectionele verbinding die tussen beide componenten aanwezig is. De combinatie van HTTP-long-polling en een CDN schept ook de mogelijkheid om de load weg te halen van onze load balancer en deze te spreiden over de verschillende edge servers van de CDN. De verbindingen van alle clients voor hetzelfde channel met ´e´enzelfde edge server worden daarbij gebundeld door de edge server tot ´e´en verbinding naar de origin server (in ons geval is dit de load balancer). Deze mogelijkheid gaat echter uit van veronderstellingen waarvan we de waarheidswaarde zullen testen en bespreken in hoofdstuk 9. Daarnaast zal ook de performantie en schaalbaarheid van deze architectuur getest worden in hoofdstuk 9 a.d.h.v. load-testing. In het volgende hoofdstuk bespreken we de technieken die het mogelijk maken om nieuwe informatie van de server naar de client te pushen binnen het web. Deze technieken staan bekend onder de verzamelnaam ‘real-time web’.
REAL-TIME WEB
32
Hoofdstuk 4
Real-time web In het vorige hoofdstuk bespraken we de event-driven architectuur waarbij de verschillende componenten samen een gedistribueerde versie van het publish/subscribe-pattern aanbieden. Daarbij hebben we vermeld dat iedere subscriber nood heeft aan een bidirectionele verbinding met een applicatieserver uit onze cluster. De mogelijkheid tot bidirectionele communicatie tussen client en server binnen het web, wordt het real-time web genoemd. Om het real-time web mogelijk te maken is er nood aan een protocol dat hiervoor ondersteuning biedt. In dit hoofdstuk zullen we twee protocollen bekijken die bidirectionele communicatie mogelijk maken binnen het web, namelijk HTTP en WebSocket.
4.1
HTTP
Het klassieke web is opgebouwd rond het HTTP-protocol, waarbij een client een HTTP-request verstuurt naar een publieke webserver. Deze zal de request verwerken volgens bepaalde semantische regels om uiteindelijk een HTTP-response terug te sturen naar de client. Daarbij valt het op dat de communicatie enkel kan worden ge¨ınitieerd door de client en niet door de server. Het real-time web vereist echter dat ook de server deze communicatiecyclus kan opstarten zodat er sprake is van bidirectionele communicatie. Om dit mogelijk te maken via het HTTP-protocol zijn er verschillende technieken ontwikkeld onder de verzamelnaam Comet [12]. Vooraleer we de verschillende technieken overlopen, bekijken we eerst nog de XMLHttpRequest API die het mogelijk maakt om een HTTP-request te initi¨eren in een browseromgeving. Het XMLHttpRequest is een voorbeeld van een asynchrone API die gebruikmaakt van het event-based model dat wordt besproken in hoofdstuk 7. Het idee is dat we eerst enkele event-listeners registreren bij een XMLHttpRequest instantie, waarna de HTTP-request wordt afgevuurd. Via de event-listeners worden we dan op de hoogte gebracht van een succesvolle of een gefaalde request zoals in codevoorbeeld 4.1.
4.1 HTTP
33
1 // 1. We maken een XMLHttpRequest - instantie aan . 2 var request = new XMLHttpRequest (); 3 4 // 2. We voegen event - listeners toe voor succesvolle en 5 // gefaalde requests . 6 7 // Succes 8
request . addEventListener ( ' load ', function (){ console . log ( 'De server reageert met status ' + request . status +
9
' en response body ' + request . response );
10 11 }); 12 13 // Falen 14
request . addEventListener ( ' error ' , function ( err ){
15
console . error ( 'Er is een fout opgetreden . ' );
16 }); 17 18 // 3. We configureren de HTTP - methode en URL . 19
request . open ( ' GET ' , url );
20 21 // 4. We starten de HTTP - request . 22
request . send (); Codevoorbeeld 4.1: XMLHttpRequest-API
In de volgende deelparagrafen volgen de verschillende technieken die real-time web mogelijk maken a.d.h.v. HTTP. Bidirectionele communicatie vereist dat zowel de client als de server op eigen initiatief een bericht kunnen sturen naar de andere partij. Standaard biedt HTTP de mogelijkheid om op initiatief van de client een bericht naar de server te sturen. De volgende technieken beschrijven dan ook enkel een manier om op initiatief van de server een bericht naar de client te sturen.
4.1.1
Polling
De meest elementaire techniek is polling, waarbij volgens een bepaald interval een HTTP-request wordt afgevuurd om eventuele nieuwe informatie op te vragen bij de server. Indien we over realtime informatie willen beschikken mag dit interval niet te groot zijn, wat als gevolg heeft dat er heel veel requests worden ge¨ınitieerd die geen nieuwe informatie opleveren. Dit is een verspilling
4.1 HTTP
34
van bandbreedte en de server heeft veel rekenkracht nodig om het grote aantal requests af te handelen. Deze techniek is dus niet performant en zeker niet schaalbaar, maar vormt wel de basis voor een schaalbaar alternatief, namelijk long-polling.
4.1.2
Long-polling
Long-polling start net zoals polling met een HTTP-request voor nieuwe informatie maar de server zal pas een HTTP-response sturen indien hij op de hoogte wordt gebracht van nieuwe informatie. Wanneer de client een HTTP-response ontvangt stuurt hij meteen een nieuwe HTTP-request. Op die manier bezit de server per client steeds over een openstaande HTTP-request waarlangs nieuwe informatie kan worden gepusht. Tussen het sturen van de HTTP-response van de server en de volgende HTTP-request van de client bevindt er zich een kleine tijdspanne waarbij de client geen verbinding heeft met de server. Daarom zal de client steeds aangeven over welke informatie hij reeds beschikt in zijn volgende HTTP-request, zodat de server bij de ontvangst van dit request kan nagaan of er ondertussen al nieuwe informatie is opgetreden. Indien dit het geval is, zal de server direct een HTTP-response sturen met deze informatie. In onze architectuur kan de applicatieserver dit nagaan via de databank die opgenomen is in het ontwerp. Indien er gedurende een lange tijd geen nieuwe informatie beschikbaar wordt, blijft de HTTPrequest openstaan. Een browser zal echter standaard een timeout triggeren wanneer er na 300s nog geen response is ontvangen op een HTTP-request [25]. Tussenliggende proxy’s in het web hebben echter meestal een timeout-waarde die lager ligt dan deze 300s en dus moeten we ervoor zorgen dat de long-poll requests na een bepaald interval sowieso be¨eindigd worden. Daarvoor wordt een long-poll interval ingevoerd dat aangeeft hoe lang een HTTP-request kan blijven openstaan. Indien er binnen deze tijdspanne geen nieuwe informatie optreedt, wordt er een lege HTTP-response teruggestuurd waarna de client een nieuwe cyclus kan starten. RFC 6202 raadt hierbij aan een waarde te gebruiken van ten hoogste 30s [25]. De ELB van Amazon heeft bv. een timeout-waarde van default 60s; deze kan nog aangepast worden via een configuratieparameter. De implementatie van long-polling in een browser gebeurt typisch a.d.h.v. het XMLHttpRequest; een eenvoudige client-side implementatie wordt gegeven in codevoorbeeld 4.2. Het probleem met HTTP-verkeer via de XMLHttpRequest-API is dat die onderhevig is aan de same-origin policy [42]. Deze zorgt ervoor dat HTTP-verkeer enkel mogelijk is met services die dezelfde origin hebben als de webpagina of het frame waarbinnen de HTTP-request wordt gestart. De origin wordt bepaald door het schema, de host en de poort van de URL van de webpagina of het frame. Indien deze niet dezelfde zijn, wordt er gesproken over een cross-origin request.
4.1 HTTP
1
35
function longPolling ( url , callback ) {
2
// 1. We maken een XMLHttpRequest - instantie aan .
3
var request = new XMLHttpRequest ();
4 5
// 2. We voegen een event - listener toe die aangeroepen zal worden
6
// in het geval van een HTTP - response .
7
request . addEventListener ( ' load ', function (){ if ( request . status === 200) {
8
// De HTTP - response bevat nieuwe informatie .
9 10
// De nieuwe informatie wordt meegegeven
11
// met de callback - functie .
12
callback ( request . response ); }
13 14 15
// We starten een nieuwe long - poll - cyclus .
16
longPolling ( url ); });
17 18 19
// 3. We configureren de HTTP - methode en URL .
20
request . open ( ' GET ' , url );
21 22
// 4. We starten de HTTP - request .
23
request . send ();
24 } Codevoorbeeld 4.2: Long-polling via het XMLHttpRequest
Onze use case vereist ondersteuning voor cross-origin requests, aangezien het frame waarbinnen de videoplayer van Zentrick draait een andere origin zal hebben dan de URL van de eventservice. Met de komst van het XMLHttpRequest Level 2 [50] is er een gestandaardiseerde oplossing voor cross-origin requests dankzij cross-origin resource sharing (CORS) [13]. De browserclient zal automatisch de benodigde CORS-headers toevoegen aan de HTTP-request, waarna deze dan ge¨ınterpreteerd kunnen worden door de server om overeenkomstige CORS-headers te plaatsen op de HTTP-response. Een HTTP-GET-request zal bv. steeds een Origin-header bevatten die gelezen wordt door de server waarna deze via de Access-Control-Allow-Origin-header kan aangeven of deze origin gebruik mag maken van de diensten van de server. De browserclient zal vervolgens de HTTP-response
4.1 HTTP
36
controleren op de aanwezigheid van de juiste CORS-headers om uiteindelijk de gevraagde resource al dan niet terug te geven via de XMLHttpRequest API. In het geval van een HTTP-POSTrequest zal er eerst een preflight-request gebeuren voorafgaand aan de request om na te gaan of deze request toegelaten is. CORS vereist dus enerzijds dat we de applicatieservers kunnen configureren voor het gebruik van deze headers, wat in ons geval geen enkel probleem vormt. Daarnaast vereist het ook een nieuwere browser die ondersteuning biedt voor het XMLHttpRequest Level 2. Daarover kan uiteraard geen controle uitgeoefend worden en hangt af van de browser van de client. Om tegemoet te komen aan oudere browsers kan er gebruikgemaakt worden van long-polling a.d.h.v. JSONP [24] voor cross-origin requests. Voor onze use case is dit echter niet nodig aangezien onze client-side module een Zentrick-module is en geen browser-module. Zoals vermeld in de inleiding zal Zentrick in het geval van een nieuwere browser gebruikmaken van HTML5 en voor oudere browsers terugvallen op Flash. De Zentrick-API voorziet o.a. ook een XMLHttpRequest API volgens de interface van het W3C die binnen de Flash-omgeving de requests zal delegeren naar de URLRequest-API van Flash. In de Flash-omgeving geldt er ook een same-origin policy maar via een cross-domain policy file [33] is het mogelijk om cross-origin requests uit te voeren. Bij het opvragen van een resource zal de Flash-container eerst een HTTP-GET-request uitvoeren naar dezelfde host als de resource, maar met als pad /crossdomain.xml. De server dient dan een HTTP-response te sturen met als body een cross-domain policy file: dit is een XML-bestand dat o.a. aangeeft welke origin gebruik mag maken van de diensten van de server, net zoals bij CORS. Onze applicatieservers moeten dus naast CORS ook een cross-domain policy file aanbieden om cross-origin HTTP-requests vanuit de Flash-container mogelijk te maken. De configuratie hiervoor is triviaal aangezien dit bestand wordt opgevraagd via een HTTP-GET-request naar een statisch pad. Long-polling heeft als grote voordeel dat het altijd werkt en het kan dus dienst doen als een laatste fallback wanneer geen enkel andere server-push techniek mogelijk is. Daarnaast zijn er ook enkele nadelen aan verbonden die opgelijst worden in RFC 6202 [25]. De voornaamste worden hier opgesomd: Header overhead : Iedere long-poll request bevat de overhead van de HTTP-headers, wat
veel extra bandbreedte kost, vooral bij kleine boodschappen. Latency: De latency om een boodschap van de server naar de client te krijgen is gemiddeld
gelijk aan ´e´en keer de end-to-end delay. Wanneer er echter een event optreedt tussen twee
4.1 HTTP
37
long-poll requests door, kan deze maximaal de grootte hebben van minstens drie keer de end-to-end delay. TCP-connectie overhead : Iedere HTTP-request heeft een onderliggende TCP-connectie.
In het geval van HTTP 1.0 wordt per HTTP-request een nieuwe TCP-connectie opgezet wat extra overhead en latency met zich meebrengt door o.a. de three-way handshake en het slow-start mechanisme van TCP. Dankzij de persistent connections van HTTP 1.1 worden de TCP-connecties nu default niet afgebroken maar worden ze hergebruikt voor volgende HTTP-requests, waardoor de overhead van het opzetten en afbreken van TCP-connecties wegvalt. In de praktijk wordt HTTP 1.0 ook nauwelijks meer gebruikt. Caching: Tussenliggende proxy’s en gateways kunnen aan caching doen waardoor onze
long-poll request niet het gewenste effect heeft. Dit kan gelukkig eenvoudig opgelost worden door bij het sturen van de HTTP-response een Cache-Control header te plaatsen met als waarde no-cache.
4.1.3
Streaming
Long-polling biedt veel voordelen t.o.v. gewone polling, maar toch zijn er nog enkele belangrijke nadelen waaronder de overhead van de headers en een mogelijke latency van drie keer de endto-end delay tussen twee long-poll requests. Een derde techniek om bidirectionele communicatie mogelijk te maken via HTTP maakt daarom gebruik van de streaming-mogelijkheden van het protocol en weet ook deze nadelen te vermijden. Het TCP-protocol dat zich in de transportlaag onder het HTTP-protocol bevindt, is een voorbeeld van een connection-oriented en stream-based protocol [46] dat de beschikbare data voorstelt als een stream van bytes. Het is echter geen message-based protocol en het is de taak van het protocol in de applicatielaag om aan message-framing te doen. Bij HTTP 1.0 gebeurt dit door de onderliggende TCP-connectie af te sluiten. HTTP 1.1 zal de TCP-connectie niet meer afsluiten en gebruikt de Content-Length header of de Transfer-Encoding: chunked header om aan message-framing te doen. Via de Connection: close header is het mogelijk om net zoals HTTP 1.0 een bericht af te bakenen door de onderliggende TCP-connectie te sluiten. HTTP-streaming maakt in het geval van HTTP 1.1 gebruik van de Transfer-Encoding: chunked header om data van de server naar de client te streamen. De client start de communicatie met een HTTP-GET-request waarop de server direct de headers van de HTTP-response stuurt, met o.a. de Transfer-Encoding: chunked header. Deze header vertelt de client en tussenliggende proxy’s dat de response nog niet volledig is en dat het resterende deel in verschillende chunks zal verstuurd worden.
4.1 HTTP
38
Iedere chunk wordt voorafgegaan door de lengte uitgedrukt in het hexadecimale talstelsel gevolgd door een CR/LF zoals te zien in codevoorbeeld 4.3. Door ieder event in een chunk onder te brengen is het mogelijk om meerdere events te triggeren met slechts ´e´en HTTP-request. De response wordt uiteindelijk be¨eindigd door een chunk met lengte 0 aan te kondigen. HTTP 1.0 laat ook streaming toe door de onderliggende TCP-connectie open te houden. Nieuwe informatie wordt toegevoegd aan de HTTP-response en de response wordt be¨eindigd door de TCP-connectie te sluiten. HTTP /1.1 200 OK Content - Type : text / plain Transfer - Encoding : chunked 25 This is the data in the first chunk 1C and this is the second one 0 Codevoorbeeld 4.3: HTTP-response met chunked encoding in HTTP 1.1 Er zijn verschillende implementaties van HTTP-streaming in een browseromgeving; we lichten ze kort even toe: Forever frame: Er wordt een verborgen iframe aangemaakt in de DOM van de webpa-
gina, waardoor een HTTP-request getriggerd wordt om de inhoud van dit iframe op te vragen. De server stuurt voorlopig enkel de headers terug en geeft aan gebruik te maken van HTTP-streaming zoals hiervoor beschreven. Nieuwe informatie wordt via een script-tag doorgestuurd, waarbij de browser na ontvangst deze script-tag zal toevoegen aan het iframe. De code die zich in deze script-tag bevindt, zal daarna worden uitgevoerd. De implementatie verschilt per browser aangezien sommige browsers geen scripts zullen uitvoeren indien er geen extra padding-data wordt toegevoegd zoals bv. een
tag. XHR-streaming: De webclient stuurt een HTTP-request via het XMLHttpRequest waarna
de server wederom enkel de HTTP-headers terugstuurt. Nieuwe informatie wordt verkregen via het readystatechange event van het XMLHttpRequest object. Ook hier zijn talloze browserhacks nodig zoals o.a. de vermelding van een specifiek Content-Type afhankelijk van de browser. Ook dient de HTTP-response na verloop van tijd be¨eindigd te worden aangezien alle gepushte informatie in het geheugen aanwezig blijft.
4.1 HTTP
39
Server-Sent Events (SSE): SSE is het antwoord van het W3C op XHR-streaming en het
forever frame. Het voorziet de EventSource API die alle implementatiedetails verbergt waardoor de ontwikkelaar zich kan focussen op het bouwen van de applicatie. Daarnaast voorziet het ook automatische reconnecting wanneer de verbinding wegvalt. SSE is echter enkel beschikbaar in moderne browsers.
Dankzij HTTP-streaming vermijden we de overhead van de HTTP-headers en de mogelijke extra latency tussen twee events. Net zoals bij long-polling zijn er echter ook nog enkele nadelen aan HTTP-streaming die beschreven worden in RFC 6202 [25]: Proxy buffering: Tussenliggende proxy’s kunnen ervoor kiezen om een HTTP-response
volledig te bufferen alvorens deze door te sturen naar de client. Het HTTP-protocol vereist namelijk niet dat een gedeeltelijke HTTP-response direct wordt doorgestuurd door een proxy-server. Dit probleem kan opgelost worden door het HTTP-verkeer over SSL te leiden, via HTTPS. Latency: Theoretisch is de maximale latency van HTTP-streaming gelijk aan ´e´en keer
de end-to-end delay. Zoals al vermeld bij de verschillende implementaties van HTTPstreaming moet de HTTP-response na een zekere tijd be¨eindigd worden om geen geheugen te lekken aan de client-side. Daardoor krijgen we terug een maximale latency van minstens drie keer de end-to-end delay. In tegenstelling tot long-polling is dit echter niet mogelijk tussen elke twee events. HTTP-streaming biedt enkele voordelen t.o.v. long-polling, maar de implementaties a.d.h.v. een forever frame of XHR-streaming zijn door de vele specifieke browserhacks moeilijk onderhoudbaar. SSE zorgt voor een gestandaardiseerde oplossing die ons verlost van deze browserhacks, maar is enkel in nieuwere browsers beschikbaar. Dit probleem zou opgelost kunnen worden door wederom gebruik te maken van de tweeledigheid van het Zentrick-platform. De HTML5-omgeving van Zentrick wordt gebruikt in het geval van een moderne browser waar SSE beschikbaar is.
Oudere browsers vallen terug op de Flash-omgeving en daar kan de
EventSource API van SSE nagebootst worden zoals dat voor de XMLHttpRequest API reeds het geval is. Dit vereist dan echter dat we een implementatie moeten schrijven voor SSE in Flash. In het kader van de HTML5-specificatie werd echter het WebSocket-protocol en bijhorende browser-API ontwikkeld die standaard voorzien in bidirectionele communicatie. WebSocket vormt daarbij een veel betere kandidaat om op te nemen als netwerkprotocol binnen onze architectuur, met als gevolg dat er geen gebruik zal gemaakt worden van HTTP-streaming.
4.2 WebSocket
4.2
40
WebSocket
De WebSocket-standaard bestaat uit twee grote delen. Ten eerste is er het WebSocket-protocol dat ontwikkeld is door de HyBi-werkgroep van het IETF. Hierbij worden alle details besproken van het protocol dat zich binnen de applicatielaag van het OSI-model bevindt. Vervolgens is er de WebSocket-API ontworpen door het W3C die een interface aanbiedt om binnen een browseromgeving gebruik te maken van het WebSocket-protocol. We starten deze paragraaf met een beschrijving van het protocol waarna we overgaan op de API die we uiteindelijk binnen het Zentrick-platform beschikbaar willen maken. Als laatste bekijken we de integratie van WebSocket binnen de event-driven architectuur.
4.2.1
WebSocket-protocol
Het WebSocket-protocol wordt beschreven in RFC 6455 [45] en is de standaard voor bidirectionele communicatie binnen het web. Aangezien real-time webapplicaties pas de laatste jaren een opmars kennen, is dit protocol vrij recent ontwikkeld (de RFC dateert van 2011) door de HyBi werkgroep van het IETF. Zoals de naam WebSocket doet vermoeden is het protocol opgebouwd met het idee om het concept van TCP-sockets te voorzien binnen het web. Aangezien compatibiliteit met de huidige webinfrastructuur belangrijk is, gebruikt WebSocket ook de poorten 80 en 443 van HTTP(S) en is er zoveel mogelijk getracht om ondersteuning te bieden voor bestaande webproxy’s en intermediaire servers. WebSocket voegt enkele zaken toe aan TCP opdat het bruikbaar zou zijn voor het web. Ten eerste voegt het een beveiligingsmodel toe gebaseerd op de same-origin policy die we eerder al besproken hebben. Het voegt net zoals HTTP adressering toe a.d.h.v. een pad om meerdere services op dezelfde poort aan te bieden. Eerder werd ook al vermeld dat TCP een streambased protocol is waarbij de taak van message-framing aan de applicatielaag wordt overgelaten. Opdat de gebruiker niet zelf moet instaan voor message-framing, wordt dit ook voorzien door het WebSocket-protocol. Als laatste voorziet het protocol een closing-handshake die ook ge¨ınterpreteerd kan worden door bestaande proxy’s en intermediaire servers. HTTP opening-handshake Opdat het WebSocket-protocol zou werken in de aanwezigheid van bestaande webinfrastructuur wordt de verbinding opgezet met een opening-handshake a.d.h.v. een HTTP-GET-request (zie codevoorbeeld 4.4). Daarbij wordt gebruikgemaakt van de Upgrade: websocket header en de Connection: Upgrade header om aan te geven dat de client wil overschakelen op het WebSocketprotocol. Op de eerste regel van de request wordt het pad vermeld om zoals eerder vermeld meerdere services aan te bieden op ´e´en poort. De Host en de Origin header hebben dezelfde
4.2 WebSocket
41
functie als binnen HTTP, zodat ons enkel de bespreking rest van de verplichte headers die eigen zijn aan het WebSocket-protocol. Het huidige WebSocket-protocol (versie 13) zoals beschreven in RFC 6455 is het resultaat van meerdere iteraties en wordt door de meeste moderne browsers ondersteund. Opdat client en server dezelfde versie van het protocol zouden hanteren, wordt deze vermeld door de client met de Sec-WebSocket-Version header. Als laatste hebben we de Sec-WebSocket-Key header die ervoor zorgt dat de server geen verbindingen accepteert die geen WebSocket-verbindingen zijn zodat malafide clients geweerd worden. GET / chat HTTP /1.1 Host : server . example . com Upgrade : websocket Connection : Upgrade Origin : http :// example . com Sec - WebSocket - Version : 13 Sec - WebSocket - Key : dGhlIHNhbXBsZSBub25jZQ == Codevoorbeeld 4.4: WebSocket client-handshake
De server beantwoordt de handshake van de client met een HTTP-response zoals in codevoorbeeld 4.5. Hij stuurt daarvoor een 101 Switching Protocols statuscode samen met de Upgrade en Connection header als bevestiging voor de client dat hij wil overschakelen op het WebSocketprotocol. De server dient ook nog te bewijzen aan de client dat hij de handshake goed heeft ontvangen via de Sec-WebSocket-Accept header. Daarvoor neemt hij de waarde van de random nonce die door de client gegenereerd is en meegegeven met de Sec-WebSocket-Key header. Deze nonce wordt geconcateneerd met de GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" waarna de SHA-1 hash wordt berekend van deze waarde. Het resultaat wordt tenslotte ge¨encodeerd volgens het base64-schema en toegevoegd aan de handshake van de server. HTTP /1.1 101 Switching Protocols Upgrade : websocket Connection : Upgrade Sec - WebSocket - Accept : s3pPLMBiTxaQ9kYGzzhZRbK + xOo = Codevoorbeeld 4.5: WebSocket server-handshake
4.2 WebSocket
42
Message-framing Na een succesvolle opening-handshake kunnen zowel de client als de server een bericht sturen naar de andere partij over de onderliggende TCP-connectie. Deze berichten maken daarbij niet langer gebruik van het HTTP-protocol maar worden opgesteld volgens het WebSocket-protocol. WebSocket is een message-based protocol en doet daarvoor aan message-framing, waarbij ieder WebSocket-frame (zie figuur 4.1) ingekapseld wordt in een TCP-segment. E´en message kan bestaan uit ´e´en of meerdere WebSocket-frames. De keuze om een message in ´e´en of meerdere frames op te splitsen wordt bepaald door de WebSocket-implementatie van de client of server. Een frame heeft daarbij de volgende indeling: Het FIN-bit bepaalt of dit frame het laatste fragment is van een message. De opcode (4 bits) bepaalt het type van de payload. De mogelijkheden zijn een continuation
frame (0), een text frame (1), een binary frame (2), een frame om de verbinding af te sluiten (8), een ping frame (9) of een pong frame (10). Het mask-bit bepaalt of de payload gemaskeerd is, dit is enkel van toepassing op messages
van de client naar de server. De payload-length is een veld met variabele lengte. Indien de waarde tussen 0 en 125 ligt,
is dit de werkelijke payload length. Een waarde van 126 geeft aan dat volgende twee bytes een 16-bit unsigned integer vormen die de payload-length voorstelt. Een waarde van 127 duidt op een payload-length voorgesteld door de volgende 8 bytes die samen een 64-bit unsigned integer vormen. De masking-key bevat een 32-bit waarde die gebruikt wordt om de payload te maskeren
in het geval de mask-bit op 1 staat. De payload bevat de eigenlijke application data en kan ook custom extension data bevatten
indien overeengekomen tijdens de opening-handshake.
Figuur 4.1: WebSocket-frame
4.2 WebSocket
43
Dit zorgt ervoor dat de overhead door de message-framing 2 tot 10 bytes bedraagt. De messages van de client naar de server bevatten ook nog de masking-key die 4 bytes omvat, waardoor deze messages in totaal 6 tot 14 bytes hebben aan overhead. Dit is een aanzienlijke winst in vergelijking met het HTTP-protocol waarbij een request snel 500 tot 800 bytes aan headers bevat [65]. Na deze beknopte inleiding tot de werking van het WebSocket-protocol bekijken we de API die ontwikkeld is door het W3C.
4.2.2
WebSocket-API
WebSocket biedt een minimale API aan die het mogelijk maakt om messages te versturen van de client naar de server. Op de server kan een gelijkaardige API gebruikt worden, afhankelijk van de server-side technologie die gebruikt wordt. Codevoorbeeld 4.6 geeft een voorbeeld van de WebSocket-API. 1 // We starten een nieuwe WebSocket - verbinding . 2 var socket = new WebSocket ( ' ws :// example . com :8080/ path '); 3 4 socket . addEventListener ( ' open ' , function (){ 5
// De opening - handshake was succesvol .
6
// Vanaf nu kunnen we data verzenden naar de server .
7
socket . send ( ' Hello server . ');
8 }); 9 10 socket . addEventListener ( ' close ', function (){ 11
// De WebSocket - verbinding is afgesloten .
12 }); 13 14 socket . addEventListener ( ' error ', function (){ 15
// Er is een fout opgetreden .
16 }); 17 18 socket . addEventListener ( ' message ', function ( message ){ 19
// We hebben een bericht ontvangen van de server .
20
console . log ( ' Bericht van de server : ' + message . data );
21 22
// We sluiten de verbinding na het ontvangen van een message .
23
socket . close ();
4.2 WebSocket
44
24 }); Codevoorbeeld 4.6: WebSocket-API Om een WebSocket-verbinding op te zetten, maken we eerst een object aan via de WebSocket constructor, waaraan we een URL als argument meegeven. Deze URL bevat het ws schema dat gebruikt wordt voor het WebSocket-protocol. Indien we een beveiligde verbinding willen opzetten over SSL kunnen we gebruikmaken van het wss schema. Verder vermelden we ook de host, het poortnummer en het pad van de endpoint waar de WebSocket-service draait. Vervolgens worden we via het open event asynchroon op de hoogte gebracht indien de handshake geslaagd is. Vanaf dan kunnen we messages versturen via de send methode van het WebSocket object. Indien de verbinding wordt afgesloten of indien er een fout optreedt worden we verwittigd via respectievelijk het close en het error event. Wanneer de server een boodschap stuurt, zal de client de message uit alle WebSocket-frames opnieuw samenvoegen en deze doorgeven via het message event. Indien we aan de client-side de verbinding willen be¨eindigen is dat mogelijk via de close methode.
4.2.3
Deployment
WebSocket is een nieuwkomer binnen het web en ondanks de inspanningen van het IETF om het protocol zo goed mogelijk te laten samenwerken met de bestaande webinfrastructuur is dit niet altijd mogelijk. Sommige proxy’s die het WebSocket-protocol niet verstaan kunnen verkeerdelijk meerdere WebSocket-frames bufferen of de inhoud aanpassen. De Sec-WebSocket-Key en Sec-WebSocket-Accept header kunnen helpen bij het oplossen van deze problemen. Er zijn echter nog steeds proxy’s waarbij het verkeerd loopt en daarom wordt WebSocket veelal over SSL gestuurd. Op die manier wordt een ge¨encrypteerde tunnel gecre¨eerd waardoor proxy’s niet langer de inhoud van de frames kunnen interpreteren en aanpassen. Onze architectuur zal met die reden ook gebruikmaken van het wss schema om de kans op een succesvolle WebSocket-verbinding te vergroten. In onze architectuur bevindt zich met de ELB ook een reverse proxy die het WebSocket-verkeer kan verstoren. Zoals eerder al vermeld kan deze zowel opereren op layer 4 (TCP/SSL) als op layer 7 (HTTP/HTTPS). Aangezien we SSL-decryptie willen uitvoeren op de load balancer, zullen we deze configureren als een layer 4 load balancer in SSL-modus. Dit wil zeggen dat iedere verbinding van de client tot een applicatieserver bestaat uit een beveiligde TCP-connectie van de client tot de ELB, en een onbeveiligde TCP-connectie van de ELB tot onze applicatieservers. De ELB verzorgt daarbij de decryptie en encryptie van de data en hevelt deze over van de ene TCP-connectie naar de andere.
4.3 Synopsis
45
Aangezien het WebSocket-protocol pas vrij recent is ontwikkeld, is het ook enkel in moderne browsers beschikbaar. Deze moderne browsers komen overeen met de HTML5-omgeving van het Zentrick-platform. Omdat oudere browsers gebruik zullen maken van de Flash-omgeving van het Zentrick-platform kunnen we daar ook een implementatie voorzien van de WebSocket-API. AS3 bevat geen API’s die WebSocket aanbieden, maar het biedt wel een API voor TCP-sockets. Met deze TCP-sockets kunnen we aan de slag om het WebSocket-protocol te implementeren; dit wordt verder besproken in hoofdstuk 8.
4.3
Synopsis Wat zijn de technische mogelijkheden om een event van de server naar de clients te pushen binnen een webomgeving?
Deze onderzoeksvraag vormde het uitgangspunt van dit hoofdstuk waarin we de verschillende technieken onderzochten om real-time web mogelijk te maken. Daarbij werden eerst de mogelijkheden onderzocht die gebruikmaken van het alomtegenwoordige HTTP-protocol. Polling was de eerste manier die besproken werd, maar deze vormde met zijn onvolkomenheden enkel een aanloop naar de long-polling methode. Long-polling heeft het voordeel dat het altijd werkt maar brengt een zekere overhead met zich mee door de aanwezigheid van de HTTP-headers bij iedere request. Daarnaast is er ook telkens een mogelijke niet-verwaarloosbare latency tussen twee long-poll intervallen door. Dankzij de tweeledigheid van het Zentrick-platform hoeven we echter geen gebruik te maken van JSONP voor cross-origin requests en wordt een implementatie vrij eenvoudig. De laatste techniek die gebruikmaakt van het HTTP-protocol is HTTP-streaming dat de nadelen van long-polling vermijdt maar daarbij nieuwe problemen introduceert. Tussenliggende proxy’s kunnen de keuze maken om een HTTP-response te bufferen in plaats van nieuwe data direct door te sturen. Ook is dezelfde maximale latency van HTTP-long-polling nog steeds mogelijk als gevolg van implementatiedetails op browserniveau. Het belangrijkste nadeel is echter de moeilijk onderhoudbare implementatie door de vele specifieke browserhacks. Het W3C bracht hier een gestandaardiseerde oplossing met SSE, maar met de komst van het WebSocket-protocol lijkt deze optie voor onze use case overbodig. De WebSocket-standaard is ontworpen om de standaard te worden voor bidirectionele communicatie binnen het web. Het elimineert de overhead van het HTTP-protocol en biedt een API aan die volledig gebouwd is rond het sturen en ontvangen van messages. Het ontwerp van het protocol houdt zoveel mogelijk rekening met bestaande webinfrastructuur en door onze WebSocket-verbindingen over SSL te sturen, maximaliseren we de kans op succes. Dankzij de
4.3 Synopsis
46
tweeledigheid van het Zentrick-platform kunnen we WebSocket aanbieden in zowel oudere als nieuwe browsers. De conclusie luidt dat we voor onze use case gebruik zullen maken van een combinatie van HTTP-long-polling en WebSocket. WebSocket is de beste oplossing voor bidirectionele communicatie binnen het web maar is nog niet altijd succesvol. In dat geval hebben we een goede fallback-methode nodig waarbij long-polling een uitstekende kandidaat is. De implementatie van beide methodes wordt verder besproken in hoofdstuk 8. Real-time web vereist dat de server per client minstens ´e´en TCP-connectie heeft openstaan (via HTTP-long-polling of WebSocket) waarlangs het nieuwe informatie kan pushen. In het geval van een groot aantal clients impliceert dit dat onze applicatieservers in staat moeten zijn om een aanzienlijk aantal verbindingen te onderhouden. In het volgende hoofdstuk gaan we daarbij op zoek naar een webserver architectuur die in dat geval goede prestaties kan leveren.
SCHAALBARE WEBSERVERARCHITECTUREN
47
Hoofdstuk 5
Schaalbare webserverarchitecturen Onze use case vereist dat we per client een verbinding openhouden met ´e´en van de applicatieservers waarlangs nieuwe informatie kan gepusht worden van de server naar de client. De projecten die gebruik zullen maken van de real-time interactiviteit kunnen een potentieel heel grote verzameling aan subscribers hebben. Daarom moet iedere applicatieserver in staat zijn om vele simultane connecties te onderhouden. In dit hoofdstuk gaan we op zoek naar een webserver architectuur die aan deze verwachting voldoet. Eerst worden de klassieke webservers besproken die gebruikmaken van een thread-based concurrency model waarna we vervolgen met een uiteenzetting van de event-driven webservers. We eindigen met een bespreking van Node.js, een recente implementatie van het event-driven concurrency model.
5.1
Concurrency
Een webserver die veel verbindingen simultaan moet onderhouden heeft nood aan een bepaalde vorm van concurrency. Concurrency is het uitvoeren van verschillende taken binnen overlappende tijdsintervallen [66]. Het begrip wordt vaak verward met parallellisme, dat we reeds besproken hebben in hoofdstuk 2. In het geval van parallellisme worden de verschillende taken tegelijkertijd uitgevoerd door gebruik te maken van bv. een multi-core processor. Parallellisme is dus een speciale vorm van concurrency, maar concurrency is evengoed mogelijk met slechts ´e´en processor door het concept van multitasking toe te passen. In dat geval krijgt iedere taak meerdere tijdsloten toegewezen waarbinnen een bepaalde hoeveelheid werk kan verricht worden. Bij de afloop van een tijdslot volgt er een nieuw tijdslot voor een andere taak. Op die manier worden verschillende taken door elkaar uitgevoerd, waardoor de perceptie ontstaat dat ze tegelijkertijd worden uitgevoerd.
5.1 Concurrency
5.1.1
48
Thread-based
Het thread-based model maakt gebruik van het concept van een thread. Een besturingssysteem bevat verschillende processen waarbij een proces op zijn beurt ´e´en of meerdere threads bevat. Verschillende threads binnen hetzelfde proces kunnen lezen en schrijven naar hetzelfde blok geheugen. Bij het thread-based concurrency model zal een hoeveelheid werk toegewezen worden aan ´e´en thread. In het kader van webservers wordt een hoeveelheid werk dikwijls geassocieerd met een HTTP-request of een TCP-connectie. Voor onze use case betekent dit dat iedere subscriber een eigen thread toegewezen krijgt. Aangezien het aanmaken en verwijderen van threads een zekere overhead met zich meebrengt, wordt er bij thread-based webservers meestal gebruikgemaakt van een threadpool. Een nieuwe request krijgt een thread uit de threadpool toegewezen waarbij de request vervolgens verwerkt wordt binnen deze thread. Wanneer de verwerking van een request is afgelopen, wordt de thread weer vrijgegeven aan de threadpool zodat deze aan een andere request kan toegewezen worden. Een threadpool heeft meestal ook een maximaal aantal threads, zodat de mogelijkheid bestaat dat de requests in een queue worden geplaatst indien het aantal requests hoger is dan het aantal threads. De volledige verwerking van een request speelt zich dus af op ´e´en thread, waarbij de verwerking bestaat uit een afwisseling van CPU-bursts en I/O-bursts. Een CPU-burst bestaat uit het uitvoeren van instructies door de CPU, waarna op een gegeven moment een bepaalde vorm van I/O wordt ge¨ınitieerd. Dit kan o.a. een lees- of schrijfoperatie naar het secundair geheugen zijn of het uitvoeren van een netwerkoperatie. Gedurende deze I/O-burst moet de thread wachten tot de I/O-bewerking afgelopen is. In een systeem zonder multitasking is de CPU op dat moment idle en wordt er geen enkele instructie uitgevoerd. Indien we de geheugenhi¨erarchie van dichtbij bekijken zien we dat een I/O-bewerking overeenkomt met toegang tot informatie die zich niet in de registers, geheugencaches of het primair geheugen bevindt. Zoals figuur 5.1 aantoont, neemt de toegangstijd in het geval van een I/Obewerking toe met minimum 4 grootteordes in vergelijking met de toegangstijd naar het primair geheugen. Het is duidelijk dat een model zonder multiprogramming, waarbij de CPU idle is gedurende een I/O-bewerking, een enorme verspilling van resources vormt. Multithreading laat het concept van multitasking toe door afwisselend verschillende threads uit te voeren op de CPU. Het doel van multitasking is om de CPU zo effici¨ent mogelijk te gebruiken door deze zo veel mogelijk bewerkingen te laten uitvoeren per tijdseenheid. Daarom zal een nieuwe thread toegewezen worden aan de CPU, in het geval van een I/O-bewerking. Indien we de verschillende states van een thread bekijken (zie figuur 5.2), zal de thread die aan I/O doet zich verplaatsen van de running-state naar de waiting-state. Wanneer de I/O-bewerking
5.1 Concurrency
49
Figuur 5.1: De geheugenhi¨erarchie
voltooid is, zal de thread zich naar de ready-state begeven en wordt daarbij op de ready queue geplaatst. De CPU-scheduler zal de thread vervolgens weer een bepaalde CPU-verwerkingstijd geven door deze naar de running-state te brengen a.d.h.v. een bepaald scheduling-algoritme.
Figuur 5.2: De verschillende states van een thread
I/O-bewerkingen zijn niet de enige redenen om een thread weg te halen van de running-state. Een interrupt kan ervoor zorgen dat een thread die zich reeds lang in de running-state bevindt, vervangen wordt door een andere thread, zodat deze ook de mogelijkheid krijgt om progressie te boeken. Deze vorm van scheduling is o.a. nodig voor een taak die CPU-gebonden is. CPUgebonden wil zeggen dat de uitvoering van de taak meer CPU-tijd vergt dan tijd om I/Obewerkingen uit te voeren. Een taak die I/O-gebonden is, zal daarentegen meer tijd spenderen aan de uitvoering van I/O-bewerkingen dan CPU-tijd. Een CPU-gebonden taak zou zonder deze vorm van scheduling in de running-state blijven tot hij een I/O-operatie initieert. Dit heeft als gevolg dat andere threads gedurende een lange tijd geen kans krijgen om vooruitgang te boeken, wat leidt tot starvation. Multitasking waarbij het besturingssysteem het initiatief neemt om de verschillende threads afwisselend enige CPU-verwerkingstijd te geven, wordt pre¨emptieve multitasking genoemd en dit
5.1 Concurrency
50
is het meest gebruikte model van multithreading op een single-core processor. Multithreading in aanwezigheid van een multi-core processor laat parallellisme toe door de verschillende threads op een aparte core uit te voeren. Hierbij is het aantal threads in de running-state nooit groter dan het aantal cores. We kunnen dus besluiten dat multithreading een vorm van concurrency is met daarbij ondersteuning voor parallellisme. Concurrency helpt ons met de simultane behandeling van een verzameling requests, waarbij parallellisme de sleutel is voor horizontale schaalbaarheid. Aangezien een thread blokkeert in het geval van een I/O-bewerking kunnen we gebruikmaken van een synchroon programmeermodel. Dit is de meest intu¨ıtieve vorm van programmeren, waarbij de volgende regel code pas uitgevoerd wordt wanneer de I/O-bewerking voltooid is. Multithreading lijkt dus op het eerste gezicht een goede keuze voor onze use case. Er zijn echter ook enkele belangrijke nadelen verbonden aan het gebruik van multithreading. De meest gebruikte vorm van multithreading maakt namelijk gebruik van pre¨emptive multitasking, waardoor de uitvoering van de bijhorende code een niet-deterministisch karakter krijgt. Het besturingssysteem kan immers op gelijk welk ogenblik beslissen om de uitvoering van een thread te onderbreken en over te schakelen op een andere thread. Dit zorgt voor problemen indien een programma nood heeft aan een gedeelde en wijzigbare state, wat zeker het geval is bij onze use case. Bepaalde operaties die worden uitgevoerd door de code zijn impliciet atomair en een onderbreking van zo’n atomaire operatie kan leiden tot foutieve resultaten indien de state gedurende deze onderbreking wordt gewijzigd door een andere thread. Het deel van de code die een atomaire operatie beschrijft, wordt ook wel de critical section genoemd. Oplossingen voor het critical section probleem bestaan uit o.a. het gebruik van synchronisatiemechanismen a.d.h.v. een lock. Deze vorm van multithreaded programmeren is echter heel foutgevoelig aangezien het niet-deterministische karakter van de code veel verschillende uitvoeringsvolgordes toelaat die niet allemaal een geldig resultaat opleveren. Een tweede probleem ontstaat door de overhead die threads met zich meebrengen. We willen namelijk het aantal gelijktijdige verbindingen met onze applicatieservers maximaliseren, waardoor we nood hebben aan een groot aantal threads. Iedere thread heeft echter nood aan bepaalde hoeveelheid geheugen en het wisselen tussen de verschillende threads vereist ook telkens een context-switch met de bijhorende overhead. Om die redenen blijken threads niet de meest schaalbare oplossing in de praktijk.
5.1 Concurrency
5.1.2
51
Event-driven
Met het event-driven concurrency model gaan we op zoek naar een oplossing voor de twee problemen van multithreading die hiervoor vermeld werden. Het grootste probleem is de foutgevoeligheid van de niet-deterministische uitvoeringsvolgorde van een programma in aanwezigheid van een gedeelde en wijzigbare state. Het event-driven model lost dit op door gebruik te maken van een single-threaded model. Daardoor verliezen we op het eerste gezicht de voordelen van multithreading, namelijk concurrency en parallellisme. Een eerste poging om het probleem van concurrency op te lossen bestaat uit een sequenti¨ele behandeling van alle requests. Zonder multithreading hebben we echter geen ondersteuning voor multitasking, waardoor de CPU idle is gedurende iedere I/O-bewerking. Dit vormt zoals eerder al vermeld een enorme verspilling van resources. De oplossing is om de thread niet langer te blokkeren in het geval van een I/O-request en dus over te schakelen op een non-blocking I/O-model. Dit model geeft een thread de kans om na het initi¨eren van een I/O-request direct verder te gaan met de verwerking van de volgende instructie. Na de aanroep van een I/Osubroutine zal de processor zijn werk verderzetten met de uitvoering van de volgende regel code. Aangezien de I/O-bewerking nog niet voltooid is, kan deze subroutine nog geen resultaat teruggeven en worden we op de hoogte gebracht via een asynchroon mechanisme zoals bv. een event; vandaar het begrip event-driven concurrency. Asynchroon programmeren leidt al snel tot moeilijk leesbare code en dit is dan ook een veelgebruik argument tegen het event-driven model. In hoofdstuk 7 zullen we echter zien dat asynchrone code heel krachtig en leesbaar kan zijn met behulp van de juiste abstractie. In het event-driven model worden de binnenkomende requests op een event gemapt i.p.v. een thread en de verwerking van deze events gebeurt sequentieel op ´e´en thread. Hierbij wordt gebruikgemaakt van een event-loop die ´e´en voor ´e´en de events zal verwerken die zich op de event-queue bevinden. Een binnenkomende HTTP-request of TCP-connectie resulteert in een event dat wordt toegevoegd aan de event-queue. De event-loop haalt dit event van de queue en start daarbij de uitvoering van de geregistreerde event-handlers. Wanneer er een I/O-bewerking plaatsvindt tijdens de verwerking van een event-handler, zal de thread niet blokkeren en direct doorgaan met het uitvoeren van de volgende regel code. Wanneer alle code van een event is uitgevoerd, zal de event-loop verdergaan met de verwerking van het volgende event in de event-queue. Een nieuw event zal toegevoegd worden aan de event-queue op het ogenblik dat de eerder vernoemde I/O-bewerking voltooid is waardoor de event-loop de verwerking van de eerdere request kan verderzetten door de bijhorende event-handler aan te roepen. Het event-driven model introduceert dus een vorm van multitasking waarbij het einde van een event-handler conceptueel overeenkomt met het verwisselen van de running-thread in het mul-
5.1 Concurrency
52
tithreaded model. Het grote voordeel van het event-driven model is dat er geen pre¨emptie kan optreden tijdens de uitvoering van een aangesloten blok code aangezien de verschillende event-handlers sequentieel worden behandeld door de single-threaded event-loop. Dit wordt co¨ operatieve multitasking genoemd waarbij we in tegenstelling tot pre¨emptieve multitasking zelf bepalen wanneer de verwerking van een request kan onderbroken worden. De plaatsen waar de verwerking van een request onderbroken wordt, komen dan ook meestal overeen met de plaatsen waar een I/O-request ge¨ınitieerd wordt, aangezien deze plaatsen in het algemeen het einde van een event-handler aanduiden. Dit model blijkt goed te werken in aanwezigheid van I/O-gebonden taken en het is dan ook geschikt voor webapplicaties aangezien deze meestal I/O-gebonden zijn. Deze eigenschap geldt ook voor onze use case. Indien een taak echter CPU-gebonden is, zal deze weinig I/O-requests genereren en veel rekenwerk vergen. Tijdens de uitvoering van dit rekenwerk krijgen andere taken in het event-driven model geen kans om vooruitgang te boeken, zolang er geen I/O-request gegenereerd wordt binnen de actieve taak. Omwille van die reden wordt het event-driven model minder geschikt geacht voor CPU-gebonden taken aangezien dit kan leiden tot starvation van de andere taken. Een CPU-gebonden taak kan echter opgesplitst worden in meerdere deeltaken die dan kunnen ondergebracht worden in asynchrone blokken code. Deze asynchrone blokken worden dan achteraan toegevoegd aan de event-queue zodat andere taken de kans krijgen om verdergezet te worden. De programmeur staat dus zelf in voor de multitasking, waarbij een thread-based model gebruikt maakt van de diensten van de thread-implementatie. Een andere oplossing is het gebruik van aparte worker-threads en via een asynchroon message-passing mechanisme het CPU-gebonden werk hierop uitvoeren. Aangezien deze worker-threads geen gezamenlijk geheugen delen, doet het probleem van de gedeelde wijzigbare state zich ook niet voor. Zo’n model wordt o.a. in de browser aangeboden met de komst van Web Workers [48] binnen de HTML5-specificatie. Het event-driven model biedt ons concurrency met de garantie dat er geen pre¨emptie optreedt gedurende de uitvoering van een aaneengesloten blok code. Dit brengt het gevaar met zich mee dat CPU-gebonden taken kunnen leiden tot starvation van de andere taken. Door de I/Obewerkingen asynchroon te behandelen, biedt dit model ondersteuning voor I/O-parallellisme waarbij meerdere I/O-requests simultaan kunnen worden verwerkt. Er is echter geen ondersteuning voor CPU-parallellisme aangezien de event-loop single-threaded is. Dit heeft als gevolg dat we in het geval van een multi-core processor slechts ´e´en core benutten. Dit wordt opgelost door hetzelfde aantal processen als processorcores op te starten, waarbij ieder proces beschikt over een single-threaded event-loop. Het heeft geen zin om meer processen op te starten aangezien het event-driven model dankzij de co¨operatieve multitasking zal streven naar een maximale belasting van de toegewezen processorcore.
5.2 Node.js
5.1.3
53
Metafoor van het restaurant
Om meer inzicht te verkrijgen tussen de twee verschillende concurrency-modellen gebruikt de literatuur soms ook de metafoor van het restaurant. Een restaurant heeft meerdere klanten, waarbij iedere klant moet bediend worden. De bediening van een klant komt overeen met de verwerking van een request. In het multithreaded model beschikt iedere klant over een persoonlijke ober. De ober zal de bestelling opnemen van de klant en geeft deze door aan de keuken. Het doorgeven van de bestelling aan de keuken stelt een I/O-request voor. Vervolgens zal de ober wachten tot de keuken klaar is met de bereiding van de bestelling om deze vervolgens door te geven aan de klant. Dit komt conceptueel overeen met de voltooiing van een I/O-request. Het valt hierbij op dat de ober meestal niets doet en dit is dan ook niet het model dat gebruikt wordt in een echt restaurant. Volgens het event-driven model beschikt het restaurant over slechts ´e´en ober en deze zal ook starten met het opnemen van de bestelling van een klant en ze doorgeven aan de keuken. In plaats van te wachten tot de bestelling klaar is, zal de ober vervolgens naar een volgende tafel gaan en daar ook de bestelling opnemen. Dit geeft een duidelijke overeenkomst met het eventdriven model, waarbij een I/O-request (het doorgeven van een bestelling aan de keuken) er niet voor zorgt dat de ober in een geblokkeerde toestand terecht komt. Wanneer de ober het belletje van de keuken hoort, weet hij dat de bestelling klaar is, zodat hij deze bij de juiste tafel kan bezorgen. Het belletje van de keuken is een event uit het event-driven model en is een manier om de ober asynchroon op de hoogte te brengen. Het event-driven model heeft slechts nood aan ´e´en ober die voortdurend bezig is, zolang er werk is. Het multithreaded model heeft veel obers die meestal niets doen. Deze obers lopen elkaar ook voortdurend in de weg, wat overeenkomt met de overhead die multithreading met zich mee brengt.
5.2
Node.js
In de vorige paragraaf hebben we de twee meest voorkomende concurrency-modellen besproken, waarna we hebben besloten dat het event-driven model het meest geschikt is voor onze use case. Onze applicatieservers hebben dus nood aan webtechnologie die ondersteuning biedt voor het single-threaded event-driven model. De meeste hedendaagse webtechnologie¨en bieden standaard ondersteuning voor het multithreaded model, met soms ook bibliotheken voor het event-driven model. Het gevaar hiervan is dat de performantie van een single-threaded event-driven webserver dramatisch afneemt van zodra er amper ´e´en blocking functie wordt aangeroepen. Daarom gaan we op zoek naar webtechnologie die standaard ontworpen is volgens het asynchrone model, zoals Node.js [30].
5.2 Node.js
54
Node.js is een server-side platform om webtoepassingen te bouwen volgens het event-driven concurrency model. Applicaties in Node.js worden geschreven in JavaScript aangezien het platform gebouwd is bovenop de JavaScript-V8-engine van Google. Deze engine staat bekend om zijn uitstekende performantie en bewijst ook zijn diensten in de Chrome-browser. De keuze voor JavaScript lijkt op het eerste gezicht controversieel aangezien deze taal niet ontworpen is voor grootschalige server-side projecten. Oplossingen voor de tekortkomingen van JavaScript worden besproken in het volgende hoofdstuk, maar de taal biedt zeker enkele interessante mogelijkheden als basis voor een webframework. Ten eerste was er bij het ontstaan van Node.js in 2009 geen ander server-side framework dat gebruikmaakte van JavaScript. Dit was een belangrijke vereiste om een framework te ontwerpen waarbij alle API’s standaard asynchroon en non-blocking zijn. Een programmeur kan niet per ongeluk gebruikmaken van een blocking API die de event-loop blokkeert, aangezien zo’n API niet bestaat. Ten tweede is JavaScript een taal die beheerst wordt door iedere webdeveloper omdat er aan de client-side geen andere keuze is. Webdevelopers kunnen deze kennis nu ook gebruiken aan de server-side en hoeven geen extra server-side programmeertaal aan te leren. Ten derde leent JavaScript zich perfect tot asynchrone code door de aanwezigheid van first-class functies zoals verder besproken zal worden in hoofdstuk 7. JavaScript in de browser maakt daarbij trouwens ook gebruik van het event-driven model via een single-threaded event-loop1 , dus deze programmeerstijl voelt natuurlijk aan voor JavaScript-developers. Node.js levert alle nodige API’s om webapplicaties te bouwen waarbij deze ingeladen kunnen worden volgens de CommonJS-standaard via de require() functie die in §6.2.3 besproken wordt. Het eerste voorbeeld dat vermeld wordt op de website van Node.js [30] bestaat uit een triviale HTTP-server die antwoordt met 'Hello world' op iedere HTTP-request. Een voorbeeld is te zien in figuur 5.1. Daarbij zien we dat iedere HTTP-request wordt gemapt op een request event waarbij de functie handler() wordt aangeroepen om het event af te handelen. 1 // 1. We vragen een referentie op van de HTTP - bibliotheek . 2 var http = require ( ' http '); 3 4 // 2. We maken een server aan die luistert op poort 80 5 // voor inkomende HTTP - requests . 6 var server = createServer (). listen (80); 7 8 // 3. Iedere HTTP - request wordt beantwoord met de statuscode 200 1
In nagenoeg alle UI-frameworks van eender welk programmeerplatform wordt gebruikgemaakt van een single-
threaded event-loop. Toegang tot de UI via meerdere threads zou immers heel foutgevoelig zijn om dezelfde redenen die in dit hoofdstuk reeds gegeven zijn.
5.2 Node.js
55
9 // en de body ' Hello world '. 10 server . on ( ' request ', function handler ( request , response ) { 11
response . writeHead (200 , { ' Content - Type ': ' text / plain ' });
12
response . end ( ' Hello world ' );
13 }); Codevoorbeeld 5.1: Node.js HTTP-API Op analoge manier is het ook mogelijk om een TCP-server aan te maken die ons toelaat om te werken op de transportlaag met behulp van sockets. Node.js biedt enkele globale API’s en klassen aan die overal beschikbaar zijn, zoals de Buffer klasse die het mogelijk maakt om met binaire data te werken in JavaScript. Via setTimeout(), setInterval() en setImmediate() is het mogelijk om functies ´e´en of meerdere keren uit te voeren op een later tijdstip. De globale variabele process geeft dan weer meer informatie over het huidige Node.js-proces. Het eventdriven concurrency model faalt in de aanwezigheid van blocking I/O; vandaar dat alle API’s in Node.js standaard non-blocking zijn met een asynchroon programmeermodel. Sommige nonblocking API’s hebben echter ook een synchrone blocking versie die duidelijk wordt aangegeven met het suffix Sync. Het gebruik van synchrone API’s is in het algemeen enkel gerechtvaardigd bij het opstarten van een Node.js-applicatie, vooraleer de event-loop in werking treedt. De volledige API die Node.js aanbiedt kan geraadpleegd worden via [31]. Node.js wordt ook standaard ge¨ınstalleerd met npm, een package manager voor Node.js. Op de website npmjs.org kunnen daarbij alle packages geraadpleegd worden. We besluiten dit deel met de EventEmitter API die de basis vormt van event-handling binnen Node.js. In codevoorbeeld 5.2 is te zien hoe we eerst een instantie aanmaken van de EventEmitter klasse. Via de on() methode is het mogelijk om nieuwe event-handlers toe te voegen. Event-handlers kunnen verwijderd worden via de methodes removeListener() en removeAllListeners(). Het triggeren van een event gebeurt via de emit() methode. Daarnaast zijn er nog enkele methodes beschikbaar die geraadpleegd kunnen worden in de documentatie van Node.js. De EventEmitter klasse leent zich uitstekend tot overerving zodat nieuwe klassen kunnen aangemaakt worden die events triggeren. Zo is ook het server object uit codevoorbeeld 5.1 een EventEmitter. 1 var events = require ( ' events '); 2 3 var emitter = new events . EventEmitter (); 4 5 // We schrijven ons in op het event met als naam ' eventName '. 6 7
emitter . on ( ' eventName ' , function handler ( data ) { // Deze event - handler wordt aangeroepen
5.3 Synopsis
56
// voor ieder event van het type ' eventName '.
8 9 }); 10
11 // We verwijderen alle event - handlers van het type ' anotherEvent '. 12
emitter . removeAllListeners ( ' anotherEvent ');
13 14 // We vuren een event af van het type ' eventName ' 15 // met de bijhorende data . 16
emitter . emit ( ' eventName ' , ' Hello world '); Codevoorbeeld 5.2: Node.js EventEmitter-API
5.3
Synopsis Wat is de meest geschikte webserver-architectuur om tweewegscommunicatie te realiseren voor een groot aantal clients?
Deze onderzoeksvraag lag aan de basis van dit hoofdstuk waarin we de twee meest voorkomende architecturen hebben onderzocht. Deze architecturen onderscheiden zich van elkaar door elk een andere invulling te geven aan het concurrency-model. Een thread-based webserver mapt iedere inkomende verbinding op een aparte thread. Multithreading maakt concurrency mogelijk door gebruik te maken van pre¨emptieve multitasking en biedt daarbij ondersteuning voor CPUen I/O-parallellisme. Een I/O-request blokkeert de actieve thread waardoor deze synchroon kan behandeld worden. Door het pre¨emptieve karakter van threads krijgen we echter een heel foutgevoelig programmeermodel in het geval van een gedeelde en wijzigbare state. Ook is het thread-based model minder aan te raden in de aanwezigheid van heel veel verbindingen aangezien de overhead van alle overeenkomstige threads dan te groot wordt. Het event-driven model doet aan co¨ operatieve multitasking dankzij een single-threaded eventloop, waarbij de afloop van een event-handler bepaalt wanneer de verwerking van een request wordt onderbroken. Het vermijdt dus de twee voornaamste nadelen van het multithreaded model, maar het introduceert daarbij moeilijk leesbare asynchrone code aangezien iedere vorm van I/O non-blocking is. Dit probleem wordt in hoofdstuk 7 verder aangepakt door het gebruik van een goede abstractie voor asynchroon programmeren. Dit model vereist ook voorzichtigheid voor starvation in de aanwezigheid van CPU-gebonden taken. Dit probleem kan opgelost worden door deze taken op te splitsen in deeltaken die achteraan de event-queue geplaatst worden of door gebruik te maken van worker-threads. Onze use case en webapplicaties zijn in het algemeen echter I/O-gebonden, waardoor dit probleem niet optreedt.
5.3 Synopsis
57
Nadat we de keuze maakten voor het event-driven model gingen we op zoek naar een implementatie en daarbij kwamen we bij Node.js terecht. Deze technologie maakt gebruik van JavaScript en de snelle V8-engine van Google om webapplicaties te bouwen. De keuze voor JavaScript lijkt controversieel aangezien deze taal niet ontworpen is voor grootschalige server-side projecten. JavaScript biedt echter enkele belangrijke voordelen aangezien de JavaScript-runtime in de browser hetzelfde event-driven model aanbiedt als dat van Node.js. Verder kan de JavaScriptkennis van client-side webdevelopment nu ook ingezet worden aan de server-side. Hoe we de taal nu ook bruikbaar kunnen maken om grootschalige projecten aan te pakken, wordt besproken in het volgende hoofdstuk.
PROFESSIONEEL JAVASCRIPT
58
Hoofdstuk 6
Professioneel JavaScript We hebben de keuze gemaakt voor Node.js als webtechnologie voor onze applicatieservers waarbij gebruik wordt gemaakt van de programmeertaal JavaScript. Aangezien onze client-side modules ook in deze taal worden gedefinieerd, zal onze volledige software-stack gebruikmaken van JavaScript. Dit vereist een grondig onderzoek van de taal en zijn concepten, waarbij we vooral de aandacht willen vestigen op het gebruik van JavaScript binnen grootschalige projecten. In §6.1 starten we met een overzicht van de taal en de concepten die van belang zijn voor het verdere verloop van dit hoofdstuk en masterproef. Daarna gaan we verder in §6.2 met een bespreking van de verschillende technieken om modulariteit toe te voegen aan een JavaScript-project. §6.3 vormt de grootste brok van dit hoofdstuk en laat ons kennis maken met zowel objectgeori¨enteerd als functioneel JavaScript. Nadat deze concepten zijn doorgedrongen, verleggen we de focus naar de kwaliteitscontrole van JavaScript-code a.d.h.v. linting en unit-testing met de bijhorende frameworks. In §6.5 besluiten we met een overzicht van Grunt als build-tool om de verschillende functionaliteiten van onze modules te verzamelen in een kwalitatieve build.
6.1
Overzicht
JavaScript is gestandaardiseerd door de organisatie Ecma1 International en de offici¨ele standaard van de taal heet ECMAScript. In deze masterproef spreken we enkel over ECMAScript als we een specifieke versie van de standaard willen aanduiden. De huidige standaard is ECMAScript 5.1 (ES5) en wordt door Node.js en alle moderne browsers ondersteund. ES5 is daarbij backwards compatible met de vorige versie van de standaard, ECMAScript 3 (ES3). Oudere browsers leveren enkel ondersteuning voor ES3, net zoals het Zentrick-platform om redenen die eerder in hoofdstuk 1 vermeld werden. De volgende versie van ECMAScript draagt voorlopig de codenaam Harmony maar zal naar alle waarschijnlijk uitmonden in ECMAScript 6 (ES6). In deze masterproef zal dan ook vooral de term ES6 gebruikt worden, aangezien die duidelijk aangeeft dat het om de 1
European Computer Manufacturers Association
6.1 Overzicht
59
versie gaat die na ES5 komt. Een eerste versie van JavaScript werd in 1995 ontworpen door Brendan Eich in amper tien dagen met het doel om interactiviteit toe te voegen aan HTML-pagina’s. Dit heeft als gevolg dat de taal enkele cruciale concepten mist om grootschalige projecten aan te pakken zoals bv. het gebrek aan enige vorm van modulariteit. Daarnaast bevat de taal ook een aantal eigenschappen en constructies die aanleiding kunnen geven tot slechte code. Douglas Crockford2 schreef daarover het boek JavaScript: The Good Parts [61] waarbij hij deze bad parts ging isoleren om zo tot een subset van JavaScript te komen die the good parts werd gedoopt. In dit hoofdstuk pakken we de tekortkomingen ´e´en voor ´e´en aan zodat we op het einde een goede basis hebben om op verder te bouwen. Daarbij worden zowel de huidige oplossingen bekeken die rekening moeten houden met de tekortkomingen van ES5, als de toekomstige oplossingen die ingebouwd worden in de taal zelf dankzij ES6. Bij de implementatie van het project zal er verder zoveel mogelijk getracht worden om enkel gebruik te maken van de ‘goede’ subset van JavaScript. JavaScript is een taal die enkele concepten heeft overgenomen van andere reeds bestaande programmeertalen. Het concept van first-class functies heeft het ge¨erfd van Lisp, overerving a.d.h.v. prototypes is een eigenschap afkomstig uit de progammeertaal Self. JavaScript biedt ook een goede ondersteuning voor reguliere expressies, waarbij het idee¨en heeft overgenomen van Perl. En qua syntax lijkt het dan weer op Java, dat zijn syntax op zijn beurt heeft geleend van C. Daarnaast is JavaScript een dynamisch getypeerde taal wat wil zeggen dat het type van een variabele niet vastgelegd wordt at compile-time. Het type van een variabele wordt pas bepaald at runtime en kan gedurende de levensloop van een programma veranderen. Door de afwezigheid van deze type-informatie krijgen we meer compacte code in vergelijking met statisch getypeerde talen. Daarvoor verliezen we uiteraard type-safety en zullen type-errors pas at runtime aan het licht komen. We overlopen in dit overzicht kort de belangrijkste elementen van JavaScript die van belang zijn in het kader van deze masterproef.
6.1.1
Primitieve types en object types
De types die JavaScript aanbiedt kunnen in twee grote categorie¨en onderverdeeld worden, namelijk de primitieve types en de object types. Primitieve types zijn daarbij value types en object types zijn reference types. Een voorbeeld van de types die besproken worden, is te vinden in codevoorbeeld 6.1. De primitieve types worden gevormd door het number, string en boolean type. Het string type en het boolean type spreken voor zich en het number type is de enige mogelijkheid in JavaScript om te werken met getallen. Er wordt geen onderscheid gemaakt tussen een integer en een float type; alle getallen worden namelijk voorgesteld a.d.h.v. een 2
Douglas Crockford is ook tevens de bedenker van het dataformaat JSON, gebaseerd op de JavaScript-syntax
van objecten.
6.1 Overzicht
60
64-bit floating point getal via het number type. Dit heeft als gevolg dat alle gehele getallen exact kunnen voorgesteld worden vanaf −253 t.e.m. 253 . Aangezien JavaScript dynamisch getypeerd is, wordt het type niet geassocieerd met de variabele zelf maar met de waarde van de variabele. Daarnaast zijn er ook nog de speciale primitieve waarden null en het undefined om de afwezigheid van een waarde aan te duiden, maar zij zijn geen instantie van een bepaald type. 1 // Primitieve types 2 var getal = 1;
// number
3 var tekst = ' Hello world ';
// string
4 var flag = true ;
// boolean
5 6 // Object types 7 var obj = { 8
een : 1 ,
9
twee : ' twee ' ,
10
drie : true ,
11
vier : []
// object
12 }; 13 14 var collection = [1 , ' twee ', true ];
// array
15 var regex = /ˆ hello world$ /;
// regex
Codevoorbeeld 6.1: Types in JavaScript Alle andere waardes in JavaScript zijn objecten waarbij een object eigenlijk een hash is waarop dynamisch verschillende property’s kunnen toegevoegd en verwijderd worden. Objecten kunnen aangemaakt worden via een literal syntax, die toelaat om op een compacte manier nieuwe objecten te defini¨eren. Deze object literal syntax vormt ook de basis van het JSON-dataformaat. Daarnaast kunnen objecten ook aangemaakt worden a.d.h.v. een constructor functie; dit wordt verder besproken in dit hoofdstuk in de paragraaf over objectgeori¨enteerd JavaScript. In dit deel zal ook het overervingsmechanisme van JavaScript besproken worden, dat gebaseerd is op prototypes i.p.v. klassen zoals bij klassieke objectgeori¨enteerde talen. Twee voorbeelden van het object type zijn arrays en reguliere expressies. Een array laat toe om een collectie van primitieve waarden en/of objecten te groeperen en kan ge¨ınstantieerd worden via de Array constructor of via de literal syntax. Op analoge manier kunnen ook reguliere expressies gedefinieerd worden a.d.h.v. de RegExp constructor of via de literal syntax gelijkaardig aan die van Perl. Als laatste is er nog ´e´en speciaal object type dat speciale aandacht verdient, de functie. Een functie is een object dat daarnaast ook beschikt over uitvoerbare code. Een functie kan daarbij
6.1 Overzicht
61
aangeroepen worden met ´e´en of meerdere argumenten, waardoor de geassocieerde code wordt uitgevoerd. Als een functie optreedt als property van een object, wordt dit een methode genoemd. In de body van een methode is het mogelijk om via het this keyword de andere property’s van het object op te vragen en te wijzigen. De behandeling van functies als data in de vorm van objecten vormt een andere benadering in vergelijking met het functie-concept van klassieke imperatieve talen. Het geeft echter wel aanleiding tot krachtige abstracties die ons zullen helpen bij het schrijven van asynchrone code.
6.1.2
Functies
Een functie in JavaScript is first-class en kan net zoals primitieve types en objecten meegeveven worden als argument van een andere functie, dienen als teruggeefwaarde van een functie, of toegewezen worden aan een willekeurige variabele [53]. Dit concept laat toe om bepaalde idee¨en uit het functioneel programmeren ook toe te passen binnen JavaScript. Functioneel programmeren biedt in het algemeen meer uitdrukkingskracht met minder code en wordt verderop in dit hoofdstuk besproken. Aangezien functies first-class zijn, kunnen ze ook genest worden in andere functies, wat leidt tot enkele krachtige eigenschappen. In JavaScript wordt de scope van een variabele bepaald door de functie waarbinnen ze gedeclareerd wordt. Een variabele die buiten een functie gedeclareerd wordt, bevindt zich in de global scope en is overal zichtbaar binnen een programma. Variabelen in de global scope zijn ook bereikbaar als property’s van het global object. In de browseromgeving en het Zentrick-platform is dit bv. het window object waarbij dit in Node.js dan weer het global object is. Op regel 1 in codevoorbeeld 6.2 wordt op die manier een globale variabele aangemaakt. Een functie cre¨eert een local scope en iedere variabele die in een functie gedeclareerd wordt, is enkel zichtbaar binnen de functie zelf en eventueel geneste functies. Deze manier van scoping wordt lexical scoping of static scoping genoemd, aangezien de lexicale structuur van het programma de scope van een variabele bepaald. De functie checkScope() maakt twee lokale variabelen aan met de namen globaleVariabele en lokaleVariabele. Aangezien de lokale variabele op regel 4 dezelfde naam heeft als de globale variabele op regel 1, wordt deze laatste onzichtbaar binnen de scope van de functie checkScope(). Het aanroepen van de functie op regel 15 zorgt er dan ook voor dat de lokale variabele wordt afgedrukt als gevolg van functie-aanroep op regel 7, en we krijgen dus de waarde 2 van de lokale variabele. Op regel 16 wordt dan de globale variabele afgedrukt om aan te tonen dat deze niet gewijzigd werd door de functie checkScope(). 1 var globaleVariabele = 1; 2
6.1 Overzicht
3
62
function checkScope () {
4
var globaleVariabele = 2;
5
var lokaleVariabele = 3;
6 console . log ( globaleVariabele );
7 8
return function nestedFunction () {
9 10
console . log ( lokaleVariabele );
11
lokaleVariabele ++; };
12 13 } 14
15 var result = checkScope (); 16
console . log ( globaleVariabele );
// Uitvoer : 2. // Uitvoer : 1.
17 result ();
// Uitvoer : 3.
18 result ();
// Uitvoer : 4. Codevoorbeeld 6.2: Functies in JavaScript
Het wordt echter interessanter wanneer we zien dat de functie checkScope() als resultaat een instantie van de functie nestedFunction() teruggeeft. Dit is enkel mogelijk omdat functies first-class zijn binnen JavaScript waardoor we ze kunnen nesten binnen andere functies en ook kunnen doorgeven via een variabele zoals dit ook mogelijk is met andere datatypes. Op regel 17 roepen we deze functie voor de eerste keer aan waarbij we op regel 10 zien dat de waarde van de variabele lokaleVariabele afgedrukt wordt. Maar aangezien de functie checkScope() reeds afgelopen is, zouden we op het eerste gezicht concluderen dat de variabele lokaleVariabele reeds verdwenen is van de stack en dus niet meer bestaat in het geheugen. Hoe is het dan mogelijk dat deze afgedrukt wordt? Het antwoord op deze vraag vinden we terug in het concept van een closure. Closures Iedere functie in JavaScript heeft een scope-chain met zich geassocieerd die bestaat uit verschillende aan elkaar gelinkte scope-objecten. Een scope-object kunnen we conceptueel beschouwen als een object met referenties voor alle variabelen die gedeclareerd worden in de overeenkomstige scope. Wanneer de waarde van een variabele die gebruikt wordt in een functie opgezocht moet worden, wordt er gestart met het eerste object in de scope-chain, namelijk het scope-object van de functie zelf. In het geval van de functie nestedFunction() zal die op zoek gaan naar een referentie voor lokaleVariabele in zijn eigen scope-object. Het scope-object van nestedFunction()
6.2 Modulariteit
63
is echter leeg aangezien er geen enkele variabele gedeclareerd wordt binnen deze functie. Daarom gaat men vervolgens naar het volgende object in de scope-chain; dit is het scope-object van checkScope(). Daar wordt wel een referentie gevonden voor lokaleVariabele waardoor men deze waarde kan gebruiken om vervolgens af te drukken. Dit proces wordt variable resolution genoemd. Nu rest nog de vraag hoe deze scope-chain juist wordt opgebouwd. Op het ogenblik dat een functie ge¨ınstantieerd wordt, zal hij de scope-chain opslaan die op dat moment van toepassing is. Deze scope-chain zal bestaan uit de scope-objecten van de functies waarbinnen de functie genest is. Een instantie van de functie nestedFunction() wordt aangemaakt op regel 9 tijdens de uitvoering van de functie checkScope(). Samen met deze instantie wordt ook de scope-chain opgeslagen die bestaat uit de scope-objecten van de instantie van checkScope() en het global scope-object. Bij het aanroepen van nestedFunction() op regel 17 wordt de scope-chain verlengd met een nieuw scope-object dat alle referenties bijhoudt voor variabelen die gedeclareerd worden binnen deze aanroep van de functie. In het geval van nestedFunction() is dit scope-object leeg. De combinatie van een instantie van een functie en een bijhorende scope-chain met referenties naar niet-lokale variabelen, wordt in de literatuur een closure genoemd. Een closure laat toe om de state van een functie bij te houden nadat deze afgelopen is. Deze state is vervolgens zichtbaar voor de geneste functie (de closure) die later pas wordt aangeroepen. Dit zien we duidelijk op regel 18 wanneer nestedFunction() voor de tweede keer wordt aangeroepen, waaruit blijkt dat de functie-aanroep op regel 17 ervoor gezorgd heeft dat de waarde van lokaleVariabele met 1 is toegenomen. Het is deze ondersteuning voor closures die essentieel is voor de standaard manier van asynchroon programmeren binnen Node.js. Deze techniek wordt continuation passing style (CPS) genoemd en maakt gebruik van een callback om de continuation te defini¨eren nadat de I/O-request voltooid is.
6.2
Modulariteit
Modulariteit zorgt ervoor dat we verschillende functionaliteiten in de vorm van klassen of objecten kunnen bundelen tot ´e´en logisch geheel. Verschillende modules bieden a.d.h.v. klassen en objecten een interface aan, die toelaat om hun diensten aan te spreken. Een module heeft een dependency op een andere module indien ze gebruikmaakt van haar interface. Daarbij streven we net zoals bij het ontwerp van verschillende klassen voor een losse koppeling tussen de verschillende modules. Modules die los gekoppeld zijn van elkaar, laten immers veel gemakkelijker veranderingen toe zonder een verlies aan compatibiliteit met andere modules. Over het algemeen wordt het afgeraden of is het zelfs niet mogelijk om circulaire dependencies te hebben tussen verschillende modules. De verschillende dependency’s kunnen voorgesteld worden door
6.2 Modulariteit
64
een gerichte dependency graph waarbij de knopen overeenkomen met de modules en de verbindingen met de dependencies. Deze graaf is een directed acyclic graph (DAG) wanneer er zich geen circulaire dependency’s bevinden tussen de verschillende modules.
6.2.1
Module pattern
Iedere programmeertaal biedt op ´e´en of andere manier ondersteuning voor modules; zo biedt Java bv. het concept van een package aan. ES5 heeft standaard echter geen ondersteuning voor modulaire code en verschillende stukken code kunnen enkel elkaars interface benaderen door gebruik te maken van globale variabelen. Een browser zal tijdens het parsen van een HTMLdocument de inhoud van de <script> tags aan de JavaScript-engine doorgeven die ze dan zal uitvoeren. Alle variabelen in de global scope van het script vormen daarbij globale variabelen die zichtbaar zijn in de volgende scripts. Dit leidt uiteraard tot problemen wanneer verschillende modules los van elkaar globale variabelen aanmaken, aangezien een conflict qua naamgeving ervoor zorgt dat de eerste globale variabele overschreven wordt. Om dat probleem op te lossen zijn er verschillende oplossingen ontstaan voor clients-side JavaScript die modulariteit aanbieden. Een eerste triviale oplossing legt de basis voor meer geavanceerde oplossingen en wordt in de literatuur het module pattern genoemd [69]. In codevoorbeeld 6.3 wordt een mogelijke implementatie gegeven van het module pattern, dat hierna beschreven wordt. Ten eerste worden objecten en hun property’s gebruikt om verschillende namespaces aan te bieden. Aangezien modules in het algemeen ook nood hebben aan private functies, worden anonieme functies gebruikt om een local scope te cre¨eren waarbinnen deze private functies kunnen worden gedefinieerd. Om de interface publiek te maken wordt daarna het root-object van de namespace-hi¨erarchie zichtbaar gemaakt in de global scope. Dit zorgt ervoor dat iedere module slechts ´e´en globale variabele meer heeft waarlangs de volledige interface beschikbaar is. De mogelijkheid op conflicten van globale variabelen is daarmee echter nog steeds re¨eel. Ook moet een gebruiker zelf het dependency-management verzorgen door de verschillende modules in de juiste volgorde in te laden. Naarmate dat client-side JavaScript door de komst van o.a. SPWA’s aan belang toenam, zijn er nieuwe oplossingen gekomen die deze nadelen vermijden. 1 // Door gebruik te maken van een functie maken we een local scope aan . 2 var module = ( function (){ 3
var privateFunction = function (){ console . log ( ' Hello world ');
4 5
};
6 7 8
var root = { child1 : function () {
6.2 Modulariteit
65
// We kunnen de private functie aanroepen
9 10
// binnen een publieke functie .
11
privateFunction ();
12
},
13
child2 : {
14
// We kunnen een namespace aanmaken uit
15
// meerdere niveau 's diep .
16
grandchild : ' Hello world ' }
17 18
};
19 20
return root ;
21 }()); Codevoorbeeld 6.3: Module pattern
6.2.2
Asynchronous module definition
Het asynchronous module definition (AMD) formaat maakt met de define() functie ´e´en globale variabele aan. Om een nieuwe module aan te maken wordt gebruikgemaakt van deze functie waarbij er geen nieuwe globale variabele wordt ge¨ıntroduceerd. Zoals te zien in codevoorbeeld 6.4 verwacht de functie define() als argumenten een optionele module id, een optionele array met statische dependency’s en een verplichte factory function waarbinnen de module gedefinieerd wordt. De publieke interface die we willen aanbieden met de module wordt op het einde van de factory function teruggegeven en kan gelijk welk JavaScript-type zijn. 1 define ( ' moduleId ', [ ' foo ', ' bar '] , function ( foo , bar ){ 2
// Hierbinnen kan gebruikgemaakt worden van foo en bar .
3
foo . method ();
4
bar . anotherMethod ();
5 6
var module = { foo : function () {
7
return ' Hello world ';
8 }
9 10
};
11 12
// We geven het publieke deel van de module als teruggeefwaarde .
6.2 Modulariteit
66
return module ;
13 14 });
Codevoorbeeld 6.4: Statische dependency’s in AMD
AMD biedt daarnaast ook het bijkomende voordeel dat dependency’s dynamisch kunnen ingeladen worden. In dat geval dienen we niet op voorhand de dependency’s te declarareren a.d.h.v. een array, maar zal AMD een require() functie meegeven met de factory function. Deze functie laat dan zoals in codevoorbeeld 6.5 toe om enkel wanneer het nodig is bepaalde dependency’s dynamisch en asynchroon in te laden. Dankzij deze feature is AMD vooral populair in clientside JavaScript aangezien het mogelijk maakt om initieel niet alle modules te downloaden en zo bandbreedte te sparen. Om gebruik te maken van AMD in client-side JavaScript is er ook nog nood aan een script-loader zoals RequireJS [41]. Deze script-loader wordt dan als eerste ingeladen via een <script> tag binnen de webpagina waarna deze vervolgens de andere dependency’s zal inladen. Om de juiste inlaadvolgorde te bepalen kan de script-loader bv. gebruikmaken van topologisch rangschikken van de dependency graph indien deze een DAG is. 1 define ( ' moduleId ', function ( require ){ 2
// Dependency 's kunnen dynamisch ingeladen worden via require ().
3
require ([ ' foo ' , ' bar '] , function ( foo , bar ) {
4
// Hierbinnen kan gebruikgemaakt worden van foo en bar .
5
foo . method ();
6
bar . anotherMethod (); });
7 8
var module = ' Hello world ';
9
return module ;
10 11 });
Codevoorbeeld 6.5: Dynamische dependency’s in AMD AMD vermijdt pollution van de global scope door de introductie van de define() functie. Een bijhorende script-loader staat in voor het dependency-management, waardoor de belangrijkste nadelen van het module pattern weggewerkt zijn. Iedere module heeft nu wel een zekere syntactische overhead die de define() functie met zich meebrengt. Ook neemt de code al direct met ´e´en indentatieniveau toe, wat sneller problemen kan geven in bedrijven waar slechts een maximum aantal karakters per lijn worden toegelaten. Het voornaamste voordeel van AMD is de mogelijkheid om modules asynchroon in te laden, wat zeker een voordeel biedt in client-side
6.2 Modulariteit
67
omgevingen. Aan de server-side is er geen nood aan dynamische dependency’s en daar zien we dan ook vaak een ander formaat opduiken met CommonJS.
6.2.3
CommonJS
CommonJS is een groep waarbij men verschillende standaarden ontwikkeld voor het gebruik van JavaScript buiten een browser, waaronder de Modules standaard. We bespreken hier kort de Modules/1.0 standaard die ook gebruikt wordt in Node.js. Zoals te zien in codevoorbeeld 6.6 definieert CommonJS standaard twee variabelen die het mogelijk maken om een module te specifi¨eren. Ten eerste is er de require() functie die toelaat om een dependency in te laden. Deze is qua functionaliteit niet gelijk aan de require() functie van AMD. Ze laat immers enkel toe om dependency’s op een synchrone manier in te laden. Daarnaast wordt er ook nog een exports object beschikbaar gesteld waarmee de publieke interface van een module zichtbaar kan gemaakt worden. 1 // Dependency 's worden synchroon ingeladen . 2 var foo = require ( ' foo ') , bar = require ( ' bar ');
3 4
5 foo . method (); 6 bar . anotherMethod (); 7 8 // De publieke interface wordt beschikbaar gesteld 9 // via het exports object . 10
exports . module = { foo : function () {
11
return ' Hello world ';
12 }
13 14 };
Codevoorbeeld 6.6: CommonJS-module
Aangezien CommonJS enkel toelaat om dependency’s op een synchrone manier in te laden, lijkt dit op het eerste gezicht problematisch in combinatie met het asynchrone, non-blocking I/Omodel van Node.js. In hoofdstuk 5 hebben we echter vermeld dat het gebruik van synchrone API’s toegestaan is tijdens de opstartfase van een programma, aangezien de event-loop dan nog niet in werking is getreden. Omdat dependency’s typisch als eerste worden ingeladen, en niet in een event-handler, vormt deze synchrone API van CommonJS dus geen probleem. Verder valt het ook op dat de module in de CommonJS-standaard niet gewrapt wordt in een factory
6.2 Modulariteit
68
function. Aangezien private functies zich niet binnen de local scope van een functie bevinden, lijkt het alsof deze allemaal globaal zichtbaar zijn met mogelijke conflicten tot gevolg. Maar omdat we ons niet in de browser bevinden, kan de script loader eerst de module wrappen in een functie vooraleer ze aan de JavaScript-engine door te geven. In een browser is dit niet mogelijk aangezien de code rechtstreeks wordt uitgevoerd na het verwerken van de <script> tag. Door de afwezigheid van de define() functie binnen CommonJS, is er minder syntactische overhead en winnen we een indentatieniveau t.o.v. AMD. De interface van een module wordt publiek gemaakt door property’s toe te voegen aan het exports object. Dit heeft een nadeel wanneer we slechts ´e´en object willen publiek maken aangezien we toch nog steeds verplicht zijn om dit door te geven via een property van het exports object. Node.js lost dit op door naast het exports object ook een module object te voorzien waarbij men ´e´en enkel object publiek kan maken zonder daarbij een property te moeten voorzien zoals in dit voorbeeld: module.exports = {...}. Aangezien CommonJS de standaard is in Node.js, wordt hiervan dan ook gebruikgemaakt voor het defini¨eren van de server-side modules.
6.2.4
Zentrick
Als laatste bespreken we ook nog de Zentrick-modules, aangezien onze client-side modules deze standaard zullen volgen. Zentrick voorziet standaard een globale variabele zentrick binnen de Zentrick-omgeving die de root vormt van de volledige Zentrick-namespace. Via de load() functie van de modules namespace is het mogelijk om een nieuwe module in te laden zoals te zien in codevoorbeeld 6.7. Aan deze functie wordt een object meegegeven met een id die uniek is voor deze module, een versienummer en een naam die zichtbaar is voor gebruikers in de Zentrick Studio. Daarnaast is er ook nog een dependencies object waarmee de verschillende dependency’s kunnen aangegeven worden. En als laatste is er de load property waarmee een functie wordt geassocieerd die de code bevat van de module. 1
zentrick . modules . load ({
2
id : 'zntr - module ',
3
version : 1 ,
4
name : 'A Test Module ',
5
dependencies : {
6
' foo ': { id : ' zntr - foo ', version : 1 } ,
7
' bar ': { id : ' zntr - bar ', version : 1 }
8
},
9
load : function () {
10
// De dependency 's worden op het global object window
11
// geplaatst en zijn dus overal zichtbaar .
6.3 Paradigma’s
69
12
foo . method ();
13
bar . anotherMethod ();
14 15
// De publieke interface wordt beschikbaar gemaakt door het
16
// op het window object te plaatsen .
17
window . module = { foo : function () {
18
return ' Hello world ';
19 }
20 };
21 }
22 23 });
Codevoorbeeld 6.7: Zentrick-module
Een experience binnen het Zentrick-platform bestaat uit verschillende apps waarbij deze apps ´e´en of meerdere modules als dependency kunnen declareren. In de Zentrick-player is daarvoor een script-loader verwerkt die de verschillende modules in de juiste volgorde zal inladen. Alle dependency’s die vermeld worden in het dependencies object, worden toegevoegd als property’s op het global object. Dit heeft als gevolg dat er nog steeds conflicten mogelijk zijn in de global scope. Dankzij de script-loader in de Zentrick-player is er gelukkig geen behoefte meer aan manueel dependency-management. Zentrick heeft een uitbreiding van zijn module-syntax in de pijplijn, gebaseerd op het AMD-formaat om conflicten in de global scope te vermijden en het dynamisch inladen van dependency’s mogelijk te maken. Aangezien deze feature nog niet gereleased is, zal er in deze masterproef gebruik worden gemaakt van de huidige Zentrickmodules. Tot slot willen we nog vermelden dat er met ES6 ingebouwde ondersteuning komt voor modules, maar deze standaard is nog niet voltooid [20].
6.3
Paradigma’s
JavaScript biedt ondersteuning voor het objectgeori¨enteerde paradigma dankzij de aanwezigheid van object types en overerving a.d.h.v. een prototype. Vooral het concept van overerving verschilt fundamenteel in vergelijking met overerving a.d.h.v. klassen zoals in de klassieke objectgeori¨enteerde talen. Daarnaast biedt JavaScript ook ondersteuning voor het functioneel programmeren door zijn goede ondersteuning voor first-class functies. Functioneel programmeren geeft ons een andere kijk op het oplossen van een probleem en leidt vaak ook tot minder code met meer uitdrukkingskracht. In deze paragraaf nemen we beide paradigma’s onder handen opdat we deze daarna kunnen combineren en toepassen gedurende de implementatie van
6.3 Paradigma’s
70
ons project.
6.3.1
Objectgeori¨ enteerd programmeren
Objectgeori¨enteerde talen beschikken naast objecten ook nog over een concept voor overerving, polymorfisme, inkapseling van data en een mogelijkheid om de constructie van een object te defini¨eren. We starten deze bespreking met de benadering van overerving binnen JavaScript. Overerving ES5 beschikt niet over het concept van een klasse die kan erven van een andere klasse. JavaScript kent enkel objecten en een nieuw object kan daarbij de property’s erven van een bestaand object. Het bestaand object wordt daarbij het prototype genoemd van het nieuwe object. Dit nieuwe object kan dan weer dienst doen als prototype van een ander object en op die manier wordt er een prototype-chain gevormd. Deze prototype-chain komt overeen met de klassenhi¨erarchie bij klasse-gebaseerde overerving. Het aanmaken van een nieuw object dat erft van een prototypeobject gebeurt a.d.h.v. de Object.create() functie zoals in codevoorbeeld 6.8. 1 var obj = { 2
een : 1 ,
3
greet : function () { console . log ( ' Hello world '); }
4 }; 5 6 // We maken een object foo aan dat erft van obj . 7 var foo = Object . create ( obj ); 8 9
console . log ( foo . een );
10 foo . greet ();
// 1. // Hello world .
11 12 // We overschrijven de greet methode van foo . 13 foo . greet = function () { console . log ( ' Hello Zentrick '); } 14 foo . greet ();
// Hello Zentrick . Codevoorbeeld 6.8: Overerving a.d.h.v. prototypes
Wanneer een property wordt opgevraagd van een object zal de JavaScript-engine eerst het object zelf controleren op de aanwezigheid van deze property. Indien deze niet aanwezig is, wordt vervolgens het prototype-object onderzocht. Indien het daar ook niet aanwezig is wordt vervolgens het prototype van dit object onderzocht tot we uiteindelijk bovenaan de prototype-chain uitkomen bij het prototype van Object. Dit prototype-object beschikt zelf niet meer over een prototype
6.3 Paradigma’s
71
waardoor de prototype-chain be¨eindigd wordt. Indien een property niet gevonden wordt onderweg op de prototype-chain, zal de waarde undefined teruggegeven worden. Wanneer een nieuwe waarde wordt toegekend aan een property van een object, zal deze geassocieerd worden met het object zelf en niet met ´e´en van de objecten op de prototype-chain. Het prototype-object van een object kan opgevraagd worden met de ES5-methode Object.getPrototypeOf() of in bepaalde JavaScript-engines zoals V8 via de niet-gestandaardiseerde property __proto__. Constructor-functies Prototypes vormen een goede basis voor overerving maar meestal willen we bij de constructie van een object ook enige constructielogica uitvoeren. Klassieke programmeertalen bieden daarvoor het concept van een constructor aan. De code in de constructor zorgt voor het vastleggen van de klasse-invariant zodat een nieuw object zich in een geldige toestand bevindt na constructie. Deze constructor wordt dan ingebed in de definitie van een klasse, zodat ook de bijkomende operaties op dit object a.d.h.v. methodes kunnen vastgelegd worden. JavaScript laat ook toe om klassen van objecten te defini¨eren dankzij de combinatie van een constructor-functie en een bijhorend prototype-object. We noemen twee objecten dan instanties van dezelfde klasse indien hun prototype-object hetzelfde is. Er zijn duidelijke overeenkomsten met klassen uit klassieke objectgeori¨enteerde talen maar het onderliggende mechanisme is duidelijk anders door de aanwezigheid van de prototype-chain. De constructor-functie is een gewone JavaScript-functie die kan aangeroepen worden in combinatie met het new keyword om dienst te doen als constructor. Binnen de constructor kunnen de waarden van de property’s ge¨ınitialiseerd worden en ook andere constructielogica uitgevoerd worden. Tegelijkertijd willen we ook het prototype-object vastleggen met daarop de methodes en property’s die gelijk zijn voor alle instanties van de klasse. JavaScript voorziet daarvoor de property prototype op iedere functie, die toelaat om het prototype vast te leggen van een nieuw ge¨ınstantieerd object. Overerving tussen meerdere klassen is mogelijk door het prototype-object van de ene klasse op te nemen in de prototype-chain van het prototype-object van de andere klasse. We maken dit alles duidelijk a.d.h.v. een voorbeeld. 1
function Person ( name ) {
2
this . name = name ;
3 } 4 5 Person . prototype . greet = function () { 6 7 } 8
console . log ( ' Hallo , ik ben ' + this . name );
6.3 Paradigma’s
9
72
function Member ( name , birthyear ) {
10
// Aanroepen van de super constructor .
11
Person . call ( this , name );
12
this . birthyear = birthyear ;
13 } 14 15 // Hier wordt bepaald dat Member erft van Person . 16 Member . prototype = Object . create ( Person . prototype ); 17 18 Member . prototype . getAge = function () { return new Date (). getFullYear () - this . birthyear ;
19 20 }; 21
22 var zentrick = new Member ( ' Zentrick ' , 2010); 23 24
zentrick . greet ();
// Hallo , ik ben Zentrick .
25
console . log ( zentrick . getAge ()); // 4. Codevoorbeeld 6.9: Constructor-functies In codevoorbeeld 6.9 worden de klassen Person en Member aangemaakt door een constructorfunctie te defini¨eren en bijhorende methodes via de property prototype. De klasse Member erft van Person door op regel 16 de prototype-chain van Member.prototype te verlengen met Person.prototype. Op regel 22 wordt dan een instantie aangemaakt van de Member klasse door de constructor-functie in combinatie met het keyword new te gebruiken. De JavaScript-engine zal vervolgens intern een nieuw object aanmaken dat erft van het prototype-object van Member via Object.create(Member.prototype). Dit zorgt ervoor dat de methodes getAge() en greet() beschikbaar worden op het object. Daarna zal de Member() functie aangeroepen worden met de argumenten zoals aangegeven op regel 22 en met de invocation-context verwijzend naar het nieuwe aangemaakte object. De invocation-context van een JavaScript-functie is bereikbaar via het this keyword en is een voorbeeld van dynamic scoping aangezien de waarde afhankelijk is van de manier waarop de functie aangeroepen wordt. De invocation-context kan bepaald worden via de call() methode van een functie3 . Op regel 11 binnen de Member() constructor wordt vervolgens de constructor aangeroepen van de superklasse, net zoals dat in bv. Java met het super keyword zou gebeuren. Dit zorgt ervoor dat de constructielogica van de Person() constructor wordt uitgevoerd en het 3
Aangezien functies in JavaScript objecten zijn, beschikken ze zelf ook over methodes.
6.3 Paradigma’s
73
object zijn name property wordt ge¨ınitialiseerd. Daarna wordt op regel 12 nog de property birthyear toegekend waarna de constructie van het object is afgelopen en de klasse-invariant geldig is. Het is duidelijk dat het gebruik van klassen via prototypes in JavaScript vrij omslachtig is. Aangezien we gebruik willen maken van objectgeori¨enteerde patterns en principes bij het ontwerp van de modules voor onze use case, hebben we nood aan een abstractie die deze technische details verbergt. Daarom maken we een aparte module class (zie bijlage A) aan die ons toelaat om op een intu¨ıtieve manier objectgeori¨enteerd te programmeren in JavaScript. De implementatie van deze module is gebaseerd op het werk van John Resig [43], de ontwerper van jQuery. Het gebruik van deze module wordt gedemonstreerd in codevoorbeeld 6.10, waarbij we gebruikmaken van het CommonJS-formaat. De Class constructor beschikt over een methode extend() waaraan een object kan meegegeven worden die de constructor en andere methodes bevat. Zoals te zien op regel 15 is het mogelijk om een methode of constructor van de superklasse aan te spreken via this.base(). Met de komst van ES6 komt er ook native ondersteuning van klassen binnen JavaScript zodat dergelijke abstracties niet meer nodig zullen zijn. 1 var Class = require ( ' class '); 2 3 var Person = Class . extend ({ 4
constructor : function ( name ) { this . name = name ;
5 6
},
7
greet : function () { console . log ( ' Hallo , ik ben ' + this . name );
8 9
}
10 }); 11 12 var Member = Person . extend ({ 13
constructor : function ( name , birthyear ) {
14
// Aanroepen van de super constructor .
15
this . base ( name );
16
this . birthyear = birthyear ;
17
},
18
getAge : function () { return new Date (). getFullYear () - this . birthyear ;
19 20 21 });
}
6.3 Paradigma’s
74
22 23 var zentrick = new Member ( ' Zentrick ' , 2010); 24 25
zentrick . greet ();
// Hallo , ik ben Zentrick .
26
console . log ( zentrick . getAge ()); // 4. Codevoorbeeld 6.10: Abstractie van klassen
Inkapseling We hebben overerving en constructor-functies besproken die samen aangewend worden om het concept van een klasse aan te bieden binnen JavaScript. Een andere belangrijke peiler van het objectgeori¨enteerd programmeren is de mogelijkheid tot inkapseling van data. JavaScript heeft geen ondersteuning om property’s van een object als private in te stellen waardoor inkapseling op het eerste zicht niet mogelijk lijkt. Maar zoals Crockford [61] aangeeft, is het wel mogelijk om private data op te nemen in de definitie van een klasse, door gebruik te maken van closures zoals in codevoorbeeld 6.11. 1
function Person ( age ) {
2
// De functies getAge () en growUp () zijn privileged functies .
3
this . getAge = function () { return age ;
4 };
5 6
this . growUp = function () {
7
age ++;
8 };
9 10 } 11
12 var person = new Person (4); 13 14
console . log ( person . getAge ()); // 4.
15 person . growUp (); 16
console . log ( person . getAge ()); // 5. Codevoorbeeld 6.11: Inkapseling Op regel 1 defini¨eren we de klasse Person die age als argument meekrijgt. We hebben reeds eerder vermeld dat een functie een local scope cre¨eert, waardoor de variabele age enkel zichtbaar is
6.3 Paradigma’s
75
binnen de functie Person() en eventueel geneste functies. Dit betekent dat we age als privaat kunnen beschouwen binnen de functie Person(). I.p.v. de functies getAge() en growUp() op de prototype property te plaatsen van Person(), plaatsen we deze rechtstreeks op de nieuwe instantie via this. Aangezien beide functies genest worden binnen de functie Person(), vormen zij beide een closure en hebben zij toegang tot de variabele age. Op die manier kan age enkel aangepast worden via deze zogenaamde privileged methodes, i.p.v. age publiek te stellen zoals de property birthday in codevoorbeeld 6.9. Deze techniek voor inkapseling binnen JavaScript heeft echter wel het nadeel dat het meer resources vraagt i.p.v. de methode met het prototype-object zoals in codevoorbeeld 6.9. Een privileged methode wordt namelijk op het object zelf geplaatst zodat iedere instantie van een klasse een nieuwe closure introduceert per privileged methode. De JavaScript-engine moet per closure een unieke scope-chain bewaren wat veel geheugen kost indien er veel instanties zijn van een klasse. Door te werken met het prototype-object wordt de methode slechts ´e´en keer aangemaakt wat een aanzienlijke geheugenwinst oplevert. Om die reden zullen we gedurende de implementatie van het project geen inkapseling voorzien. Om aan te geven dat een property of methode enkel voor privaat gebruik is, zal deze voorafgegaan worden door een underscore-teken. Deze conventie wordt dikwijls gebruikt in de JavaScript-community en zo ook binnen Node.js. Polymorfisme Veelgebruikte design patterns binnen het objectgeori¨enteerd programmeren zoals bv. het strategypattern steunen op polymorfisme. Polymorfisme is de mogelijkheid om at runtime te bepalen welke specifieke implementatie van een methode zal uitgevoerd worden. Bij klassieke objectgeori¨enteerde talen wordt daarbij gebruikgemaakt van concrete subtypes van een (abstracte) klasse of interface waarbij ieder subtype een andere invulling kan geven aan een methode, zolang de signatuur van deze methode gerespecteerd wordt. JavaScript daarentegen heeft geen statische typering en maakt at runtime gebruik van duck-typing. Daarbij wordt enkel gekeken naar de methodes die een object bezit, zonder rekening te houden met het type of de klasse van dit object. Het volstaat dus om verschillende klassen van methoden te voorzien met dezelfde signatuur om polymorfisme toe te passen binnen JavaScript.
6.3.2
Functioneel programmeren
Functioneel programmeren biedt een andere kijk op het oplossen van een probleem in vergelijking met de alomtegenwoordige imperatieve programmeertalen. Imperatieve programmeertalen benaderen een berekening op een vrij mechanische manier door iedere instructie gedetailleerd te beschrijven. Daarbij wordt gebruikgemaakt van variabelen die een bepaalde state bijhouden, waarbij deze state gedurende de uitvoering van het programma aangepast kan worden. Dit
6.3 Paradigma’s
76
model van programmeren mapt goed op de werking van een processor die instructies uitvoert waarbij de gegevens in het primaire geheugen aangepast worden. Functioneel programmeren daarentegen probeert state zoveel mogelijk te vermijden en maakt gebruik van een functie als de primaire eenheid van abstractie. JavaScript is geen pure functionele taal maar maakt dankzij zijn first-class functies wel enkele functionele concepten mogelijk. Aangezien functionele programmeertalen op een hoger abstractieniveau werken, moet de compiler een grotere inspanning leveren om deze programma’s om te zetten naar een reeks imperatieve processorinstructies. Dankzij deze abstractie leveren functionele programma’s in het algemeen meer uitdrukkingskracht in minder regels code. We bekijken in dit overzicht enkel de functionele technieken die we zullen gebruiken gedurende de uitwerking van ons project. Hogere-orde functies Een hogere-orde functie is een first-class functie die ´e´en of meerdere functies als argument heeft en/of een functie teruggeeft. De belangrijkste vertegenwoordigers van hogere-orde functies binnen het functioneel programmeren zijn map(), filter() en reduce(). Deze functies werken in op een collectie van items en verwachten als argument een functie die zal toegepast worden op ieder item van deze collectie. In het geval van map() en filter() wordt een collectie teruggegeven, reduce() kan elk mogelijk type teruggeven. JavaScript biedt vanaf ES5 ondersteuning voor deze functies en voorziet ze als methodes van het Array type. map() verwacht een functie die een item uit de collectie als argument krijgt, en vervolgens gelijk welk type teruggeeft. filter() verwacht ook een functie met een item als argument, maar verwacht een boolean als teruggeefwaarde. Deze waarde bepaalt of dit item wordt opgenomen in de resulterende collectie. De functies die worden meegegeven aan map() en filter() worden dus n keer opgeroepen indien er n items in de collectie aanwezig zijn. De functie reduce() verwacht een functie die twee items als argumenten meekrijgt waarbij deze gereduceerd worden tot ´e´en item als teruggeefwaarde. Een collectie met n items zorgt ervoor dat de functie meegegeven aan reduce() n − 1 keer wordt opgeroepen. Deze functies zijn heel handig om grote hoeveelheden gegevens te verwerken en om te zetten naar een gevraagd uitvoerformaat. Om de kracht van deze functies aan te tonen, zien we in codevoorbeeld 6.12 dat er eerst een array van personen wordt aangemaakt. Uit deze array met invoergegevens willen we een oplijsting hebben van alle namen van de personen die ouder zijn dan 30 jaar, gesorteerd van oud naar jong. Door eerst een filter te plaatsen (regel 14), daarna te sorteren (regel 15) en iedere persoon te mappen op enkel zijn naam (regel 16), en tot slot deze namen te reduceren tot ´e´en string (regel 17), krijgen we het gevraagde uitvoerformaat. Als we deze functionele code vergelijken met de imperatieve code vanaf regel 21, is er een duidelijk verschil merkbaar. Imperatief programmeren vereist meerdere for-lussen en hulpvariabelen om tot een uiteindelijk resultaat te komen.
6.3 Paradigma’s
1 var collection = [{ 2
name : ' Anna ' ,
3
age : 23
4 }, { 5
name : ' Bernard ',
6
age : 32
7 }, { 8
name : ' Dirk ' ,
9
age : 50
10 }]; 11 12 // Functioneel programmeren . 13 var result = collection 14
. filter ( function ( person ) { return person . age > 30; })
15
. sort ( function (a , b ) { return b . age - a . age ; })
16
. map ( function ( person ) { return person . name ; })
17
. reduce ( function (a , b ) { return a + ', ' + b ; });
18 19
console . log ( result ); // Bernard , Dirk .
20 21 // Imperatief programmeren . 22 var aux = [] , result = ' ';
23 24
25 for ( var i = 0; i < collection . length ; i ++) { 26
if ( collection [ i ]. age > 30) {
27
aux . push ( collection [ i ]); }
28 29 } 30
31 aux . sort ( function (a , b ) { return b . age - a . age ; }); 32 33 for ( var i = 0; i < aux . length ; i ++) { result += aux [ i ]. name + ', ';
34 35 } 36 37
console . log ( result . slice (0 , -2));
77
6.3 Paradigma’s
78
Codevoorbeeld 6.12: Hogere-orde functies We kunnen de functies die we meegeven aan de hogere-orde functies i.p.v. inline ook apart declareren zodat deze ook elders kunnen gebruikt worden. Functies als eenheid van abstractie, lenen zich op die manier veel beter tot hergebruik van code in vergelijking met imperatieve code. Vanaf ES6 kan de functionele code zelfs nog korter door de komst van arrow-functions. Deze syntax helpt namelijk om de verbositeit van de keywords function en return weg te werken; zie codevoorbeeld 6.13. In het volgende hoofdstuk zullen we ook nog zien dat hogere-orde functies zoals reduce() goed samengaan met asynchrone code a.d.h.v. promises. Het nadeel van de functies map() en filter() is dat ze niet lazy zijn in JavaScript en dus iedere functie een nieuwe array construeert. Door gebruik te maken van het iterator-pattern is het mogelijk om deze functies lazy te maken zoals bv. het geval is in het LINQ-framework van .NET [26]. ES6 voorziet language-ondersteuning voor iterators [19], wat een eerste stap kan zijn richting laziness van deze functies. 1 var result = collection 2
. filter ( person = > person . age > 30)
3
. sort (( a , b ) = > b . age - a . age )
4
. map ( person = > person . name )
5
. reduce (( a , b ) = > a + ', ' + b ); Codevoorbeeld 6.13: ES6 arrow-functions
Parti¨ ele applicatie Parti¨ele applicatie is het laatste concept uit het functioneel programmeren dat we toelichten, aangezien we dit zullen gebruiken in het volgende hoofdstuk. In het functioneel programmeren spreekt men niet zozeer over het aanroepen van een functie met enkele argumenten, maar eerder over de applicatie van argumenten op een functie. Daarbij is het ook mogelijk om deze applicatie slechts deels toe te passen door niet alle argumenten in ´e´en keer mee te geven. Het kan namelijk gebeuren dat niet alle argumenten direct beschikbaar zijn, of parti¨ele applicatie kan gebruikt worden om nieuwe functies te bouwen a.d.h.v. bestaande functies. In codevoorbeeld 6.14 hebben we bv. een bestaande functie sum(). Stel nu dat we een functie increment() willen maken, voor het geval JavaScript geen increment-operator zou bezitten. Dan is het mogelijk om de bestaande functie sum() te gebruiken. Daarvoor gebruiken we de functie bind() die sinds ES5 ook is toegevoegd aan JavaScript. Deze laat toe om de invocation-context van een functie te bepalen en daarnaast ook ´e´en of meerdere argumenten reeds toe te passen.
6.4 Kwaliteitscontrole
79
bind() begint bij het meest linkse argument, en op regel 3 passen we op die manier de waarde 1 toe voor het eerste argument a van sum(). De invocation-context zetten we op null aangezien deze toch niet gebruik wordt4 . Op regel 5 gebruiken we onze nieuwe increment functie die nu slechts ´e´en argument meer nodig heeft, namelijk b, waarbij dan 1 wordt opgeteld. 1 var sum = function (a , b ) { return a + b ; }; 2 3 var increment = sum . bind ( null , 1); 4 5
console . log ( increment (1)); // 2. Codevoorbeeld 6.14: Parti¨ele applicatie
Met een korte toelichting van hogere-orde functies en parti¨ele applicatie hebben we slechts een heel klein deel van alle concepten besproken die het functioneel programmeren aanbiedt. Zo hebben we het bv. nog niet gehad over purity (immutability), currying, monads, recursie, luie uitvoering via call-by-name en call-by-need [70]. Bepaalde concepten zijn mogelijk in JavaScript maar worden niet gebruikt bij de uitwerking van deze masterproef. En nog andere concepten zoals call-by-name en call-by-need zijn niet ingebouwd in de JavaScript-programmeertaal en kunnen dus ook niet toegepast worden. Voor de implementatie van ons project zal in de eerste plaats het objectgeori¨enteerde paradigma gebruikt worden. Dit laat namelijk toe om onze bestaande kennis van objectgeori¨enteerd software-ontwerp en design patterns toe te passen. Daarnaast zullen af en toe ook functionele concepten aangewend worden, indien de context van het probleem er zich toe leent.
6.4
Kwaliteitscontrole
We hebben al de voornaamste tekortkomingen van JavaScript aangepakt met een bespreking over modulariteit en de ontwikkeling van een goede abstractie voor objectgeori¨enteerd JavaScript. In het begin van dit hoofdstuk werd daarnaast ook vermeld dat JavaScript nog enkele eigenschappen bezit die beter kunnen vermeden worden. Deze zullen hier aangepakt worden samen met een oplossing voor unit-testing zodat de kwaliteit van de code gewaarborgd blijft.
6.4.1
Linting
In appendix A en B van JavaScript: The Good Parts [61] beschrijft Crockford alle slechte features van JavaScript. De slechtste feature van allemaal is zonder meer de nood aan globale variabelen voor zichtbaarheid tussen verschillende modules. Dit probleem werd reeds opgelost in 4
Zoals eerder al vermeld is de invocation-context bereikbaar via het keyword this.
6.4 Kwaliteitscontrole
80
de paragraaf over modulariteit door slechts ´e´en globale variabele te introduceren. Daarnaast is er echter ook nog het probleem dat een nieuwe variabele zonder het keyword var ook in de global scope terecht komt. Dit is een probleem dat opgelost kan worden door gebruik te maken van een linter die controleert of iedere nieuwe variabele beschikt over het keyword var. Crockford was de eerste die een linting-tool ontwikkelde voor JavaScript onder de naam JSLint [23]. Deze linter is echter heel strikt en laat weinig configuratie toe; daarom kwam er later een fork met JSHint [22] die veel meer configuratiemogelijkheden biedt. Voor de implementatie van deze masterproef zal dan ook gebruikgemaakt worden van JSHint als linting-tool. De configuratie is vrij eenvoudig en gebeurt a.d.h.v. een bestand in JSON-formaat. Een voorbeeld is te zien in codevoorbeeld 6.15. Zo geeft de optie newcap aan dat een constructorfunctie altijd met een hoofdletter moet beginnen. Op die manier kan JSHint controleren dat een constructor-functie met new wordt aangeroepen. Indien dit per ongeluk niet gebeurt, zal een constructor globale variabelen declareren omdat this dan verwijst naar het global object. De optie quotmark geeft aan dat strings met enkele quotes moeten worden gedeclareerd, JavaScript laat hier immers de keuze tussen enkele en dubbele quotes. Met de optie strict op true wordt ge¨eist dat iedere functie in ES5 strict mode draait, die enkel een striktere subset van JavaScript toelaat. { newcap : true , quotmark : " single " , strict : true } Codevoorbeeld 6.15: JSHint configuratiebestand Dit zijn slechts enkele van de vele configuratieparameters die JSHint aanbiedt; meer informatie is te vinden in de documentatie van JSHint. Voor een overzicht van de vermijdbare features van JavaScript verwijzen we naar Appendix A en B van het boek JavaScript: The Good Parts [61].
6.4.2
Testing
Testing of dynamische verificatie laat toe om met grotere waarschijnlijkheid correct werkende software te schrijven. Formeel is testing gelijk aan de controle of de specificatie van een stuk software overeenkomt met de implementatie. Het opstellen van een specificatie binnen het objectgeori¨enteerd programmeren is mogelijk door gebruik te maken van precondities, postcondities en klasse-invarianten volgens het Design by Contract paradigma [67]. Dit wordt echter slechts zelden toegepast en de specificatie volgt dan meestal ook uit de documentatie. Door unittesten te schrijven kunnen we op zoek gaan naar plaatsen waar het gedrag van software niet
6.4 Kwaliteitscontrole
81
overeenkomt met de vooropgestelde documentatie. JavaScript biedt daarvoor net zoals andere programmeerplatform meerdere testframeworks aan, voor deze masterproef werd gekozen voor Mocha [28]. Er bestaan ook alternatieve frameworks zoals bv. Jasmine [21] en QUnit [36] maar we hebben de keuze gemaakt voor Mocha aangezien dit framework het meest wordt gebruikt binnen Node.js. In codevoorbeeld 6.16 is te zien hoe we op regel 5 de describe() functie gebruiken om een nieuwe test suite te defini¨eren. Mocha laat toe om een timeout te defini¨eren voor iedere unit test, dit is vooral van belang bij het testen van asynchrone API’s. In dit voorbeeld wordt deze ingesteld op 2s zoals te zien op regel 8. Via de before() functie kunnen we code uitvoeren die slechts ´e´en keer aan het begin van een testronde moet uitgevoerd worden. Typisch wordt hier bv. een verbinding met een databank opgezet. Als voorbeeld lezen we op regel 13 de inhoud van het bestand helloworld.txt in, waarna we deze string aan de variabele message toewijzen. Met de functie it() is het vervolgens mogelijk om een unit-test te defini¨eren. In dit voorbeeld defini¨eren we een asynchrone unit-test, die door Mocha wordt opgemerkt door de aanwezigheid van het argument done zoals op regel 19. Dit geeft aan dat de unit-test pas be¨eindigd is wanneer deze functie done() wordt aangeroepen. Indien dit langer dan 2s duurt zoals aangegeven op regel 8, zal de unit-test ook falen. Mocha zorgt er ook voor dat alle unit-testen sequentieel na elkaar worden uitgevoerd. Asynchrone unit-testen kunnen dus niet conflicteren met elkaar aangezien ze niet overlappen qua tijd. Op regel 21 controleren we dan uiteindelijk via de assert module van Node.js of de variabele message wel gelijk is aan 'Hello world'. 1 var assert = require ( ' assert ') , 2
fs = require ( ' fs ');
3 4 var message ; 5 6
describe ( ' test suite ', function (){
7
// Een test geeft een timeout indien hij meer dan 2 s duurt .
8
this . timeout (2000);
9 10
// Via before () kunnen we een actie ondernemen die eenmalig
11
// optreedt bij de start .
12
before ( function (){ message = fs . readFileSync ( ' helloworld . txt ' , {
13
encoding : ' utf8 '
14 });
15 16
});
6.5 Build-automatisering
82
17 18
// Via it () kunnen we een unit - test aanmaken .
19
it ( ' should return " hello world " ' , function ( done ){ setTimeout ( function (){
20 21
assert . equal ( message , ' Hello world ');
22
done (); } , 1000);
23 });
24 25 });
Codevoorbeeld 6.16: Mocha testframework
6.5
Build-automatisering
In de vorige paragraaf hebben we twee tools besproken die ons zullen helpen om de codekwaliteit van ons project te waarborgen. JSHint behoedt ons voor vaak voorkomende fouten bij JavaScript-development en Mocha helpt ons om de correctheid van onze code te controleren. Uiteraard hebben deze tools enkel zin als ze ook frequent gebruikt worden. Om onszelf te verplichten om gedurende de ontwikkeling van de code gebruik te maken van deze tools voegen we een extra build-stap toe aan het ontwikkelingsproces. JavaScript-code moet in principe niet gecompileerd worden en kan direct uitgevoerd worden via het node commando, zonder een bijhorend build-proces. Door gebruik te maken van een build-tool zoals Grunt, kunnen we enkele tasks defini¨eren die moeten plaatsvinden vooraleer de code wordt uitgevoerd. Deze verschillende tasks vormen samen het build-proces. Grunt [17] is een build-tool voor JavaScript-projecten die gebruikmaakt van Node.js. Naast Grunt zijn er ook nog andere tools zoals Gulp [18] en Broccoli [10], maar deze hebben niet dezelfde maturiteit, ondersteuning en verscheidenheid aan plugins als Grunt. De verschillende tasks worden gedefinieerd a.d.h.v. een bestand met de naam Gruntfile.js. Het configuratiebestand bestaat uit JavaScript-code in CommonJS-formaat en is samengesteld uit de volgende delen zoals zichtbaar in codevoorbeeld 6.17: de wrapper-functie (regel 1) de configuratie van de verschillende tasks (regel 2-14) het inladen van de grunt-plugins en de tasks (regel 16-17) het defini¨eren van custom tasks (regel 19-22)
6.5 Build-automatisering
83
1 module . exports = function ( grunt ) { 2
grunt . initConfig ({
3
jshint : { options : {
4
jshintrc : true
5 6
},
7
development : [ ' lib /**/*. js ', ' testing /**/*. js '] ,
8
production : [ ' lib /**/*. js ' , ' testing /**/*. js '] },
9
mochaTest : {
10 11
development : [ ' testing / unit /**/*. js '] ,
12
production : [ ' testing /**/*. js '] }
13 });
14 15 16
grunt . loadNpmTasks ( ' grunt - contrib - jshint ');
17
grunt . loadNpmTasks ( ' grunt - mocha - test ');
18 grunt . registerTask ( ' development ' , [ ' jshint : development ',
19
' mochaTest : development ' ]);
20
grunt . registerTask ( ' production ', [ ' jshint : production ',
21
' mochaTest : production ' ]);
22 23 };
Codevoorbeeld 6.17: Grunt configuratiebestand
De publieke interface van het configuratiebestand bestaat uit de wrapper-functie die een object als argument verwacht. Wanneer Grunt wordt opgestart van de command-line, zal deze functie aangeroepen worden met het grunt object. Daarna volgt vanaf regel 2 de configuratie van de tasks jshint en mochaTest. Deze tasks zijn aanwezig dankzij de Grunt-plugins grunt-contrib-jshint en grunt-mocha-test die beschikbaar zijn via npm. Daarbij volstaat het dat deze plugins aanwezig zijn in het package.json bestand en ge¨ınstalleerd zijn via npm install. Bij de jshint task geven we via het options object aan dat we wensen gebruik te maken van een .jshintrc configuratiebestand. Binnen een task kunnen daarnaast meerdere targets gedefinieerd worden, om een onderscheid te maken afhankelijk van de context waarin het build-proces plaatsvindt. In codevoorbeeld 6.17 en bij de implementatie van ons project worden deze targets gebruikt om
6.6 Synopsis
84
aan te duiden of het om een development- of een production-build gaat. Bij de jshint task worden op die manier alle JavaScript-bestanden gedefinieerd die door de linter moeten gecontroleerd worden. Bij de mochaTest task wordt via de targets de locatie van de bestanden vastgelegd die de unit-testen bevatten. Regel 16 en 17 zorgen er dan voor dat de plugins grunt-contrib-jshint en grunt-mocha-test ingeladen worden, en samen met deze plugins ook de bijkomende taken. Op het einde van de wrapper-functie op regel 19 tot 22 worden dan nog twee custom taken gedefinieerd via de grunt.registerTask() functie. Daarbij wordt een production task gedefinieerd die de production targets sequentieel zal uitvoeren van de jshint task en de mochaTest task. Analoog wordt dan ook een task gedefinieerd voor een production-build. Deze custom tasks kunnen dan aangeroepen worden via de command-line a.d.h.v. de grunt development en grunt production commando’s. Onze ontwikkelomgeving wordt dan zodanig geconfigureerd dat deze commando’s automatisch worden opgeroepen vooraleer de Node.js applicatie wordt opgestart. Gedurende de uitwerking van ons project zullen we uitgebreid gebruikmaken van Grunt om bijkomende tasks te defini¨eren. Zo zullen we o.a. een copy task aanmaken die alle JavaScriptbestanden kopieert naar een aparte production-build map. Vervolgens zullen we ook een clean task voorzien die de oude build verwijdert, zodat er geen verouderde bestanden achterblijven. Daarnaast zullen we Grunt ook gebruiken om ES6-code te transpileren naar ES5-code die door Node.js kan uitgevoerd worden. Deze task is nodig omdat we in het volgende hoofdstuk zullen zien dat het gebruik van ES6-generators leidt tot een heel krachtige abstractie voor asynchrone code.
6.6
Synopsis In welke mate kan JavaScript geprofessionaliseerd worden om grote projecten op een kwaliteitsvolle manier aan te pakken?
JavaScript is in eerste instantie niet ontworpen als server-side programmeertaal en dit uit zich in het ontbreken van enkele concepten voor grootschalige projecten. Dit gaf aanleiding tot de bovenstaande onderzoeksvraag die tot de uitgebreide uiteenzetting in dit hoofdstuk heeft geleid. Na een kort overzicht van de taal zijn we op zoek gegaan naar een manier om modulariteit toe te voegen en hebben we deze gevonden met AMD en CommonJS. AMD blijkt vooral populair te zijn aan de client-side, maar is voorlopig nog niet beschikbaar in het Zentrick-platform. Daar wordt nu gebruikgemaakt van een custom formaat dat dan ook gebruikt zal worden voor client-side modules van de real-time interactiviteit. CommonJS lijkt dan weer meer geschikt voor server-side code en is dan ook de logische keuze voor de ontwikkeling van onze server-side modules.
6.6 Synopsis
85
JavaScript is een multiparadigma-programmeertaal die louter imperatieve code kan voortbrengen, maar evengoed in staat is tot objectgeori¨enteerde code en zelfs functionele code. We onderzochten eerst de objectgeori¨enteerde mogelijkheden van JavaScript, zodat we onze bestaande kennis van software-ontwerp en design patterns maximaal zouden kunnen benutten. Daarbij vormden prototypes de basis voor overerving, maar bleef er nood aan een concept om de constructie van een object te defini¨eren. Constructor-functies vormden hier een oplossing en samen met het prototype-concept werd een abstractie ontwikkeld om op eenvoudige wijze een klasse van objecten vast te leggen. Daarna bekeken we nog enkele concepten uit het functioneel programmeren waaronder hogere-orde functies en parti¨ele applicatie. Beide technieken laten meer uitdrukkingskracht toe met minder code en zullen ons o.a. nog van pas komen in het hoofdstuk over asynchroon programmeren. Dankzij de objectgeori¨enteerde en functionele paradigma’s van JavaScript zijn we in staat om krachtige code te ontwikkelen. In een professionele ontwikkelomgeving is er echter ook nood aan kwaliteitscontrole. Linting geeft ons de mogelijkheid om alle vermijdbare features van JavaScript kenbaar te maken terwijl testing toelaat om de correctheid van onze code na te gaan. JSHint en Mocha zijn de twee tools die we zullen aanwenden om deze vormen van kwaliteitscontrole toe te passen. Omdat enkel een structureel gebruik van deze tools tot het gewenste effect leidt, introduceren we ook een build-proces door gebruik te maken van Grunt. Grunt is een buildtool die toelaat om ons build-proces op te delen in verschillende tasks waarbij iedere task kan geconfigureerd worden volgens een specifiek target. Gedurende de ontwikkeling van de real-time interactiveit zullen we gebruikmaken van een development en een production target die leiden tot een kwalitatieve build. Het volgende hoofdstuk brengt ten slotte een antwoord op de laatste en misschien wel belangrijkste onderzoeksvraag van deze masterproef: hoe zorgen we ervoor dat asynchrone code leesbaar en beheersbaar blijft?
ASYNCHROON PROGRAMMEREN
86
Hoofdstuk 7
Asynchroon programmeren We zijn al een heel eind gevorderd in ons onderzoek, waarbij we gestart zijn met de bepaling van de verschillende componenten van onze event-driven architectuur. Nadat we deze identificeerden, gingen we op zoek naar de technieken en protocollen van het web die toelaten om gegevens van de server naar de client te pushen. HTTP-long-polling en WebSocket werden daarbij aangeduid als een complementair koppel om een geslaagde bidirectionele verbinding tot stand te brengen. Daarbij ontstond vervolgens de nood aan webtechnologie die toelaat om een groot aantal verbindingen te onderhouden. De keuze ging uit naar Node.js en zijn event-driven concurrency model waardoor ook de impliciete keuze werd gemaakt voor JavaScript en een asynchroon programmeermodel. Het gebruik van JavaScript op grote schaal werd in het vorige hoofdstuk aangepakt. De zoektocht naar een goede abstractie voor asynchrone code vormt bijgevolg het laatste obstakel vooraleer we kunnen starten met de implementatie.
7.1
Synchroon programmeren
In dit hoofdstuk gaan we op zoek naar een manier om een functie op te splitsen in meerdere synchrone delen. Bij het aanroepen van de functie wordt het eerste deel uitgevoerd, waarbij tijdens de uitvoering ook een asynchrone operatie wordt opgestart. Daarna kan de event-loop verdergaan met de afhandeling van andere events. Wanneer de asynchrone operatie voltooid is, kan het volgende deel van de functie uitgevoerd worden. In het geval van sequenti¨ele I/O-operaties zullen er n−1 asynchrone operaties plaatsvinden in een functie met n synchrone delen. Wanneer we verschillende I/O-operaties parallel willen uitvoeren, zullen we wachten op het voltooien van meerdere asynchrone operaties tegelijkertijd, vooraleer we een volgend deel aanvatten. In dat geval kan een functie uit n delen, beschikken over n of meer asynchrone operaties. Dankzij de single-threaded event-loop hebben we de zekerheid dat ieder deel synchroon zal uitgevoerd worden zonder dat er pre¨emptie optreedt. Om de voor- en nadelen van iedere asynchrone methode duidelijk te onderscheiden t.o.v. elkaar, gebruiken we ´e´en voorbeeld als rode draad doorheen dit
7.1 Synchroon programmeren
87
hoofdstuk. Het voorbeeld zal daarbij telkens aangepast worden aan de gebruikte abstractie. Het voorbeeld bestaat uit een functie backup() die een array van files meekrijgt als argument die moeten opgeslagen worden in een databank. Nadat deze bestanden gepersisteerd zijn, moet een timestamp opgehaald worden uit de databank, om vervolgens een nieuwe regel toe te voegen aan een logbestand met deze tijdsaanduiding. De backup is succesvol indien al deze operaties succesvol verlopen. Codevoorbeeld 7.1 geeft een synchrone implementatie van deze functie, zoals deze eruit zou zien in de aanwezigheid van een blocking I/O-model. De functies die een blocking I/O-operatie uitvoeren, worden duidelijk aangeduid met het Sync suffix, volgens de conventie van Node.js. 1 var backup = function ( files ) { for ( var i = 0; i < files . length ; i ++) {
2
db . persistSync ( files [ i ]);
3 }
4 5 6
var time = db . fetchTimeSync ();
7
var msg = files . length + ' bestanden gepersisteerd om ' + time ;
8
fs . appendFileSync ( ' logfile . txt ', msg );
9 }; Codevoorbeeld 7.1: Synchroon programmeren De synchrone backup() functie scoort goed qua leesbaarheid aangezien alle bestaande concepten van het imperatief programmeren gebouwd zijn rond synchrone code. Om de leesbaarheid van iedere asynchrone techniek te onderzoeken zullen we gebruikmaken van enkele criteria volgens [34] die ons zullen helpen bij het maken van een keuze. Deze criteria worden bepaald door de verschillende eigenschappen van synchrone code te bepalen die bijdragen tot de leesbaarheid. We sommen de verschillende criteria op waarbij we ze telkens bespreken in functie van een synchroon programmeermodel: Hoe is de signatuur opgebouwd van de functie? In het synchroon programmeren bestaat de signatuur uit invoerargumenten en een teruggeefwaarde. Dit geeft een duidelijk onderscheid tussen de variabelen waarop de functie inwerkt, en de waarde die teruggegeven wordt door de functie. De signatuur van een asynchrone abstractie zal altijd een variant zijn op de synchrone signatuur. Deze varianten voegen soms bepaalde argumenten toe aan de signatuur waardoor ze aan leesbaarheid verliezen. Hoe zorgen we ervoor dat de functie over de nodige context beschikt?
7.1 Synchroon programmeren
88
Bij synchrone code bestaat de context uit alle variabelen die aanwezig zijn in de scope van de omvattende functie. Asynchrone code zal deze functie echter uiteentrekken in twee of meerdere delen die elk gedefinieerd worden in een aparte functie. Daardoor ontstaat de nood aan een manier om de variabelen uit eerdere functies zichtbaar te maken in de daarop volgende functies. Hoe gedraagt deze abstractie zich tot de primitieve controlestructuren? In 1966 publiceerden B¨ ohm en Jacopini een artikel [57] dat demonstreerde dat alle programma’s geschreven kunnen worden a.d.h.v. slechts drie controlestructuren: de sequentie, de selectie en de herhaling. Deze primitieve controlestructuren vormen de basis van het gestructureerd programmeren en we maken er dan ook voortdurend gebruik van zoals o.a. in codevoorbeeld 7.1. In de aanwezigheid van bepaalde asynchrone abstracties, leveren deze controlestructuren niet meer het verwachte gedrag. Om hetzelfde gedrag te verkrijgen uit het synchroon programmeren is dan ook heel wat extra code nodig, waardoor de leesbaarheid drastisch afneemt. Hoe wordt er aan error-handling gedaan? JavaScript bezit net zoals vele andere moderne programmeertalen over een try-catch-statement waarmee het mogelijk is om een error op te vangen en af te handelen. Zo kunnen we de synchrone backup() functie uit codevoorbeeld 7.1 oproepen en daarbij gemakkelijk de error opvangen door deze te omsluiten met een try-catch-statement zoals in codevoorbeeld 7.2. Een try-catchstatement is namelijk in staat om een error op te vangen van een functie die wordt aangeroepen als deze error optreedt in een functie waarvan de functie-aanroep zich hogerop binnen de callstack bevindt. 1 try { backup ( files );
2
3 } catch ( err ) { console . log ( ' Een fout is opgetreden : ' + err );
4 5 }
Codevoorbeeld 7.2: Error-handling bij synchroon programmeren Hoe worden de verschillende synchrone delen van elkaar onderscheiden? In een blocking I/O-model beschikt een synchrone functie niet over meerdere onderdelen. Door de aanwezigheid van het pre¨emptieve karakter van multithreading kan de uitvoering echter op ieder moment onderbroken worden door een andere thread. De synchrone code in het threadbased model kan dus niet garanderen dat ze niet onderbroken zal worden. Dit geeft aanleiding tot een foutgevoelig programmeermodel zoals besproken in hoofdstuk 5 en was de voornaamste reden om te kiezen voor het event-driven concurrency model.
7.2 Continuation passing style
89
Hoe kan er onderscheid gemaakt worden tussen sequenti¨ele en parallelle I/O-operaties? De sequentie uit het gestructureerd programmeren bepaalt dat alle opeenvolgende regels code in een programma sequentieel zullen uitgevoerd worden. In synchrone code wordt gebruikgemaakt van multithreading om deze sequenti¨ele benadering te doorbreken en zowel CPU- als I/O-parallellisme toe te laten. Op regel 2 tot 4 in codevoorbeeld 7.1 is te zien hoe alle bestanden sequentieel gepersisteerd worden naar de databank. Asynchroon programmeren laat enkel I/O-parallellisme toe en de manier waarop een onderscheid wordt gemaakt tussen sequenti¨ele en parallelle I/O-operaties verschilt daarbij voor iedere abstractie.
7.2
Continuation passing style
Continuation passing style is de standaard aanpak voor asynchrone operaties binnen Node.js. Alle asynchrone API’s die Node.js voorziet zijn dan ook volgens deze abstractie opgebouwd. Het principe is dat iedere asynchrone functie een callback-functie voorziet als laatste argument, die de continuation bevat die moet uitgevoerd worden wanneer de asynchrone operatie voltooid is. Node.js hanteert de conventie dat deze callback als eerste argument steeds een error-object bevat. Indien de asynchrone operatie succesvol verliep, is dit error-object gelijk aan null. De teruggeefwaarde van de asynchrone operatie bevindt zich vervolgens in het tweede argument van de callback. Codevoorbeeld 7.3 geeft het equivalent in CPS-stijl van de synchrone backup() functie. Een functie die eerst 9 regels in beslag nam, is nu aangedikt tot 33 regels code. Dit voorbeeld geeft aan waarom asynchrone code in het algemeen als onleesbaar wordt beschouwd. We zullen CPS in het vervolg van deze paragraaf onderwerpen aan onze vooropgestelde criteria. 1 var backup = function ( files , callback ) { 2
var persist = function ( i ) {
3
if ( i === files . length ) {
4
next (); return ;
5
}
6 7 8
try { db . persist ( files [ i ] , function ( err ){ if ( err ) {
9
callback ( err ); return ;
10 }
11 12
persist (++ i );
13 14
});
7.2 Continuation passing style
90
} catch ( err ) {
15
callback ( err ); return ;
16 }
17 };
18 19
var next = function () {
20
db . fetchTime ( function ( err , time ) {
21
if ( err ) {
22
callback ( err ); return ;
23 }
24 25
var msg = files . length + ' succesvol gepersisteerd om '
26
+ time ;
27
fs . appendFile ( ' logfile . txt ', msg , callback );
28 });
29 };
30 31
persist (0);
32 33 };
Codevoorbeeld 7.3: CPS
Hoe is de signatuur opgebouwd van de functie? Een synchrone functie wordt omgevormd naar CPS door een extra argument toe te voegen in de vorm van een callback-functie zoals te zien op regel 1 van codevoorbeeld 7.3. Deze callback verwacht als eerste argument een error-object en als tweede argument de teruggeefwaarde van de originele synchrone functie. Indien de synchrone functie void heeft als teruggeefwaarde, wordt dit tweede argument weggelaten in het asynchrone equivalent. Een asynchrone functie via CPS heeft bijgevolg als teruggeefwaarde altijd void aangezien het resultaat via de callback wordt meegegeven. Deze signatuur verliest duidelijk aan leesbaarheid t.o.v. de synchrone variant aangezien de teruggeefwaarde nu via een invoerparameter (de callback) beschikbaar wordt gesteld. Hoe zorgen we ervoor dat de functie over de nodige context beschikt? In hoofdstuk 5 gaven we aan dat oudere programmeertalen aan manual stack management moeten doen door de benodigde context door te geven als extra argument aan de callbackfunctie. Gelukkig biedt JavaScript closures aan zoals besproken in §6.1.2 waardoor deze callback
7.2 Continuation passing style
91
een closure is waarbinnen alle variabelen van hoger geneste functies zichtbaar zijn. Dankzij deze eigenschap is bv. de variabele files zichtbaar op regel 26 in de callback van de asynchrone functie fetchTime(). Zonder de ondersteuning voor closures wordt asynchroon programmeren bijgevolg een heel tijdsintensieve opdracht. Hoe gedraagt deze abstractie zich tot de primitieve controlestructuren? De onleesbare code in het geval van CPS is voornamelijk het gevolg van de primitieve controlestructuren die niet meer het gewenste gedrag vertonen. Zo verliezen we de eigenschap van sequenti¨ele code aangezien na de aanroep van een asynchrone functie, direct de volgende regel code wordt uitgevoerd terwijl de asynchrone functie nog niet voltooid is. Sequenti¨ele code daarentegen voert pas de volgende instructie uit als de vorige functie volledig uitgevoerd is. Om die reden worden callbacks ge¨ıntroduceerd die uiteindelijk resulteren in meer code. Daarnaast leidt het gebruik van vele geneste callbacks als gevolg van sequenti¨ele, asynchrone operaties ook nog tot het callback-hell probleem. Dit probleem is duidelijk zichtbaar door de grote indentatiesprongen die de code maakt en draagt verder bij tot de onleesbaarheid van CPS. Verder zijn ook herhalingsstructuren niet meer bruikbaar in de aanwezigheid van asynchrone API’s. Om verschillende asynchrone operaties sequentieel na elkaar uit te voeren moet er gebruik worden gemaakt van recursie. Zo wordt de for-lus van codevoorbeeld 7.1 vervangen door de recursieve functie persist() in codevoorbeeld 7.3. Recursie kan een krachtig hulpmiddel zijn maar in dit geval maakt het een eenvoudige herhalingsstructuur totaal onleesbaar. Verderop zullen we nog zien dat recursie kan vermeden worden, door een bibliotheek te gebruiken met hulpfuncties voor CPS. Hoe wordt er aan error-handling gedaan? Het try-catch-statement dat standaard voorzien wordt voor error-handling volstaat niet meer bij asynchrone code via CPS. Een try-catch-statement is namelijk niet in staat om errors op te vangen die zich voordoen in een callback. Dit komt omdat de callstack al volledig afgebroken is vooraleer de callback wordt uitgevoerd. Daardoor krijgt het try-catch-statement de kans niet om fouten op te vangen die zich afspelen binnen functie-aanroepen hogerop de callstack. Opdat we de kans zouden hebben om fouten af te handelen die ontstaan zijn tijdens de asynchrone operatie wordt er als eerste argument een error-object meegegeven. Dit object moet dan ook telkens gecontroleerd worden op de aanwezigheid van een error. Indien dit het geval is zal het object niet gelijk zijn aan null en kan de asynchrone functie zelf eindigen met een error door de callback aan te roepen met het error-object zoals op regel 10 van codevoorbeeld 7.3. Error handling moet dus in bepaalde gevallen dubbel gebeuren zoals de functie db.persist() op regel 8 die blijkbaar zowel synchrone errors kan opgooien (dus een try-catch-statement) maar ook asynchrone errors kan opgooien (dus controle van het error-object op regel 9). Een
7.2 Continuation passing style
92
synchrone error doet zich voor vooraleer db.persist() intern zijn eerste asynchrone operatie heeft opgestart en kan bv. een verbroken connectie zijn met de databank. Asynchrone errors doen zich voor nadat db.persist() zijn eerste asynchrone operatie heeft opgestart en kan bv. het gevolg zijn van een fout binnen de databank zelf. Deze tweeledige error-handling met bijhorende boilerplate-code is een tijdsintensieve bezigheid en draagt verder bij aan de onleesbaarheid van de code. Ook wordt niet altijd duidelijk gedocumenteerd of een asynchrone API enkel asynchrone errors kan gooien of ook synchrone errors. In codevoorbeeld 7.3 hebben we de implementatie zodanig opgesteld dat enkel asynchrone errors mogelijk zijn. Dit onder de veronderstelling dat db.fetchTime() op regel 21 geen synchrone errors kan gooien, want dan zou nog een extra trycatch-statement nodig zijn. Het oproepen van de asynchrone functie backup() met bijhorende error-handling wordt gedemonstreerd in codevoorbeeld 7.4. 1 backup ( files , function ( err ) { if ( err ) {
2
console . log ( ' Een fout is opgetreden : ' + err );
3 }
4 5 });
Codevoorbeeld 7.4: Error-handling bij CPS
Hoe worden de verschillende synchrone delen van elkaar onderscheiden? Aangezien iedere continuation in een aparte callback wordt gedefinieerd, onderscheiden de verschillende synchrone delen zich door de aanwezigheid van de verschillende callback-functies. Aangezien de event-loop single-threaded is, wordt een callback-functie wordt per definitie synchroon uitgevoerd zonder onderbreking. Hoe kan er onderscheid gemaakt worden tussen sequenti¨ele en parallelle I/O-operaties? In het synchrone codevoorbeeld 7.1 worden alle I/O-operaties sequentieel overlopen door de aanwezigheid van de for-lus op regel 2. Om hetzelfde gedrag te verkrijgen bij CPS hebben we deze for-lus omgezet naar een recursieve functie persist(), die zichzelf aanroept op het ogenblik dat de vorige I/O-operatie voltooid is (regel 13). Indien we deze I/O-operaties parallel willen uitvoeren kunnen we gebruikmaken van een for-lus en een teller. De Node.js-community biedt via npm echter enkele bibliotheken aan die het gemakkelijker maken om meerdere I/O-operaties sequentieel of parallel uit te voeren. De populairste bibliotheek is Async [8] die allerhande hulpfuncties aanbiedt om asynchroon programmeren a.d.h.v. CPS iets pijnlozer te maken. Codevoorbeeld 7.5 geeft een voorbeeld van parallelle I/O-operaties a.d.h.v. Async door op regel 2 gebruik te maken van de methode async.parallel(). Het resultaat is zeker al beter, maar het kan niet
7.3 Event-based
93
op tegen alle voordelen die bv. promises bieden. Async biedt ook een functie async.series() om net zoals in codevoorbeeld 7.3 alle I/O-operaties sequentieel uit te voeren. Merk ook het gebruik van de hogere-orde functie map() op die we besproken hebben in §6.3.2. 1 var backup = function ( files , callback ) { async . parallel ( files . map ( function ( file ){
2
return function ( callback ) {
3
try {
4
db . persist ( file , callback );
5
} catch ( err ) {
6
callback ( err );
7 }
8 };
9
}) , function ( err ) {
10
if ( err ) {
11
callback ( err ); return ;
12 }
13 14
db . fetchTime ( function ( err , time ) {
15
if ( err ) {
16
callback ( err ); return ;
17 }
18 19
var msg = files . length + ' succesvol gepersisteerd om '
20
+ time ;
21
fs . appendFile ( ' logfile . txt ', msg , callback );
22 });
23 });
24 25 };
Codevoorbeeld 7.5: Parallelle I/O-operaties binnen CPS a.d.h.v. Async
7.3
Event-based
Deze paragraaf over event-based asynchroon programmeren is er eerder om volledig te zijn aangezien het gebruik van events eigenlijk geen enkel substanti¨ele verbetering brengt t.o.v. CPS. Het introduceert zelfs nog nieuwe problemen aangezien events meerdere keren kunnen optreden in plaats van een callback-functie die slechts ´e´en keer wordt aangeroepen. Node.js maakt dan
7.4 Promises
94
ook geen gebruik van dit pattern en de XMLHttpRequest API zoals besproken in 4.1 is dan ook ´e´en van de weinige JavaScript-API’s die hiervan gebruikmaakt. De continuation van een asynchrone functie wordt nu niet voorgesteld a.d.h.v. een callback-functie maar via een event. Dit maakt het mogelijk dat meerdere instanties tegelijk op de hoogte worden gebracht van de voltooiing van een asynchrone operatie. Het grote nadeel is dat men dan niet mag vergeten om de eventhandler te verwijderen. Wanneer dit niet wordt gedaan zullen event-handlers afgevuurd worden bij een volgende aanroep van de asynchrone functie waarbij dat hoogst waarschijnlijk niet de bedoeling is. Error-handling wordt aangepakt door een apart error-event te voorzien. We gaan deze vorm van asynchroon programmeren niet onderwerpen aan onze vooropgestelde criteria aangezien dezelfde nadelen van CPS nog steeds geldig blijven.
7.4
Promises
Het concept van een promise stamt af uit een artikel van Friedman en Wise uit 1976 [63] en is sindsdien in vele programmeertalen ge¨ımplementeerd. Soms wordt dit concept ook aangeduid als een future, een eventual, een deferred of een task. Een promise is een object dat een asynchrone operatie voorstelt en kan zich daarbij in drie verschillende states bevinden. Initieel bevindt een promise zich in de pending state die aangeeft dat de asynchrone operatie wordt uitgevoerd. Wanneer de asynchrone operatie succesvol is verlopen, zal het promise-object zich verplaatsen naar de fulfilled state, samen met het resultaat van de operatie. Indien er een error is opgetreden tijdens de uitvoering zal de promise in de rejected state terechtkomen, samen met de error die opgetreden is. Een promise is settled als ze zich in de rejected of de fulfilled state bevindt. Eens een promise settled, is kan ze niet meer veranderen van state; de rejected en fulfilled state zijn dus final states. De populairste bibliotheek voor promises in Node.js is Q [35], die een implementatie is van de Promises/A standaard van de CommonJS-groep. ES6 biedt met de Promise API ook native ondersteuning voor promises in JavaScript, waarbij deze API ook compatibel is met de Promises/A standaard. In deze masterproef zullen we voorbeelden geven a.d.h.v. de ES6 Promise API. Een asynchrone functie die gebruikmaakt van een promise zal geen callback opnemen in zijn lijst met argumenten, maar zal een promise teruggeven als resultaat. Een promise bevat dan enkele methodes om het resultaat van de asynchrone operatie op te vragen. De belangrijkste methode is de then(fulfillmentHandler, rejectionHandler) methode. Deze methode verwacht een fulfillment-handler en/of een rejection-handler waarlangs het mogelijk is om het resultaat of de error van de asynchrone operatie op te vragen. Wanneer de promise zich in de pending state bevindt, zal de fulfillment-handler aangeroepen worden als de asynchrone operatie succesvol is ge¨eindigd, met het resultaat als argument. Bij
7.4 Promises
95
het optreden van een fout zal de rejection-handler aangeroepen worden met een error-object als argument. Indien de then() methode wordt aangeroepen als de promise reeds settled is, zal de juiste handler-functie direct asynchroon aangeroepen worden met het juiste argument. Omdat een promise het resultaat van zijn asynchrone operatie bewaart, kan je ze gemakkelijk doorgeven aan andere functies, net zoals je het resultaat van een synchrone functie kan doorgeven. Deze eigenschap maakt promises veel krachtiger dan CPS aangezien deze niet over de mogelijkheid beschikken om hun state te bundelen in een object en door te geven. Naast de then() methode heeft een promise ook nog een catch() methode die kan gebruikt worden indien je enkel een eventuele error wil opvangen. Deze methode heeft dus dezelfde semantiek als de then() functie waarbij de fulfillment-handler op null wordt gezet. De methode then() geeft vervolgens zelf ook een promise terug als resultaat. Deze promise wordt fulfilled met het resultaat van de fulfillment-handler. Indien echter een promise teruggegeven wordt in de fulfillment-handler, zal deze teruggegeven worden als resultaat van then(). Omdat then() een promise teruggeeft als resultaat, kan hierop weer de methode then() aangeroepen worden om een nieuwe promise aan te maken. Deze eigenschap laat chaining toe waarbij meerdere asynchrone operaties sequentieel na elkaar uitgevoerd worden en het resultaat gebundeld wordt in ´e´en promise. Om na te gaan hoe deze promises in de praktijk werken hebben we onze synchrone code aangepast zoals zichtbaar in codevoorbeeld 7.6. Het resultaat is al stukken beter in vergelijking met CPS. In vergelijking met codevoorbeeld 7.5 is de code geslinkt van 25 naar 16 regels code. In dit voorbeeld maken we op regel 3 gebruik van reduce() om de array files dynamisch om te zetten naar een promise die bestaat uit een chain van asynchrone operaties. We maken daarbij gebruik van een overload van reduce() die een tweede argument aanneemt die gebruikt wordt als startwaarde voor de reductie. reduce() wordt in dit geval n keer aangeroepen voor alle n bestanden. Promise.resolve() geeft een promise terug die zich reeds in de fulfilled state bevindt met een leeg resultaat, en vormt de eerste promise in de chain. Dit voorbeeld toont de kracht van functioneel programmeren en duidt daarnaast op een goede complementariteit van functioneel programmeren en promises. In het vervolg van deze paragraaf zullen we de leesbaarheid toetsen a.d.h.v. onze vooropgestelde criteria. 1 var backup = function ( files ) { 2 3 4
return files . reduce ( function ( promise , file ) { return promise . then ( function (){ return db . persist ( file );
5 6 7
}) } , Promise . resolve ())
7.4 Promises
96
. then ( function (){
8
return db . fetchTime ();
9 10
})
11
. then ( function ( time ){ var msg = files . length + ' succesvol gepersisteerd om '
12
+ time ;
13
return fs . appendFile ( ' logfile . txt ' , msg );
14 });
15 16 };
Codevoorbeeld 7.6: Promises Hoe is de signatuur opgebouwd van de functie? De argumentenlijst van de asynchrone functie blijft hetzelfde als die van de synchrone functie. Dit is een duidelijk voordeel t.o.v. CPS waarbij een extra callback de signatuur van de functie vervuilt. Het resultaat van de synchrone functie wordt in de asynchrone functie verpakt in een promise. Indien er een error optreedt in de asynchrone functie zal dit ook verpakt worden in de promise die zich dan in de rejected state bevindt. De signatuur van het asynchrone equivalent ondergaat dus minimale wijzigingen waarbij de leesbaarheid bewaard blijft. Hoe zorgen we ervoor dat de functie over de nodige context beschikt? Net zoals bij CPS maken we hier gebruik van de eigenschap dat de fulfillment- en de rejectionhandler closures zijn. Door variabelen in een omvattende functie te declareren, bezitten deze closures over de nodige zichtbaarheid. Een voorbeeld hiervan is de array files die op regel 12 gebruikt wordt. Deze variabele is namelijk gedeclareerd in de omvattende functie backup(). Hoe gedraagt deze abstractie zich tot de primitieve controlestructuren? Het enige nadeel van promises is dat ze net zoals CPS niet toelaten om gebruik te maken van primitieve controlestructuren zoals de sequentie en de herhaling. Het aanroepen van meerdere promise-gebaseerde asynchrone functies na elkaar heeft als resultaat dat deze asynchrone operaties parallel verlopen en niet sequentieel volgens de sequentie-eigenschap van het gestructureerd programmeren. Er moet gebruik worden gemaakt van then() om meerdere promises te chainen en dus sequentieel uit te voeren. Het gebruik van then() introduceert echter geen callback-hell, wat een voordeel is t.o.v. CPS. Net zoals bij CPS hebben sequenti¨ele I/O-operaties in een lus nood aan recursie. Door gebruik te maken van de hogere-orde functie reduce() kunnen we dit vermijden. Hoe wordt er aan error-handling gedaan?
7.4 Promises
97
Een synchrone functie geeft een resultaat terug of gooit een error op in het geval van een fout. Bij een promise-gebaseerde asynchrone functie wordt dit gemapt op een promise die zich settlet in de fulfilled state of de rejection state. Een promise komt in de rejection state in het geval van een asynchrone error, maar daarnaast vangt hij ook synchrone errors op die in de fullfillment- of rejection-handler optreden. Dankzij deze eigenschap kunnen we zoals op regel 5 in codevoorbeeld 7.6 de methode db.persist() aanroepen zonder toevoeging van een try-catch-statement. Indien deze methode een synchrone error zou opgooien, zal deze door de implementatie van then() opgevangen en leiden tot een rejection van de promise. Net zoals bij synchrone code wordt de uitvoering van de volgende fulfillment-handlers in de promise-chain dan overgeslagen, tot aan de aanwezigheid van een rejection-handler in de chain. Een rejection-handler is het equivalent van een catch-statement bij het synchroon programmeren en kan ook via de functie catch() gedefinieerd worden zoals eerder al vermeld. Errors binnen promise-gebaseerde API’s resulteren altijd en alleen in een rejection wat een groot voordeel oplevert t.o.v. de tweeledige error-handling bij CPS. Ook is er geen nood aan boilerplate-code bestaande uit try-catch-statements zoals bij CPS het geval is. Een voorbeeld van error-handling bij de functie backup() a.d.h.v. promises is zichtbaar in codevoorbeeld 7.8. 1 backup ( files ) 2
. catch ( function ( err ) { console . log ( ' Een fout is opgetreden : ' + err );
3 4
}); Codevoorbeeld 7.7: Error-handling bij promises
Hoe worden de verschillende synchrone delen van elkaar onderscheiden? Iedere rejection- en fulfillment-handler wordt synchroon uitgevoerd zonder onderbreking, waarbij er een duidelijke visuele afbakening is tussen de verschillende onderdelen. In codevoorbeeld 7.6 kunnen we direct de verschillende onderdelen onderscheiden gaande van regel 2-7, regel 8-10 en regel 11-15. Hoe kan er onderscheid gemaakt worden tussen sequenti¨ele en parallelle I/O-operaties? Zoals eerder al vermeld, wordt in codevoorbeeld 7.6 de functie reduce() gebruikt om de verschillende promises te chainen en sequentieel uit te voeren. Indien we deze I/O-operaties parallel willen uitvoeren, kunnen we gebruikmaken van de functie Promise.all() die een array van promises aanneemt als argument. Deze functie geeft een promise terug die in de fulfilled state zal terechtkomen wanneer alle promises van de array fulfilled zijn. Vanaf het ogenblik dat er ´e´en promise van de array in de rejected state terecht komt, wordt de resulterende promise ook
7.5 Promises en generators
98
rejected. In codevoorbeeld 7.8 demonstreren we het gebruik van deze functie. Hier blijkt nog maar eens hoe het functioneel programmeren ons helpt bij het omzetten van de files naar een array van promises. 1 var backup = function ( files ) { return Promise . all (
2
files . map ( function ( file ){
3
return db . persist ( file );
4 5
}))
6
. then ( function (){ return db . fetchTime ();
7 8
})
9
. then ( function ( time ){ var msg = files . length + ' succesvol gepersisteerd om '
10
+ time ;
11
return fs . appendFile ( ' logfile . txt ' , msg );
12 });
13 14 };
Codevoorbeeld 7.8: Parallelle I/O-operaties en promises
Promises werken een groot deel van de boilerplate-code weg die ge¨ıntroduceerd werd door CPS. De signatuur van onze asynchrone functies winnen enorm aan duidelijkheid en error-handling gebeurt op een degelijke manier. Aangezien promise-gebaseerde API’s een resultaat teruggeven in de vorm van een promise, kan dit resultaat gemakkelijk doorgegeven worden aan andere functies. Is er dan nog ruimte voor verbetering? De combinatie van ES6-generator functies en promises die in de volgende paragraaf besproken wordt, zal ons terug naar het gewenste gedrag leiden van de primitieve controlestructuren. Daarnaast zal de boilerplate-code van promises door de aanwezigheid van de then() functie ook worden weggewerkt. Het wordt tijd voor de climax van dit hoofdstuk met het resultaat van dit laatste stukje onderzoek.
7.5
Promises en generators
ES6 voegt enkele langverwachte features aan JavaScript toe waaronder de reeds vermelde ondersteuning voor modules, klassen en arrow-functions. Daarnaast wordt er echter nog een heel interessante feature toegevoegd met de introductie van generator-functies of kortweg generators. Generators zijn op hun beurt een gelimiteerde vorm van coroutines die voor het eerste besproken werden door Conway in 1963 [60]. Een coroutine is een meer generieke vorm van een
7.5 Promises en generators
99
subroutine, wat binnen JavaScript een synoniem is voor een functie. Een functie heeft namelijk ´e´en beginpunt waar de uitvoering gestart wordt en kan slechts ´e´en eindpunt hebben tijdens zijn uitvoering. Eens een functie zijn resultaat teruggegeven heeft, is de functie-aanroep namelijk be¨eindigd. De uitvoering van een coroutine kan daarentegen opgeschort worden, waarbij de coroutine later hervat wordt op het punt waar hij tijdelijk gestopt was. In de implementatie van een coroutine kan bepaald worden wanneer hij zijn uitvoering opschort, en naar welke andere coroutine de controle wordt doorgegeven. Generators beschikken over dezelfde eigenschappen van coroutines maar kunnen na het opschorten van hun uitvoering, de controle enkel doorgeven aan de functie die de generator heeft aangeroepen. Generators laten op die manier toe om op een gemakkelijke manier het iteratorpattern te implementeren zoals we demonstreren in codevoorbeeld 7.9. Op regel 1 maken we de generator-functie aan met de naam iterator. Het sterretje na het function keyword geeft aan dat het om een generator-functie gaat. In deze functie declareren we een array met de namen van de developers van Zentrick en een aparte variabele met de naam van een stagiair. Daarna gebruiken we op regel 6 en regel 9 het yield keyword om de uitvoering van de functie op te schorten. Op regel 12 wordt vervolgens een generator-object aangemaakt met de naam gen door iterator() aan te roepen. Anders dan bij een gewone functie wordt de functie nu nog niet uitgevoerd; daarentegen wordt wel al de scope-chain vastgelegd. Een eerste deel van de functie zal pas uitgevoerd worden als de methode next() van het generator-object aangeroepen wordt. De eerste aanroep van next() zorgt ervoor dat de generator-functie zal worden uitgevoerd tot aan het eerste yield statement. Dit heeft als gevolg dat de twee variabelen op regel 2 en 3 worden aangemaakt om vervolgens de uitvoering op te schorten tijdens de eerste iteratie van de for-lus. Door een argument mee te geven aan het yield statement, kan er een tussentijds resultaat worden teruggegeven. Dit resultaat is beschikbaar via de methode next() van het generator-object die een object met de property’s value en done teruggeeft. Het tussentijds resultaat is beschikbaar via value, via done kan gecontroleerd worden of de generator-functie afgelopen is. Telkens we de mehode next() aanroepen, wordt de uitvoering verdergezet om weer te stoppen bij het volgende yield statement waar de controle opnieuw teruggegeven wordt aan de caller. De while-lus op regel 15 zal bijgevolg alle namen afdrukken van de developers en de stagiair om daarna te eindigen. Dit geeft ook aan de state van de variabelen developers, intern en i bewaard wordt in het generator-object, tussen de verschillende aanroepen door. 1 var iterator = function *() { 2
var developers = [ ' Ivan ' , ' Jan I ', ' Jan II ', ' Pieter ', ' Tim ' ];
3
var intern = ' Laurent ';
4
7.5 Promises en generators
100
for ( var i = 0; i < developers . length ; i ++) {
5
yield developers [ i ];
6 }
7 8
yield intern ;
9 10 }; 11
12 var gen = iterator () , result = gen . next ();
13 14
15 while (! result . done ) { 16
console . log ( result . value );
17
result = gen . next ();
18 } 19 20 // Uitvoer : Ivan , Jan I , Jan II , Pieter , Tim , Laurent . Codevoorbeeld 7.9: Iterator a.d.h.v. een generator-functie
Maar welk voordeel kunnen generators aanbieden in de context van asynchroon programmeren? Het antwoord op die vraag schuilt in de mogelijkheid van een generator-functie om zijn uitvoering te onderbreken. Asynchroon programmeren zoekt namelijk oplossingen om een taak op te splitsen in meerdere delen aangezien de event-loop niet mag blokkeren in de aanwezigheid van een I/O-request. Daarvoor hebben we gebruikgemaakt van functies in de vorm van een callback of een fulfillment-handler, die code bevat die uitgevoerd wordt bij de voltooiing van een I/O-operatie. Door gebruik te maken van het yield statement kunnen we de uitvoering van een asynchrone functie in de vorm van een generator-functie onderbreken. Op het ogenblik dat de I/O-operatie voltooid is, kan de asynchrone functie dan verdergezet worden. Deze I/O-operatie stellen we voor a.d.h.v. een promise zoals we gedaan hebben in de vorige paragraaf, waarbij we deze promise vervolgens meegeven als argument van yield. Aan deze promise kan dan een fulfillment-handler toegevoegd worden, die het vervolg van de generator-functie aanroept. Deze logica zullen we opnemen in een wrapper-functie async() die een generator-functie verwacht als argument, en deze omzet naar een promise-gebaseerde asynchrone functie. 1 var async = function ( generatorFunction ) { 2 3 4
return function () { var continuer = function ( method , arg ) { var result ;
7.5 Promises en generators
101
5 try {
6
result = generator [ method ]( arg );
7
} catch ( exception ) {
8
return Promise . reject ( exception );
9 }
10 11
if ( result . done ) {
12
return Promise . resolve ( result . value );
13
} else {
14
return result . value
15
. then ( fulfillmentHandler , rejectionHandler );
16 }
17 };
18 19
var generator = generatorFunction . apply ( this , arguments );
20 21 22
var fulfillmentHandler = continuer . bind ( null , ' next ' );
23
var rejectionHandler = continuer . bind ( null , ' throw ');
24 return fulfillmentHandler ();
25 };
26 27 };
Codevoorbeeld 7.10: Trampoline In codevoorbeeld 7.10 wordt een implementatie getoond van de functie async(). Deze functie verwacht zoals te zien op regel 1 een generator-functie als argument. async() geeft dan als resultaat een promise-gebaseerde asynchrone functie terug zoals te zien op regel 2. Deze functie moet dus hetzelfde gedrag vertonen zoals in de vorige paragraaf beschreven, daarbij gebruikmakend van de gewrapte generator-functie. Wanneer deze asynchrone functie aangeroepen wordt zal er eerste een functie continuer worden aangemaakt zoals te zien op regel 3. Daarna wordt de gewrapte generator-functie aangeroepen met de juiste invocation-context (this) en de juiste argumenten1 zoals aangegeven op regel 20. Op regel 22 en 23 passen we vervolgens parti¨ele applicatie toe op de functie continuer waarbij we het method argument initialiseren op respectievelijk 'next' en 'throw'. 1
JavaScript laat toe om de argumenten binnen een functie op te vragen via de variabele arguments, die deze
voorstelt via een array-achtige datastructuur.
7.5 Promises en generators
102
Daarna roepen we de net aangemaakte functie fulfillmentHandler() aan waardoor we binnen de functie continuer() terechtkomen op regel 4. Hier wordt op regel 7 de methode met de naam method aangeroepen van de generator-functie. Door de parti¨ele applicatie komt dit neer op het aanroepen van de methode next() op het generator-object, met een argument arg dat undefined is, aangezien we op regel 25 geen extra argumenten hebben vermeld. Daardoor wordt de gewrapte generator-functie uitgevoerd tot aan het eerste yield statement of tot het einde van de functie bij het optreden van een (impliciet) return statement. Een andere mogelijkheid is het optreden van een error binnen de generator-functie: deze wordt dan opgevangen binnen de continuer() functie en omgezet in een rejection zoals op regel 9. Indien de uitvoering van de generator-functie werd opgeschort via yield, moet dit statement ook een promise bevatten als argument, deze komt dan terecht in result.value. Op regel 12 controleren we vervolgens of de generator-functie volledig is uitgevoerd, dit is het geval indien er tijdens de uitvoering een (impliciet) return statement optrad. In dat geval geven we een promise terug in de fulfilled state met het argument van het result statement als resultaat, zoals regel 13 aangeeft. In het andere geval is dit slechts een tijdelijke opschorting van de functie en bevat result.value een promise. We verlengen deze promise door een fulfillment-handler en een rejection-handler te registreren en geven deze nieuwe promise terug als resultaat van de asynchrone functie. Indien deze promise vervolgens in de fulfilled state terecht komt, wordt de fulfillmentHandler() aangesproken met het resultaat van deze promise. Hierdoor komen we weer in de continuer functie terecht waarbij method weer de waarde 'next' zal hebben en arg zal het resultaat van de promise bevatten. Op regel 7 wordt dan opnieuw de methode next() van het generatorobject aangesproken, maar nu met het resultaat van de promise. Wanneer we next() met een argument aanroepen, wordt dit argument gebruikt als resultaat van het yield statement binnen de generator-functie. Dit laat dan toe om het resultaat van een promise in een variabele op te slaan waarna het vervolg van de generator-functie zal uitgevoerd worden tot aan het volgende yield of return statement. In het geval van een fout zal de promise echter naar de rejected state overgaan, waardoor de rejectionHandler() wordt aangeroepen. Dit komt neer op het aanroepen van de functie continuer() waarbij het argument method de waarde 'throw' heeft en arg overeenkomt met het error-object. Dit resulteert in het aanroepen van de methode throw() van het generatorobject met het error-object als argument. De methode throw() van het generator-object geeft aanleiding tot het opgooien van het error-object binnen de generator-functie. Deze fout kan dan binnen de generator-functie afgehandeld worden door een try-catch-statement. Indien deze error niet wordt afgehandeld, zal ze doorgegooid worden naar de caller, waardoor we terug op regel 7 van de continuer() functie terecht komen. Daar wordt ze dan wel opgevangen en omgezet naar
7.5 Promises en generators
103
een rejection van de promise op regel 9. Dit proces blijft doorgaan zolang er geen yield of return statement optreedt tijdens de uitvoering van de generator-functie. De techniek waarbij de promise-chain steeds verlengd wordt door het aanroepen van een generator-functie heet trampolining [56] en de constructie in codevoorbeeld 7.10 heet dan ook een trampoline. Nu rest ons enkel het gebruik van de async() wrapper te demonstreren door onze synchrone backup() functie om te zetten naar de equivalente asynchrone versie a.d.h.v. promises en generators. Het resultaat is zichtbaar in codevoorbeeld 7.11 en daarover valt wel wat te zeggen. Ten eerste zijn we er in geslaagd om alle boilerplate-code van CPS en in mindere mate van promises volledig weg te werken. We zijn terug aanbeland bij de originele 9 lijnen code van de synchrone versie. Deze asynchrone versie toont dan ook heel grote gelijkenissen met de synchrone versie waardoor we qua leesbaarheid eigenlijk even goed scoren als het oorsprokelijke voorbeeld. We maken dan ook een finale balans op door onze verschillende criteria te overlopen. 1 var backup = async ( function *( files ){ for ( var i = 0; i < files . length ; i ++) {
2
yield db . persist ( files [ i ]);
3 }
4 5 6
var time = yield db . fetchTime ();
7
var msg = files . length + ' bestanden gepersisteerd om ' + time ;
8
yield fs . appendFile ( ' logfile . txt ', msg );
9 }); Codevoorbeeld 7.11: Promises en generators
Hoe is de signatuur opgebouwd van de functie? Hier gelden exact dezelfde regels als asynchrone API’s gebaseerd op enkel promises en geen gebruikmaken van generators. De async() wrapper voegt een klein beetje visuele overhead toe, maar weet daarbij de schade te beperken. De aanwezigheid van het woord ‘async’ maakt het zelfs duidelijker dat het om een asynchrone functie gaat. Hoe zorgen we ervoor dat de functie over de nodige context beschikt? In tegenstelling tot CPS en promises wordt de code niet meer uiteengetrokken in aparte functies. We kunnen daardoor net zoals bij de equivalente synchrone code gebruikmaken van de local scope van de generator-functie. Daarbij zorgt het generator-object ervoor zorgt dat de state van de variabelen binnen de generator-functie bewaard blijft tussen de verschillende aanroepen door. De
7.5 Promises en generators
104
afwezigheid van deze aparte functies heeft als gevolg dat de asynchrone functie qua inhoud bijna dezelfde vormgeving heeft als zijn synchroon equivalent op enkele yield statements na. Deze eigenschap zorgt ervoor dat deze asynchrone code eigenlijk even goed scoort qua leesbaarheid in vergelijking met het oorspronkelijk voorbeeld. Hoe gedraagt deze abstractie zich tot de primitieve controlestructuren? De primitieve controlestructuren zijn terug in ere hersteld. Op regel 6 wordt bv. de methode db.fetchTime() aangeroepen die een promise teruggeeft. Deze promise wordt dan dankzij het yield statement doorgegeven aan onze trampoline binnen de async() functie. Wanneer de asynchrone operatie voltooid is, wordt zijn resultaat via de next() methode van het interne generator-object toegekend aan de variabele time. Dan pas zal de JavaScript-engine starten met het uitvoeren van regel 7 zoals de sequentie-eigenschap van het gestructureerd programmeren voorschrijft. Hetzelfde principe geldt voor de aanwezigheid van het yield statement binnen de for-lus op regel 3 waardoor de verschillende bestanden sequentieel na elkaar worden gepersisteerd. Hoe wordt er aan error-handling gedaan? Aangezien onze backup() functie net zoals in de vorige paragraaf een promise teruggeeft, wordt error-handling op dezelfde manier aangepakt zoals in codevoorbeeld 7.8. Wanneer de backup() functie echter wordt aangeroepen binnen een andere asynchrone functie die gebruikmaakt van generator-functies, kunnen we ook gebruikmaken van het try-catch-statement zoals in codevoorbeeld 7.12. De trampoline binnen de async() functie zorgt er dan voor dat alle synchrone en asynchrone errors resulteren in een fout die wordt opgegooid. De semantiek van het try-catchstatement in asynchrone code komt dus weer volledig overeen met die van synchrone code. 1 var callingFunction = async ( function *(){ try {
2
backup ( files );
3
} catch ( err ) {
4
console . log ( ' Een fout is opgetreden : ' + err );
5 }
6 7 });
Codevoorbeeld 7.12: Error-handling bij promises en generators Hoe worden de verschillende synchrone delen van elkaar onderscheiden? Dankzij de aanwezigheid van het yield keyword krijgen we een heel duidelijke, visuele indicatie van de plaatsen waar de uitvoering van een functie wordt gestaakt. Ieder stuk code dat zich tussen twee yield statements bevindt, zal synchroon uitgevoerd worden door de event-loop.
7.5 Promises en generators
105
Hoe kan er onderscheid gemaakt worden tussen sequenti¨ele en parallelle I/O-operaties? Indien we in codevoorbeeld 7.11 alle bestanden tegelijkertijd naar de databank willen persisteren, kunnen we net zoals in de vorige paragraaf gebruikmaken van de functie Promise.all(). Dit geeft ons dan een nieuwe promise terug die we kunnen meegeven als argument van het yield statement zoals in codevoorbeeld 7.13. 1 var backup = async ( function *( files ){ yield Promise . all (
2
files . map ( function ( file ){
3
return db . persist ( file );
4 }));
5 6 7
var time = yield db . fetchTime ();
8
var msg = files . length + ' bestanden gepersisteerd om ' + time ;
9
yield fs . appendFile ( ' logfile . txt ', msg );
10 }); Codevoorbeeld 7.13: Parallelle I/O-operaties met promises en generators Indien we beschikken over meerdere operaties die niet op dezelfde data inwerken kunnen we ook gebruikmaken van het yield keyword om aan task-parallellism te doen. In codevoorbeeld 7.14 worden op regel 2 en 3 twee asynchrone operaties parallel opgestart. Om het resultaat van de operaties later te kunnen ophalen wordt hun promise opgeslagen in een variabele. Op de plaats van regel 4 kunnen vervolgens instructies uitgevoerd worden die onafhankelijk zijn van het resultaat van deze twee asynchrone operaties. Op regel 5 wordt de huidige asynchrone functie dan onderbroken door de aanwezigheid van het yield statement en krijgt de event-loop de kans om andere taken verder af te handelen. Wanneer de asynchrone operatie voorgesteld door promise1 is voltooid, zal de functie task() verdergezet worden op regel 5 door het resultaat aan de variabele result1 toe te kennen. Op de plaats van regel 6 en 7 kunnen dan instructies uitgevoerd worden die gebruikmaken van deze variabele. Op regel 8 wordt de functie dan weer opgeschort tot het ogenblik dat ook de tweede asynchrone operatie voltooid is. Wanneer ook dit resultaat bekend is wordt de functie task() weer verdergezet en kunnen de laatste instructies op de plaats van regel 8 en 9 uitgevoerd worden. Deze instructies kunnen daarbij gebruikmaken van zowel result1 en result2. 1 var task = async ( function *(){ 2
var promise1 = task1 ();
3
var promise2 = task2 ();
7.6 Synopsis
106
4
// Hier kunnen onafhankelijke instructies uitgevoerd worden .
5
var result1 = yield promise1 ;
6
// Hier kunnen instructies uitgevoerd worden ,
7
// afhankelijk van result1 .
8
var result2 = yield promise2 ;
9
// Hier kunnen instructies uitgevoerd worden , // afhankelijk van result1 en result2 .
10 11 });
Codevoorbeeld 7.14: Task-parallellism met promises en generators Generator-functies zijn een feature uit ES6 en zijn enkel maar beschikbaar in de development branch van Node.js via de command-line optie --harmony_generators. Het zou echter zonde zijn om niet gebruik te maken van deze mooie abstractie voor asynchroon programmeren en daarom gaan we anders te werk. Het regenerator -project [16] van Facebook laat namelijk toe om JavaScript die gebruikmaakt van ES6-generators te transpilen naar geldige ES5-code. Deze ES5-code kan dan uitgevoerd worden binnen Node.js door eerst de runtime van regenerator in te laden die via een globale variabele beschikbaar wordt gesteld. Deze stap wordt dan toegevoegd in het buildproces via Grunt en de grunt-regenerator plugin. Ten slotte vermelden we ook nog dat er grote gelijkenissen zijn tussen de verschillende asynchrone abstracties die we hier besproken hebben en de ondersteuning voor asynchroon programmeren in .NET [9]. Het .NET-framework heeft zelfs ingebouwde ondersteuning voor het pattern dat we in deze laatste paragraaf besproken hebben. Daar wordt gebruik gemaakt van het await keyword i.p.v. yield en i.p.v. een async() wrapper-functie, wordt er gebruikgemaakt van een async keyword. Een promise binnen .NET is een instantie van de klasse Task.
7.6
Synopsis Wat is de krachtigste abstractie om asynchroon te programmeren binnen JavaScript?
De nood aan asynchroon programmeren wordt dikwijls beschouwd als een nadeel van het eventdriven concurrency model. In dit hoofdstuk gingen we daarom op zoek naar een abstractie die ons in staat stelt om leesbare asynchrone code te schrijven. We zijn daarbij gestart met een voorbeeld van synchrone code en hebben een lijst opgesteld met de verschillende criteria die bijdragen tot de leesbaarheid van deze code. Deze criteria stelden ons in staat om een goede vergelijking te maken tussen de verschillende abstracties. CPS is de standaard aanpak van Node.js en maakt gebruik van een extra callback in zijn argumentenlijst waarmee het vervolg van een asynchrone operatie kan vastgelegd worden. CPS is echter verre van optimaal door de aanwezigheid van een slecht leesbare signatuur en geen ondersteuning voor de primitieve controlestructuren. Daarnaast
7.6 Synopsis
107
is error-handling bij CPS een tijdsintensieve bezigheid en leidt CPS in het algemeen tot veel boilerplate-code. Bibliotheken zoals Async proberen hier oplossingen te bieden, maar botsen daarbij ook op de beperkte uitdrukkingskracht van CPS. De conclusie is dat CPS niet gebruikt zal worden gedurende de implementatie van deze masterproef. Vervolgens hebben we heel kort het bestaan van een event-based model vermeld, maar dit bood geen substanti¨ele voordelen t.o.v. CPS en introduceerde zelfs nieuwe problemen. De eerste grote doorbraak kwam er dan ook met het concept van een promise, waarmee we de asynchrone operatie gingen verpakken in een object. Dit object kan doorgegeven worden aan andere functies, zodat iedere ge¨ınteresseerde partij gemakkelijk op de hoogte kan gebracht worden van het resultaat van de asynchrone operatie. In tegenstelling tot CPS bieden asynchrone functies gebaseerd op promises een duidelijk leesbare signatuur en beschikken ze over een goed error-handling model. Ze weten ook al een heel deel van de boilerplate-code weg te werken die CPS introduceerde en vormen dus een goede kandidaat als asynchrone abstractie. Het enige nadeel is dan ook dat ze net zoals CPS geen ondersteuning bieden voor de primitieve controlestructuren. Dit laatste probleem werd opgelost door het gebruik van generator-functies die vanaf ES6 beschikbaar zijn. Zij laten toe dat een functie tijdelijk opgeschort wordt, wat ons de mogelijkheid geeft om een asynchrone functie uiteen te trekken in zijn verschillende synchrone delen. Daarbij wordt nog steeds het concept van een promise gebruikt om aan te geven wanneer het vervolg van de asynchrone functie wordt aangevat. Promises en generators vormen dus een gouden combinatie die asynchrone functies in staat stellen om opnieuw gebruik te maken van de primitieve controlestructuren. Ook weten zij de resterende boilerplate-code van promise-only asynchrone functies te elimineren, waarbij het uiteindelijke resultaat heel grote gelijkenissen vertoont met het synchrone equivalent. Om generator-functies om te zetten naar asynchrone functies werd daarbij gebruikgemaakt van een wrapper-functie die het concept van trampolining implementeert. De combinatie van promises en generators zal dan ook aangewend worden als asynchrone abstractie aan de server-side. Daarvoor wordt gebruikgemaakt van het regenerator-project van Facebook. Aan de client-side zullen enkel promises gebruikt worden aangezien regenerator transpileert naar ES5 i.p.v. ES3.
IMPLEMENTATIE
108
Hoofdstuk 8
Implementatie In de vorige vijf hoofdstukken gingen we op zoek naar een antwoord op de verschillende onderzoeksvragen. Bij het antwoorden van deze onderzoeksvragen, werden ook al enkele implementatiedetails vermeld. Zo werden de architectuur, de netwerkprotocollen en de webtechnologie bepaald. Deze componenten zullen nu gebruikt worden bij onze implementatie. Zoals in de vereistenanalyse werd vermeld, hebben we nood aan twee systemen. Ten eerste bouwen we in §8.1 een client- en servermodule voor onze gedistribueerde versie van het publish/subscribe-pattern. Daarna zullen we in §8.2 van deze module gebruikmaken om een observable model aan te bieden waarmee de real-time interactiviteit kan gedefinieerd worden. Om dit hoofdstuk af te sluiten, wordt in §8.3 het DigitasLBi-project besproken waarbij de real-time interactiviteit in de praktijk werd uitgetest.
8.1
Publish/subscribe
De implementatie start met een publish/subscribe-module voor de client en de server. De client-side module moet daarbij zowel binnen de Zentrick-omgeving draaien, als binnen een browseromgeving. Alle videoclients bevinden zich in de Zentrick-omgeving en nemen de rol op van subscriber waardoor ze op de hoogte worden gebracht van nieuwe events. Daarnaast kan de browsermodule gebruikt worden om vanuit een browseromgeving nieuwe events te pushen naar deze clients. We maken gebruik van het CommonJS-formaat voor het defini¨eren van de client-side module zodat deze in eerste instantie getest kan worden in Node.js. Daarna zullen we gebruikmaken van Browserify [11] om deze module om te zetten naar een browsermodule. Omzetting naar een Zentrick-module gaat ook via Browserify, waarbij we als laatste stap nog de juiste wrapper-code moeten toevoegen. Voor de server-side module maken we ook gebruik van de CommonJS-standaard zoals reeds vermeld in hoofdstuk 6.
8.1 Publish/subscribe
8.1.1
109
Client
Op figuur 8.1 zien we een klassendiagram van de client-side publish/subscribe-module. Bij het ontwerp wordt gekozen voor een klasse EventBus die we zullen exporteren als de publieke API. Deze klasse maakt vervolgens gebruik van het strategy-pattern om te bepalen welk netwerkprotocol de communicatie verzorgt met de eventservice. Het object dat instaat voor de communicatie met de eventservice heet het transport. Het transport kan daarbij een instantie zijn van de klasse LongPollingTransport of WebSocketTransport. Op het klassendiagram zien we dat deze klassen de interface Transport implementeren, in het geval van JavaScript is dit echter louter conceptueel. De interface Transport bestaat namelijk niet in de implementatie aangezien JavaScript dynamisch getypeerd is en daarbij gebruikmaakt van duck-typing zoals eerder al vermeld.
Figuur 8.1: Klassendiagram van client-side publish/subscribe-module
Een EventBus object wordt aangemaakt door de constructor aan te roepen met een options object die de property’s host, path en secure bevat. A.d.h.v. deze property’s wordt de volledige URL samengesteld om verbinding te maken met de eventservice. De property secure bepaalt daarbij of de verbinding over SSL zal plaatsvinden of niet. De belangrijkste methodes die EventBus voorziet, zijn publish(), subscribe() en unsubscribe(). publish() maakt het mogelijk om een nieuw event te triggeren op een bepaald channel en geeft een promise terug die zal aangeven of het publiceren succesvol verlopen is. Intern delegeert EventBus deze aanvraag door aan het huidige transport-object, zoals voorgeschreven door het strategy-pattern. Eventbus roept dus de publish() methode aan van het transport-object, die het event zal overmaken aan
8.1 Publish/subscribe
110
de eventservice. Binnen de klasse LongPollingTransport wordt daarbij gebruikgemaakt van een HTTP-POST-request via de XMLHttpRequest API. Maar zoals we in het vorig hoofdstuk hebben beschreven, is het voorstellen van een asynchrone operatie a.d.h.v. events eigenlijk een heel slecht idee. Daarom zullen we gebruikmaken van een wrapper rond een XMLHttpRequest zoals in codevoorbeeld 8.1, die deze API omvormt naar een promise-gebaseerde API. 1
function request ( method , url ) { return new Promise ( function ( resolve , reject ){
2
var request = new XMLHttpRequest ();
3 4
request . open ( method , url );
5 6
request . addEventListener ( ' load ', function (){
7
resolve ({
8
status : request . status ,
9
response : request . response
10 });
11 });
12 13
request . addEventListener ( ' error ' , function ( err ){
14
reject ( err );
15 });
16 17
request . send ( body );
18 });
19 20 }
Codevoorbeeld 8.1: HTTP-request a.d.h.v. een promise Daarbij maken we gebruik van de Promise() constructor die toelaat om een asynchrone API om te zetten naar een promise-gebaseerde asynchrone functie. We geven een functie mee aan de Promise() constructor die zal aangeroepen worden met een resolve() en een reject() functie als argumenten. In de load event-handler roepen we vervolgens deze resolve() functie aan om de promise naar de fulfilled state te brengen. Indien er zich een fout heeft voorgedaan, roepen we reject() aan zodat de promise in de rejected state terecht komt. Deze contructor laat verder ook toe om API’s van Node.js om te vormen van CPS naar een promise. Dankzij deze wrapper kunnen we nu gebruikmaken van deze request() API in de implementatie van de methode publish() binnen LongPollingTransport. Zoals te zien in codevoorbeeld 8.2 heeft de volledige
8.1 Publish/subscribe
111
implementatie van request() ook nog ondersteuning voor o.a. timeouts en het toevoegen van headers. Wanneer we een HTTP-response krijgen van de eventservice met statuscode 200, is het publiceren van het event geslaagd zoals volgt uit de implementatie van codevoorbeeld 8.2. 1 var publish = function ( channel , message ) { var endpoint = this . endpoint ;
2 3
return request ({
4 5
method : ' POST ' ,
6
url : endpoint + '/ ' + channel ,
7
headers : { ' Content - Type ': ' application / json '
8 },
9 10
timeout : 10000 ,
11
body : JSON . stringify ( message ) }). then ( function ( res ){
12
if ( res . status !== 200) {
13
throw new Error ( ' Publish error ');
14 }
15 });
16 17 };
Codevoorbeeld 8.2: Methode publish() van LongPollingTransport De methodes subscribe() en unsubscribe() werken analoog, waarbij EventBus het werk zal delegeren naar het transport-object door de overeenkomstige methode aan te roepen. subscribe() geeft daarbij ook een promise terug die aangeeft wanneer de subscriber zich succesvol heeft ingeschreven op het meegegeven channel. unsubscribe() laat vervolgens toe om zich uit te schrijven op een channel. EventBus biedt daarnaast ook nog de methode beforePublish() aan die toelaat om een hook te defini¨eren. Een hook laat toe om op bepaalde plaatsen binnen de code in te haken, zoals bij het template-method-pattern [64]. Het template-method-pattern laat echter enkel toe om hooks op een statische manier te defini¨eren, door te erven van een klasse. Binnen JavaScript kunnen we gebruikmaken van first-class functies om hooks op een dynamische manier toe te voegen. We kunnen beforePublish() meerdere keren aanroepen waarbij we telkens een functie meegeven. Deze functies worden intern opgeslagen in een array en zullen ´e´en voor ´e´en uitgevoerd worden wanneer de publish() methode wordt aangeroepen. Iedere hook krijgt channel en message als argumenten waarbij deze functie dan wijzigingen kan aanbrengen aan het message object. Dit
8.1 Publish/subscribe
112
laat bv. authenticatie toe door aan het message object een handtekening toe te voegen die de gebruiker authenticeerd. Wanneer een gebruiker zich heeft ingeschreven op een bepaald channel, wil hij vervolgens op de hoogte gebracht worden van alle events die met dit channel geassocieerd worden. Daarvoor maken we uiteraard gebruik van events, waarbij EventBus deze functionaliteit erft van de klasse EventEmitter die Node.js standaard voorziet. Deze API werd reeds besproken in §5.2 en hoeft dan ook geen verdere uitleg. EventBus biedt vervolgens een message event aan, waarvan de overeenkomstige event-handlers aangeroepen worden met argumenten die het channel en het eigenlijke bericht voorstellen. In codevoorbeeld 8.3 worden al deze functionaliteiten van de EventBus API gedemonstreerd. Op regel 1 maken we een nieuwe client aan die zich vervolgens inschrijft op het channel zentrick. Wanneer dit succesvol verloopt, worden regel 10 en 11 uitgevoerd waarbij de client op regel 11 zelf een event publiceert. De client zal op de hoogte gehouden worden van alle events op het channel zentrick dankzij de event-handler op regel 17 tot 19. 1 var client = new EventBus ({ 2
host : ' realtime . zentrick . com ',
3
path : '/ realtime ',
4
secure : true
5 }); 6 7 client 8
. subscribe ( ' zentrick ')
9
. then ( function (){
10
console . log ( ' Successfully subscribed to channel . ');
11
client . publish ( ' zentrick ', ' Hello everybody ! ');
12
})
13
. catch ( function (){ console . error ( ' Failed to subscribe to channel . ');
14 });
15 16
17 client . on ( ' message ', function ( channel , message ){ console . log ( ' Message received on ' + channel + ': ' + message );
18 19 });
Codevoorbeeld 8.3: EventBus-API De klasse EventBus delegeert een deel van zijn werk door aan het transport-object. Indien dit ob-
8.1 Publish/subscribe
113
ject een implementatie is van de klasse LongPollingTransport, maken de methodes publish(), subscribe() en unsubscribe() gebruik van het HTTP-protocol. De methode subscribe() maakt daarbij specifiek gebruik van de HTTP-long-polling techniek zoals beschreven in hoofdstuk 4. De klasse WebSocketTransport maakt voor de methodes publish(), subscribe() en unsubscribe() gebruik van slechts ´e´en enkele WebSocket-verbinding. EventBus zal daarbij altijd eerst een verbinding opzetten via LongPollingTransport aangezien HTTP-long-polling altijd werkt, zoals vermeld in §4.1.2. Daarnaast wordt er ook geprobeerd om een WebSocketverbinding te openen met de eventservice. Indien dit lukt, wordt de verbinding via HTTP-longpolling verbroken waarna een instantie van WebSocketTransport aangewend wordt als nieuw transport-object. We gebruiken hier de kracht van het strategy-pattern om het gedrag van een object at runtime aan te passen. Om de WebSocket API in alle browsers beschikbaar te maken, kunnen we gebruikmaken van de tweeledigheid van het Zentrick-platform zoals vermeld in §4.2.3. Moderne browsers maken gebruik van de HTML5-omgeving van de Zentrick-player en hebben daarbij directe toegang tot de WebSocket-API van de browser. Oudere browsers maken gebruik van de Flash-omgeving waar we de WebSocket-API kunnen implementeren in AS3. Binnen Flash kunnen we namelijk gebruikmaken van de Socket API van AS3 die toelaat om aan netwerkprogrammatie te doen binnen layer 4 van het OSI-model. Dankzij deze API kunnen we een client-side implementatie schrijven van het WebSocket-protocol die gebruikt kan worden binnen de Flash-omgeving. Om ons die moeite te besparen, starten we van een reeds bestaande implementatie dankzij het opensource project web-socket-js [47]. Opdat het WebSocket-protocol in alle Zentrick-apps zou kunnen worden gebruikt, moet de code van dit project gecompileerd worden in de Flash-versie van de Zentrick-player. De Zentrick-player wordt aangeboden via een CDN zodat deze overal ter wereld heel snel kan ingeladen worden. Daarbij moet de grootte van de Zentrick-player ook binnen aanvaardbare grenzen blijven om een goede performantie te garanderen. Dit vormt een belangrijke beperking waarmee we rekening moeten houden bij het toevoegen van WebSocket-ondersteuning. We kunnen de code van het web-socket-js project namelijk niet zomaar toevoegen aangezien dit leidt tot een build van de Zentrick-player die toeneemt met meer dan 50KB. De oorzaak hiervan is de ondersteuning voor SSL binnen het WebSocket-protocol. Het web-socket-js project biedt namelijk ondersteuning voor Flash-containers vanaf versie 10. In deze versie van Flash is er echter nog geen ondersteuning voor de SecureSocket API die toelaat om een SSL-verbinding op te zetten. Daarom bundelt dit project ook een SSL-socket implementatie, naast de eigenlijke WebSocket implementatie. De Flash-runtime heeft daarnaast ook geen toegang tot de rootcertificaten die in de browser zijn ingebouwd, waardoor deze ook moeten toegevoegd worden aan de SSL implementatie. Dit allemaal samen zorgt voor een aanzienlijke toename in grootte
8.1 Publish/subscribe
114
van de Zentrick-player na integratie van het web-socket-js project. Vanaf Flash Player 11 zijn SSL-sockets echter wel ingebouwd in Flash dankzij de SecureSocket API. Daarom zullen we het web-socket-js project aanpassen zodat gebruik wordt gemaakt van SecureSocket wat leidt tot een build die slechts 9KB groot is. Het nadeel is dat we enkel WebSocket over SSL kunnen aanbieden voor Flash Player 11 en hoger. Het gros van alle gebruikers beschikt echter reeds over Flash Player 11, waardoor dit slechts beperkte gevolgen heeft. Aangezien de Zentrick-player een build is voor versie 10.1 van Flash, gebruiken we reflection om na te gaan of SecureSocket aanwezig is at runtime. Een Zentrick-client die gebruikmaakt van een versie ouder dan Flash Player 11, kan daarbij geen beroep doen op WebSocket over SSL maar via HTTPS en long-polling is een beveiligde verbinding nog steeds mogelijk. WebSocket over SSL is naast het beveiligingsaspect ook belangrijk omdat het de kans vergroot op een succesvolle WebSocket-verbinding zoals reeds vermeld in §4.2.3. Het WebSocket-verhaal is echter nog niet ten einde aangezien Flash ook nog een speciale securitypolicy toepast op het gebruik van de Socket en SecureSocket klasse. Een Flash-client moet namelijk de goedkeuring krijgen van de server vooraleer hij een TCP- of SSL-socket kan opzetten. Wanneer een client een nieuwe TCP-connectie wil opzetten met een server via de klasse Socket, zal de Flash-container eerst een TCP-connectie maken met dezelfde server op poort 843. Daarna zal hij de string <policy-file-request/> versturen over de TCP-connectie. De server dient dan vervolgens te antwoorden met een policy file van hetzelfde type zoals vermeld in §4.1.2, waarna de verbinding verbroken wordt. Indien de client over de juiste eigenschappen beschikt zoals vermeld in de policy file, zal deze starten met het opzetten van de eigenlijke TCP-connectie. Indien de client een SSL-connectie wil opzetten, wordt hetzelfde patroon gevolgd maar zal de voorafgaande connectie ook over SSL plaatsvinden. Er moet dus nog een inspanning geleverd worden aan de server-side om WebSocket-connecties vanuit Flash mogelijk te maken, dit wordt besproken in §8.2.2.
8.1.2
Server
Aan de server-side kiezen we voor een ontwerp dat enige gelijkenissen vertoont met dat van de client-side zoals te zien op figuur 8.2. Zo hebben we ook een algemene EventBus klasse en twee klassen die het transport verzorgen: LongPollingTransport en WebSocketTransport. Daarnaast is er ook nog de MessageStore klasse die door alle andere klassen gebruikt wordt. De klasse EventBus vormt de publieke interface van deze module die de functionaliteit aanbiedt om een publish/subscribe-server op te zetten. De EventBus() constructor verwacht een options object met de property’s path, interval, redis en server. Via path kan het pad opgegeven worden waarop de eventservice bereikbaar is, standaard is dit een lege string. interval bepaalt de duur van een HTTP-long-poll interval en ook van het keepalive interval in het geval van WebSocket.
8.1 Publish/subscribe
115
redis is een object met de poort en de host van de Redis-instantie die dienst doet als eventbus en databank. Als laatste hebben we de property server waarmee men een HTTP-server kan meegeven die events zal genereren voor alle inkomende HTTP-requests.
Figuur 8.2: Klassendiagram van server-side publish/subscribe-module
In §5.2 maakten we reeds kennis met een HTTP-server binnen Node.js. Daar hebben we in codevoorbeeld 5.1 gezien dat een HTTP-request wordt voorgesteld door het request event. Binnen de klasse EventBus zullen al deze events dan ook gedelegeerd worden naar de klasse LongPollingTransport. Naast het request event beschikt een HTTP-server binnen Node.js ook nog over een upgrade event. Dit event wordt gebruikt om een HTTP-request aan te geven dat beschikt over een Upgrade header. Zoals vermeld in §4.2.1 wordt deze header gebruikt bij de HTTP opening-handshake van het WebSocket-protocol. Het gebruik van deze header wijst dan ook op een WebSocket-connectie waardoor deze events gedelegeerd worden naar de klasse WebSocketTransport. We merken nog op dat deze klassen geen implementatie vormen van het strategy-pattern zoals bij de client-side module. EventBus biedt verder de methodes beforePublish() en whilePublish() aan die toelaten om
8.1 Publish/subscribe
116
hooks te defini¨eren, volgens hetzelfde concept als bij de client-side module. Deze methodes zullen hun nut nog aantonen in §8.2.2 gedurende de implementatie van de services voor het observable model. Als laatste beschikt de klasse EventBus ook nog over de methodes handleRequest() en handleUpgrade(). Deze kunnen gebruikt worden om de inkomende verbindingen expliciet te delegeren i.p.v. een HTTP-server object mee te geven met de constructor. HTTP-long-polling Alle inkomende HTTP-requests worden door de klasse EventBus gedelegeerd naar de klasse LongPollingTransport via de methode handleRequest(). Node.js biedt iedere HTTP-request aan in de vorm van een event met een request en een response object zoals in codevoorbeeld 5.1. Het levert daarbij echter geen enkele functionaliteit aan op het gebied van routing. Daarvoor dien je als ontwikkelaar zelf de property request.url te parsen en routing-logica toe te voegen. Gelukkig bestaan er heel goede frameworks die deze taak voor ons opnemen en meer structuur geven aan webapplicaties. Express [15] is het bekendste framework van Node.js om webapplicaties op te bouwen en heeft daarvoor allerhande voorzieningen. We demonstreren de basisfunctionaliteiten a.d.h.v. codevoorbeeld 8.4 dat een vereenvoudigde versie biedt van een stukje code uit de klasse LongPollingTransport. 1 var express = require ( ' express ') , 2
server = express ();
3 4 server . get ( path + '/: channel / last ' , async ( function *( req , res ){ 5
try {
6
var channel = req . params . channel ;
7
var last = yield messageStore . last ( channel );
8 res . writeHead (200 , {
9
' Cache - Control ': 'no - cache , no - store '
10 });
11 12
res . end ( last );
13 14
} catch ( err ) {
15
// Internal server error .
16
res . status (500). end ();
17
}
18 })); Codevoorbeeld 8.4: Express-framework
8.1 Publish/subscribe
117
Alle inkomende HTTP-requests worden dus gedelegeerd naar de klasse LongPollingTransport. Die maakt in zijn constructor een Express-server aan op dezelfde manier zoals op regel 2 in codevoorbeeld 8.4. Dit server object biedt de mogelijkheid om een event-handler te defini¨eren voor iedere HTTP-methode en een bijhorend pad. Zo voegen we op regel 4 een event-handler toe voor alle HTTP-GET-requests die toekomen op het vermelde pad. Dit pad bestaat uit de prefix path die oorspronkelijk werd meegegeven aan EventBus() gevolgd door de naam van het channel en de string 'last'. De event-handler krijgt net zoals de klassieke HTTP-server van Node.js twee objecten mee die respectievelijk de HTTP-request en de HTTP-response voorstellen. Omdat we willen gebruikmaken de async/yield programmeerstijl a.d.h.v. promises en genenerator-functies, wordt deze event-handler gewrapped door de functie async() uit codevoorbeeld 7.10. Express laat toe om de onderdelen die samen het hi¨erarchische pad van een resource vormen te parametriseren. Wanneer een nieuwe subscriber zich aanmeldt voor een bepaald channel, zal de event-handler van codevoorbeeld 8.4 aangeroepen worden. Op regel 6 wordt dan het vermelde channel opgevraagd als een property van het object req.params dat door Express is toegevoegd. Vervolgens vragen we op regel 7 de id op van het laatste opgetreden event op dit channel. Per channel worden alle events namelijk oplopend genummerd met een id, startend bij 1. Deze id wordt opgevraagd aan het messageStore object, een data access object (DAO) dat toegang verschaft tot de Redis-databank. Aangezien messageStore een I/O-request moet doen om deze waarde op te vragen aan Redis, wordt een promise teruggegeven die de asynchrone operatie voorstelt. Via yield wachten we op het voltooien van de I/O-operatie, waarna het resultaat in de variabele last terecht komt. Tot slot wordt de HTTP-response opgebouwd door de statuscode te vermelden en de juiste Cache-Control header te vermelden. Deze resource mag namelijk niet gecachet worden aangezien hij op ieder moment kan veranderen door het optreden van een event. Als body van de response wordt last meegegeven waarna de HTTP-request volledige is afgehandeld. Indien er een fout optreedt, geven we de statuscode 500 op die duidt op een Internal Server Error. Wanneer de client deze HTTP-response ontvangt, zal dit leiden tot een fulfillment van de promise die aangeeft dat het inschrijven succesvol verlopen is. Tot slot moeten de events die gedelegeerd worden door de klasse EventBus nog tot bij de Express-server geraken. Dit gebeurt binnen de methode handleRequest() van de klasse LongPollingTransport waar het server object wordt aangeroepen met de argumenten request en response. Een Express-serverobject is namelijk een functie die aangeroepen kan worden met de argumenten request en response. Deze functie zorgt ervoor dat de request verder wordt verwerkt en leidt tot het aanroepen van de juiste event-handler. Daarnaast luistert de Express-server binnen de klasse LongPollingTransport ook nog naar HTTP-GET-requests die toekomen op het pad path + '/poll'. Dit pad wordt gebruikt als
8.1 Publish/subscribe
118
endpoint voor de long-polling techniek. Een client vermeldt daarbij alle channels waarop hij is ingeschreven door na dit pad ook nog een querystring mee te geven. Deze querystring bestaat uit ´e´en of meerdere koppels gevormd door een parameter en bijhorende waarde. Via de parameter wordt de naam van het channel meegegeven, via de waarde wordt de id van het volgende event bepaald. Indien de id van het laatste event dat een client heeft ontvangen op een bepaald channel gelijk is aan bv. 10, zal hij vervolgens 11 meegeven met de querystring. De klasse LongPollingTransport zal deze aanvraag delegeren naar de messageStore door de methode findAllFromWaiting(channels, interval) aan te roepen. Deze geeft een promise terug die als resultaat een object bevat met een array voor ieder channel waarop ondertussen events zijn opgetreden. Indien er ondertussen nog geen events zijn opgetreden op ´e´en van de channels, zal de messageStore wachten met de fulfillment van de promise. Deze promise zal dan in de fulfilled state terecht komen als er een event optreedt op ´e´en van de channels. Wanneer het vermelde interval is afgelopen, zal de promise zich ook naar de fulfilled state begeven, maar dan met een leeg object als resultaat. De client bepaalt dan de volgende waarden van de id’s voor de querystring a.d.h.v. dit resultaat. Tot slot bevat de Express-server binnen de klasse LongPollingTransport ook nog een event-handler voor een HTTP-POST-request waarmee een nieuw event kan gepubliceerd worden op een channel. Dit leidt tot het aanroepen van de asynchrone methode add(channel, message) van het messageStore object die dit event zal opslaan in de Redis-databank. Daarna zorgt de messageStore ook voor de distributie van het event naar de andere applicatieservers via de publish/subscribe-functionaliteit van Redis. WebSocket Node.js heeft standaard geen API die ondersteuning biedt voor het WebSocket-protocol maar npm biedt enkele heel goede implementaties aan waaronder ws [49]. In codevoorbeeld 8.5 geven we een klein stukje implementatie van de klasse WebSocketTransport en het gebruik van ws. Alle klassen in zowel de client-side en server-side modules worden gedefinieerd a.d.h.v. de constructor Class() zoals vermeld in §6.3.1 en bijlage A. Zo defini¨eren we de klasse WebSocketTransport op regel 4 waarbij we binnen de constructor op regel 6 een WebSocket-server aanmaken. Via de optie noServer geven we aan dat we zelf de inkomende HTTP-Upgrade-requests zullen delegeren naar de server. Daarnaast vermelden we ook nog het pad van de WebSocket-connecties, een nieuwe verbinding gericht naar een ander pad zal daarbij genegeerd worden. 1 var ws = require ( ' ws ') , 2
Class = require ( ' class ');
3 4 var WebSocketTransport = Class . extend ({ 5
constructor : function ( options ) {
8.1 Publish/subscribe
119
this . _server = new ws . Server ({
6 7
noServer : true ,
8
path : options . path });
9 10
// Nog ander werk .
11 12
},
13
handleUpgrade : function ( request , socket , head ) { this . _server . handleUpgrade ( request , socket , head ,
14
function ( socket ){
15
var client = new WebSocketClient ( socket , ...);
16 });
17 }
18 19 });
Codevoorbeeld 8.5: WebSocketTransport a.d.h.v. ws Inkomende WebSocket-connecties worden door EventBus doorgegeven via de handleUpgrade() methode van de klasse WebSocketTransport. Zoals we zien op regel 14 zal deze klasse vervolgens de methode handleUpgrade() van de WebSocket-server aanroepen. Deze methode verwacht als vierde argument een functie die uitgevoerd wordt met een socket object als argument. Dit object heeft dezelfde interface als de client-side WebSocket-API zoals besproken in §4.2.2. Dit socket object wordt gebruikt als argument bij het aanmaken van een nieuwe instantie van de klasse WebSocketClient. Deze klasse zal dan vervolgens een event-handler voor het message event toevoegen aan socket. Deze event-handler wordt aangeroepen wanneer een client zich wil inschrijven of uitschrijven op een channel, of wanneer hij een nieuw event wil publiceren. De klasse WebSocketClient maakt daarbij ook gebruik van de messageStore, op een analoge manier als de klasse LongPollingTransport. Indien deze operaties succesvol verlopen, zal de methode socket.send() aangeroepen worden om de client hiervan op de hoogte te brengen. Om de verschillende operaties van elkaar te onderscheiden bij het afgaan van de message eventhandler, wordt een message voorgesteld a.d.h.v. een JSON-object. Dit object wordt op de client aangemaakt en bevat een property type die aangeeft of het om een 'subscribe', 'unsubscribe' of 'publish' operatie gaat. A.d.h.v. dit type bepaalt de server welke acties hij vervolgens moet ondernemen, gebruikmakend van de andere data op het JSON-object. Omdat de server ook een bevestiging moet sturen naar de client, bevat iedere message ook een sequence number via de property seq. Wanneer een operatie succesvol is uitgevoerd op de server, wordt er vervolgens een message teruggestuurd naar de client. Deze message bevat dan een acknowledge number
8.1 Publish/subscribe
120
dat dezelfde waarde heeft als het oorspronkelijke sequence number. Indien we geen gebruik zouden maken van sequence numbers, zouden we gebruik moeten maken van een systeem zoals HTTP-pipelining [65] a.d.h.v. een FIFO-queue. Dit heeft het nadeel van head-of-line blocking [65] waarbij een langdurige operatie de acknowledgement van een sneller afgewerkte operatie kan tegenhouden. Om een client op de hoogte te brengen van een nieuw event wordt ook gebruikgemaakt van de methode socket.send(). In dat geval wordt er geen acknowledge number vermeld, aangezien een event geen antwoord is op een eerdere aanvraag van de client. Tot slot wordt socket.send() ook nog gebruikt om in de aanwezigheid van langdurige inactiviteit een keepalive bericht te sturen naar de client. Dit bericht is nodig aangezien tussenliggende proxy’s de eigenschap hebben om een TCP-connectie te be¨eindigen indien er gedurende een bepaalde tijd geen netwerkverkeer meer is, voor de ELB van Amazon is dit bv. standaard 1 minuut. Daarnaast stelt dit bericht de client in staat om te controleren of de server nog actief is. De client beantwoordt dit bericht vervolgens waardoor ook de server in staat is om een weggevallen client te detecteren. Wanneer de client niet op tijd antwoordt, zal de server de toegewezen resources voor deze client weer vrijgeven. MessageStore Als laatste bespreken we enkele van de belangrijkste concepten bij de implementatie van de MessageStore klasse. Deze klasse is een DAO die o.a. toelaat om de inhoud van ´e´en of meerdere events op te vragen, waarbij we deze inhoud een message noemen. Daarnaast is het ook mogelijk om een message op te vragen, waarvan het bijhorende event nog niet is opgetreden. Deze functionaliteit is mogelijk aangezien de klasse MessageStore op de hoogte wordt gebracht van nieuwe events via het publish/subscribe-mechanisme dat ingebouwd is in Redis. Het opvragen van een message is een asynchrone operatie die a.d.h.v. een promise wordt voorgesteld. Wanneer de klasse MessageStore op de hoogte wordt gebracht van een nieuw event via Redis, zal hij de overeenkomstige promises in de fulfilled state brengen. Eerst en vooral moet de keuze gemaakt worden voor een framework dat communicatie toelaat met een Redis-databank. We maken hier gebruik van het redis [29] project op npm, omwille van zijn uitstekende reputatie binnen de Node.js-community. Dit project biedt een interface aan om een Redis-instantie aan te spreken, gebruikmakend van CPS voor alle asynchrone functies. Aan de server-side zullen we gebruikmaken van Q [35] voor de omzetting van CPS naar promises aangezien dit project vele hulpfuncties aanbiedt om dit proces te vergemakkelijken. Vooraleer we kunnen starten met de implementatie van de klasse MessageStore moeten we echter ook nog bepalen hoe we alle messages zullen opslaan in de databank. Zoals al vermeld in §3.2.4 is Redis in essentie een key-value store waarbij we dus een opdeling
8.1 Publish/subscribe
121
moeten maken van onze data. De eventspace van ons systeem wordt in de eerste plaats opgedeeld door de verschillende channels van het publish/subscribe-pattern. Een channel is dus de ideale kandidaat om de rol op te nemen van key binnen Redis. Bij ieder channel horen dan verschillende messages met een bijhorend id. Daarbij willen we ´e´en of meerdere messages heel snel kunnen opvragen a.d.h.v. hun id. Tot slot moeten we ook nog op een performante manier de id van het laatste event kunnen opvragen. Rekening houdend met deze voorwaarden lijkt een hash de ideale datastructuur binnen Redis om dienst te doen als value. Binnen deze hash kunnen we dan iedere id afbeelden op een message. Om de id van het laatste opgetreden event op te vragen, voegen we ook de sleutel 'last' toe aan deze hash. Dit zorgt ervoor dat het opvragen van een message of de id van het laatste opgetreden event een tijdscomplexiteit heeft van O(1). De twee voornaamste operaties die de klasse MessageStore aanbiedt zijn het toevoegen van een nieuwe message en het opvragen van een message die op dat ogenblik zelfs nog niet hoeft te bestaan. Conceptueel komt het toevoegen van een nieuwe message overeen met het triggeren van een event binnen het publish/subscribe-systeem. We zullen het toevoegen van een nieuwe message demonstreren a.d.h.v. codevoorbeeld 8.6 dat een sterk vereenvoudigde versie is van de werkelijke implementatie. Op regel 5 verhogen we eerst de id van het laatste opgetreden event met 1 via de methode hincrby(). Het object redisClient is daarbij een promise-gebaseerde versie van de CPS-API die het redis project aanbiedt. redisClient geeft in principe enkel strings terug, maar om het codevoorbeeld niet te bevuilen, gaan we er vanuit dat de conversie naar het type number reeds is gebeurd. Nadat deze waarde ge¨ıncrementeerd is, wordt ze teruggegeven door de methode hincrby() en aan last toegekend. 1 var MessageStore = Class . extend ({ 2
// ...
3
add : async ( function *( channel , message ){
4
// Incrementeren van ' last ' en deze nieuwe waarde opvragen .
5
var last = yield this . redisClient . hincrby ( channel , ' last ', 1);
6 7
// Toevoegen van message en aanpassen van ' last '.
8
yield this . redisClient . hset ( channel , last , message );
9 10
// Publiceren van het event op de Redis eventbus .
11
yield this . redisClient . publish ( channel , message );
12
})
13
// ...
14 });
8.1 Publish/subscribe
122
Codevoorbeeld 8.6: Methode add() van MessageStore De variabele last bevat dan de id van de nieuwe message die moet worden toegevoegd. Dit gebeurt via de methode hset() waarmee we een nieuw koppel toevoegen aan de hash van het opgegeven channel. Daarbij doet last dienst als sleutel en vormt message de bijhorende waarde. Tot slot worden alle applicatieservers op de hoogte gebracht van deze wijziging via de methode publish() op regel 11. Zoals reeds vermeld, is de implementatie in codevoorbeeld 8.6 een sterk vereenvoudigde versie aangezien o.a. de beforeHooks niet worden uitgevoerd. Daarnaast wordt last verhoogd en dan pas een nieuwe message toegevoegd met deze id. Dit kan in zeldzame gevallen ervoor zorgen dat de server een message wil opvragen aan de databank die er (nog) niet is. Daarom wordt in de echte implementatie gebruikgemaakt van transactions binnen Redis. Tot slot maakt de methode add() ook nog gebruik van een cache. Dit wordt in het vervolg van deze paragraaf nog verder behandeld. De tweede belangrijke operatie die de klasse MessageStore ondersteunt, is het ophalen van een message met een bepaald id. Daarbij wordt er gebruikgemaakt van een cache om te controleren of deze message reeds aanwezig is. Messages lenen zich perfect tot caching aangezien ze immutable zijn. We maken daarvoor gebruik van een cache volgens het least recently used principe. Wanneer een LRU-cache volloopt, zal deze eerst de waarden verwijderen die het langst niet meer zijn opgevraagd. In onze implementatie maken we daarvoor gebruik van het lru-cache project [32] op npm. Wanneer een long-polling client de message met de volgende id opvraagt, zal de klasse MessageStore eerst kijken of deze message al aanwezig is in de cache, door gebruik te maken van de id. Indien dit niet het geval is, zal hij een promise aanmaken die als resultaat uiteindelijk de message zal bevatten. Deze pending promise wordt vervolgens aan de cache toegevoegd. Daarna wordt er gecontroleerd of het redisClient object reeds is ingeschreven op het channel geassocieerd met de message. Indien dit niet het geval is zal het redisClient object zich eerst inschrijven op dit channel zodat het event kan opgemerkt worden als het zich voordoet. Daarna zal het redisClient object controleren of de message zich nog niet in de databank bevindt. Het zou namelijk kunnen dat een event optreedt tussen twee long-poll intervallen van een client. Indien dit de enige ge¨ınteresseerde client is voor dit event, zal de overeenkomstige message zich dan ook nog niet in de cache bevinden. Als het object zich in de databank bevindt, wordt de promise uit de cache fulfilled met de verkregen message. Indien dit niet het geval is, wordt er gewacht tot het event optreedt en dan zal de event-handler van het redisClient object aangeroepen worden. Deze event-handler zal er vervolgens voor zorgen dat de promise uit de cache fulfilled wordt. Door dit systeem van caching toe te passen, wordt de databank slechts ´e´en keer aangeroepen
8.2 Observable model
123
voor ´e´en welbepaalde message. De volgende clients zullen namelijk direct gebruikmaken van de promise uit de cache om van het resultaat op de hoogte gebracht te worden. Stel dat deze cache niet aanwezig is en dat er 30000 subscribers zijn die gebruikmaken van HTTP-long-polling. Met een long-poll interval van standaard 30s, zou dit leiden tot een gemiddelde van 1000 requests per seconde voor de Redis-databank. Deze uiteenzetting sluit het deel af over de implementatie van de publish/subscribe-module. Verdere details zijn beschikbaar in de meegeleverde code van deze masterproef.
8.2
Observable model
In de vereistenanalyse kwamen we tot de conclusie dat het publish/subscribe-pattern een goede basis vormde voor het aanbieden van real-time interactiviteit. We hebben echter ook zijn gebreken opgemerkt als het gaat om het bewaren van een bepaalde state. Daarom introduceerden we het concept van een observable model waarbij de state bewaard wordt in een model. Een event van het publish/subscribe-pattern kan deze state aanpassen en een client moet dan ook over een mogelijkheid beschikken om op de hoogte gebracht te worden van veranderingen op het model. In dit deel zullen we de client- en server-side module bespreken die deze functionaliteit mogelijk maakt. Het gros van het implementatiewerk is echter achter de rug en deze modules zijn dan ook slechts een dun laagje boven de publish/subscribe-modules.
8.2.1
Client
Om de real-time interactiviteit aan te bieden binnen het Zentrick-platform, kiezen we voor ´e´en enkel object realtime. Dit object heeft drie oude bekenden als methodes: subscribe(), unsubscribe() en publish(). Tot daar komt de API overeen met de client-side EventBus API van het publish/subscribe-pattern. Wanneer een client zich inschrijft op een bepaald channel, is het resultaat van de promise echter niet undefined maar de huidige state van het model zoals te zien op regel 4 in codevoorbeeld 8.7. In dit voorbeeld wordt het model voorgesteld door het object person. De state van person wordt o.a. bepaald door zijn gewicht, voorgesteld a.d.h.v. de property weight. Op regel 7 wordt deze initi¨ele state opgevraagd en afgedrukt via de standaard uitvoer. 1 // We schrijven ons in op een channel . 2
realtime
3
. subscribe ( ' personchannel ')
4
. then ( function ( person ){
5
// We zijn succesvol ingeschreven op dit channel en vragen
6
// de huidige state op .
8.2 Observable model
124
console . log ( ' Initial weight : ' + person . weight );
7 8
// Wanneer de state veranderd ,
9 10
// worden we hiervan op de hoogte gebracht .
11
person . on ( ' change : weight ', function (){ console . log ( ' New weight : ' + person . weight );
12 });
13 14
});
15 16 // We publiceren een nieuwe waarde voor de state van het model . 17 18
realtime . publish ( ' personchannel ' , { weight : 80
19 20
}); Codevoorbeeld 8.7: Real-time interactiviteit a.d.h.v. het observable model
Om wijzigingen op het model op te vangen, wordt gebruikgemaakt van het observer-pattern via het change event. Zoals aangegeven op regel 11 kunnen we interesse tonen in veranderingen van een specifieke property door de string 'change:' en de naam van de property te concateneren. Wanneer een property van het model is veranderd, kunnen we deze verandering kenbaar maken zoals wordt aangetoond op regel 12. Om deze functionaliteit aan te bieden, maken we gebruik van de client-side publish/subscribe-module waarbij de implementatie terug te vinden is in bijlage B. Het komt er op neer dat we binnen de methode subscribe() van de klasse Realtime het werk delegeren naar de klasse EventBus. Zoals we hierna nog zullen bespreken, wordt de serverside zodanig geconfigureerd dat deze het model meegeeft wanneer een client zich succesvol heeft ingeschreven op een channel. Dit model wrappen we in een object van de klasse Model. Deze klasse schrijft zich dan vervolgens binnen zijn constructor in op de events van het vermelde channel. Wijzigingen worden in de message voorgesteld door een JSON-object, waarbij alle property’s worden overlopen en een change event wordt afgevuurd per property. De klasse Model en de klasse Realtime maken gebruik van slechts ´e´en EventBus instantie. Op die manier vermijden we nodeloze extra verbindingen met de server indien er meerdere Zentrick-apps gebruik zouden maken van de real-time interactiviteit. De publieke interface van deze module bestaat dan uit een instantie van de klasse Realtime waarmee de gebruiker direct aan de slag kan zoals in codevoorbeeld 8.7.
8.2 Observable model
8.2.2
125
Server
Bij het opzetten van de real-time services maken we gebruik van AWS zoals reeds vermeld in hoofdstuk 3. We gebruiken daarvoor het PaaS-model dankzij de Elastic Beanstalk service (EB) die AWS voorziet. EB omvat ook de configuratie van de Elastic Load Balancer (ELB) die de inkomende load zal distribueren over de verschillende applicatieservers. Daarbij stellen we de ELB in als een layer 4 load balancer die in staat is om zowel WebSocket-connecties als HTTPconnecties af te handelen. Aangezien we aan SSL-offloading willen doen zoals vermeld in §3.2.2, configureren we ook nog de private sleutel en het bijhorende certificaat van Zentrick op de ELB. Zentrick beschikt over een wildcard SSL-certificaat in de vorm van *.zentrick.com dat toelaat om alle directe subdomeinen van zentrick.com te beveiligen. Als domein voor de real-time services wordt gekozen voor realtime.zentrick.com. Achter de ELB bevindt zich dan een cluster van applicatieservers waarop onze applicatie gedeployed wordt. We kiezen hier voor een container die versie 0.10.26 van Node.js draait. Voor de EC2-instanties kiezen we voor het m3.medium type, een general purpose server die ook door Zentrick gebruikt wordt voor het aanbieden van zijn cloud-services. In §3.2.1 hebben we gezien dat een cluster van applicatieservers in AWS-termen een Auto Scaling groep heet. A.d.h.v. de Auto Scaling groep stellen we nog enkele parameters in die bepalen wanneer de cluster zal open neerschalen. Deze parameters moeten sowieso later nog gefinetuned worden afhankelijk van hoe de applicatie zich gedraagt onder een bepaalde load. Tot slot moeten we ook nog een Redisinstantie voorzien. Zoals vermeld in §3.2.3 biedt AWS hier ook een oplossing met de ElastiCache service. Binnen ElastiCache voegen we een cache cluster toe die bestaat uit 1 Redis-instantie die dienst zal doen als eventbus en databank voor onze applicatie. Cluster Vooraleer we overgaan tot het deployen van onze applicatie, bekijken we eerst nog de belangrijkste implementatiedetails van de real-time services. Ten eerste willen we dat onze applicatie gebruikmaakt van alle aanwezige processorcores die onze instantie levert. Zoals vermeld in §5.1.2 zal het event-driven concurrency model van Node.js slechts gebruikmaken van 1 processorcore. Daar hebben we ook vermeld dat het mogelijk is om de volledige capaciteit van de CPU te benutten door evenveel Node.js-processen als cores op te starten. Dit leidt tot een probleem als we willen luisteren voor inkomende TCP-connecties op dezelfde poort. Het besturingssysteem laat namelijk maar ´e´en proces toe per luisterende poort voor TCP-connecties. Node.js biedt hier gelukkig een oplossing dankzij de cluster module. Zoals codevoorbeeld 8.8 aantoont, laat deze module toe om verschillende worker processen te forken die in staat zijn om op dezelfde poort te luisteren. We maken daarbij gebruik van de module os om het aantal processorcores op te vragen. Vervolgens geven we op regel 8 het
8.2 Observable model
126
bestand op die de code bevat die moet uitgevoerd worden door de workers. Als laatste worden alle workers aangemaakt die de eigenlijke applicatielogica zullen uitvoeren voor onze real-time services. 1 ' use strict '; 2 3 var cluster = require ( ' cluster ') , os = require ( ' os ');
4 5
6 var workerCount = os . cpus (). length ; 7 8
cluster . setupMaster ({ exec : __dirname + '/ worker . js '
9 10 }); 11
12 // Fork all the workers . 13 for ( var i = 0; i < workerCount ; i ++) { cluster . fork ();
14 15 }
Codevoorbeeld 8.8: Cluster module
Zoals aangegeven in codevoorbeeld 8.8, gebeurt het eigenlijke werk van onze applicatie in de workers. Iedere worker zal een instantie van de klasse EventBus aanmaken die we besproken hebben in §8.1.2. Dankzij deze EventBus kunnen de verschillende clients gebruikmaken van het publish/subscribe-pattern om informatie in real-time te delen met elkaar. Er zijn echter nog enkele implementatieproblemen die moeten aangepakt worden zoals vermeld in §4.1.2 en §8.1.1. Cross-origin HTTP-requests In §4.1.2 hebben we aangegeven dat de XMLHttpRequest API binnen het Zentrick-platform een implementatie heeft in HTML5 en ´e´en in Flash. Daarbij hebben we vermeld dat cross-origin HTTP-requests enkel mogelijk zijn indien de server ondersteuning biedt voor CORS en een cross-domain policy file. Om deze functionaliteit mogelijk te maken, zullen we in onze worker een Express-server configureren die deze features mogelijk maakt. De ondersteuning voor crossdomain policy files is triviaal aangezien we daarvoor enkel een route moeten voorzien met het pad /crossdomain.xml die dan vervolgens dit bestand teruggeeft zoals te zien in codevoorbeeld 8.9.
8.2 Observable model
127
1 server . get ( '/ crossdomain . xml ', function ( req , res ){ res . writeHead (200 , {
2 3
' Content - Type ': ' application / xml ',
4
' Cache - Control ': ' public , max - age =86400 ' });
5 6
res . end ( crossdomain );
7 8 });
Codevoorbeeld 8.9: Ondersteuning voor cross-domain policy files Het toevoegen van CORS-ondersteuning is ook vrij triviaal dankzij het concept van middleware binnen Express. Om gebruik te maken van middleware biedt iedere Express-server de methode use() aan. Deze methode verwacht een functie met drie parameters: request, response en next. Deze middleware-functie is eigenlijk een hook die toelaat om een HTTP-request te onderscheppen vooraleer hij verwerkt wordt door ´e´en van volgende event-handlers. We defini¨eren deze functie dus vooraleer de request wordt doorgegeven aan de EventBus instantie. In codevoorbeeld 8.10 wordt de implementatie gegeven van deze functie. Op regel 2 en 3 worden de headers toegevoegd die aangeven dat GET- en POST-requests zijn toegelaten door alle origins. Een crossdomain POST-request wordt echter steeds voorafgegaan door een preflight OPTIONS-request aangezien deze methode het doel heeft om resources toe te voegen. Dit type HTTP-requests worden onderschept en beantwoord met een juiste HTTP-response die de nodige CORS-headers bevat. De overige HTTP-requests worden verder gerouteerd doorheen de Express-server door het aanroepen van de de methode next() op regel 19. 1 server . use ( function ( req , res , next ){ 2
res . header ( ' Access - Control - Allow - Origin ' , '* ');
3
res . header ( ' Access - Control - Allow - Methods ', 'GET , POST ');
4 5
// We onderscheppen een preflight request .
6
if ( ' OPTIONS ' === req . method ) {
7 8
var allowHeaders = req . headers [ ' access - control - request - headers ' ];
9 10
// We voegen de Acces - Control - Allow - Headers header toe
11
// indien nodig .
12
if ( typeof allowHeaders !== ' undefined ') {
13
res . header ( ' Access - Control - Allow - Headers ', allowHeaders );
8.2 Observable model
128
}
14 15
res . send (200);
16 17
}
18
else { next ();
19 }
20 21 });
Codevoorbeeld 8.10: Ondersteuning voor CORS
Policy file requests We zijn nu in staat om alle clients die gebruikmaken van HTTP-long-polling te bedienen. Daarnaast zijn er echter ook nog clients die gebruikmaken van WebSocket indien de netwerktopologie het toelaat. Zoals vermeld in §8.1.1 maken we daarbij gebruik van de tweeledigheid van het Zentrick-platform om het WebSocket-protocol aan te bieden in moderne browsers a.d.h.v. HTML5, en in oudere browsers dankzij een Flash-implementatie van het protocol. Deze Flash-implementatie vereist echter nog een extra inspanning aan de server-side. Iedere WebSocket-connectie vanuit Flash wordt namelijk voorafgegaan door een policy file request. De Flash-container zal daarbij een TCP-connectie opzetten op poort 843, waarna de string <policy-file-request/> verstuurd wordt. De server dient dan te antwoorden met dezelfde cross-domain policy file zoals hiervoor al besproken. Hierbij stelt er zich een probleem aangezien de ELB geen netwerkverkeer toelaat op andere poorten dan 25, 80 en 443. Indien de Flash-container er echter niet in slaagt om een connectie op te zetten op 843, zal hij na 3s een andere strategie aanwenden. Daarbij probeert hij om een TCP-connectie op te zetten op dezelfde poort als de eigenlijke TCP-connectie. In ons geval is dat poort 80 of 443 waardoor onze applicatieservers in staat moeten zijn om meerdere protocollen aan te bieden op dezelfde poort. Dit gaat uiteraard tegen de filosofie in waarvoor poorten ontworpen zijn, maar we hebben in dit geval geen andere keuze. Aangezien de ELB aan SSL-offloading doet, komt al het netwerkverkeer bij onze applicatieservers toe op poort 80. We moeten dus aan demultiplexing doen door bij een nieuwe TCP-connectie het HTTPen WebSocket-verkeer te scheiden van de policy file requests. Dit levert ook nog een extra argument op om gebruik te maken van WebSocket over SSL naast de reden vermeld in §4.2.3. Een webproxy zal namelijk een policy file request niet doorlaten op poort 80 aangezien het niet voldoet aan het HTTP-protocol. Een beveiligde tunnel zorgt ervoor dat het netwerkverkeer niet kan worden ge¨ınterpreteerd waardoor de policy file request wel kan doorgaan.
8.2 Observable model
129
Codevoorbeeld 8.11 toont aan hoe deze protocollen van elkaar gescheiden worden door gebruik te maken van een TCP-server. Node.js biedt namelijk de mogelijkheid om aan netwerkprogrammatie te doen op de transportlaag van het OSI-model dankzij de module net. Op regel 5 wordt een nieuwe TCP-server aangemaakt die luistert voor inkomende TCP-connecties op poort 80. Op regel 8 maken we een HTTP-server aan die de HTTP- en WebSocket-verbindingen verder zal afhandelen dankzij het request en het upgrade event. Vervolgens defini¨eren we een eventhandler voor inkomende TCP-connecties die worden voorgesteld door een socket object zoals te zien op regel 10. Dit object implementeert de Stream interface die binnen Node.js gebruikt wordt om op een handige manier om te gaan met streams van data. Een Stream erft van de klasse EventEmitter en bezit daarbij o.a. een readable event. Het readable event wordt afgevuurd als er een stuk data beschikbaar is om gelezen te worden. Op regel 12 gebruiken we dan de methode read() om de eerste byte van de nieuwe TCP-connectie te lezen. Indien deze byte overeenkomt met het karakter <, weten we dat het gaat om een policy file request aangezien een HTTP-request nooit start met dit karakter. In dat geval sturen we een antwoord terug in de vorm van een cross-domain policy file waarna de connectie gesloten wordt zoals aangegeven op regel 16. In het andere geval voegen we deze byte terug toe aan de stream, waarna we deze TCP-connectie delegeren naar de HTTP-server. De HTTP-server zal de request dan verder parsen om vervolgens een request of upgrade event af te vuren. Een request event zal afgehandeld worden door de Express-server in onze worker. Deze zorgt voor de ondersteuning van cross-origin requests voor zowel HTML5- als Flash-clients, waarna hij de request zal doorgeven naar de EventBus. Een upgrade event wordt direct doorgegeven aan de EventBus via de handleUpgrade() methode. Tot slot willen we nog vermelden dat deze methode enkel mogelijk is in de development branch van Node.js. We moeten hiervoor dus nog wachten tot versie 0.12 van Node.js uitkomt, waardoor we voorlopig enkel WebSocket kunnen aanbieden voor HTML5-clients. 1 var net = require ( ' net ') , 2
http = require ( ' http ');
3 4 // We luisteren voor inkomende TCP - connecties op poort 80. 5 var tcpServer = net . createServer (). listen (80); 6 7 // Daarnaast maken we ook een HTTP - server aan . 8 var httpServer = http . createServer (); 9 10 11
tcpServer . on ( ' connection ', function ( socket ){ socket . once ( ' readable ' , function (){
8.2 Observable model
130
var chunk = socket . read (1);
12 13 14
// 60 komt overeen met '<'
15
if ( chunk !== null && chunk [0] === 60) { socket . end ( crossdomain );
16
} else {
17 18
socket . unshift ( chunk );
19
httpServer . emit ( ' connection ' , socket ); }
20 });
21 22 });
Codevoorbeeld 8.11: Demultiplexing in Node.js
Model Als laatste onderdeel van de implementatie van de real-time services bespreken we de ondersteuning voor het observable model. Zoals we gezien hebben in §8.2.1 ontvangt de client een snapshot van het huidige model nadat hij zich heeft ingeschreven op een channel. Om dit mogelijk te maken, voorziet de server-side implementatie van het publish/subscribe-pattern de methode afterSubscribe() op de klasse EventBus. Deze methode laat toe om ´e´en of meerdere asynchrone hooks te defini¨eren die aangeroepen zullen worden nadat een client succesvol is ingeschreven op een channel. In §8.1.2 hebben we verder ook gezien dat opeenvolgende events van een channel een oplopend id krijgen waarmee ze worden opgeslagen in de Redis-databank. In de context van het observable model zorgt ieder event ervoor dat de state van het model wordt aangepast. Bij iedere id hoort dus een nieuw snapshot van het model. Wanneer de client zich inschrijft op een channel, vraagt hij de id op van het laatste opgetreden event. Binnen de afterSubscribe() hook willen we dan ook in staat zijn om de snapshot van het overeenkomstige model mee te geven met de response. Om deze functionaliteit mogelijk te maken, wordt er naast de MessageStore ook een ModelStore gedefinieerd. Deze houdt voor ieder id de huidige snapshot van het model bij, waarbij deze data in Redis op analoge manier wordt opgeslagen zoals bij de ModelStore. De klasse ModelStore biedt dan een methode find() aan waarmee een snapshot kan opgevraagd worden a.d.h.v. een channel en een id. In codevoorbeeld 8.12 zien we dan hoe de methode afterSubscribe() wordt ge¨ımplementeerd. We vragen op regel 2 eerst het model op, waarna we controleren of dit wel degelijk bestaat. Indien er nog geen enkel event is gepubliceerd op een channel, zal er namelijk ook nog geen model zijn. Indien het model niet bestaat in de databank, geven we een leeg object mee als model. Daarna voegen we
8.2 Observable model
131
dit model toe aan de response die vervolgens naar de client wordt verstuurd. 1
eventBus . afterSubscribe ( async ( function *( channel , last , response ){ var model = yield modelStore . find ( channel , last );
2 3
if ( model === null ) {
4
model = {};
5 }
6 7
response . model = model ;
8 9 }));
Codevoorbeeld 8.12: Toevoeging snapshot van model
Uiteraard moet het model ook ge¨ updatet worden indien er een event wordt gepubliceerd. Daarvoor maken we gebruik van de methode whilePublish() om een hook te defini¨eren die een nieuwe snapshot zal toevoegen in de ModelStore. Daarvoor zal hij eerst de vorige snapshot van het model opvragen uit de databank. Vervolgens zullen alle property’s overlopen worden van het object dat gepubliceerd wordt. De waarden van deze property’s worden op het model toegepast waaruit dan een nieuwe snapshot voortkomt. Deze snapshot wordt dan opgeslagen in de databank zodat het kan meegegeven worden met nieuwe subscribers. Tot slot willen we er nog op wijzen dat in §3.2.5 de mogelijkheid beschreven werd om gebruik te maken van een CDN om de snapshot op te vragen. We hebben hier in de implementatie echter niet voor gekozen, aangezien de client sowieso een HTTP-request doet naar het publish/subscribe-systeem bij het inschrijven op een channel. Daarbij kunnen we maar beter gebruikmaken van de mogelijkheid om het model mee te geven met de HTTP-response op deze request. Deployment We hebben nu de belangrijkste implementatiedetails overlopen van de real-time services waardoor we enkel nog ons resultaat moeten deployen. Eerst moet er een build gecre¨eerd worden in de vorm van een zip-bestand, waarna we dit kunnen uploaden en deployen op de applicatieservers. Voor deze build wordt gebruikgemaakt van Grunt zoals vermeld in §6.5 waarbij we naast mochaTest, jshint, clean en copy nog enkele tasks defini¨eren. Zo gebruiken we regenerator om onze ES6-generator functies te transpileren naar geldige ES5-code. Verder gebruiken we ook zip om alle bestanden te bundelen in een zip-bestand.
8.3 Project DigitasLBi
8.3
132
Project DigitasLBi
Om het hoofdstuk over de implementatie af te sluiten, bespreken we nog kort het real-time project dat werd uitgevoerd met het bedrijf DigitasLBi. DigitasLBi is een agency die zich bezighoudt met digitale marketing en technologie. In mei 2014 organiseerden ze het NewFront 2014 event waar allerlei marketeers kwamen spreken over de nieuwste ontwikkelingen van digital branding. Dit event werd ook ook uitgezonden als een livestream waarbij we samen met Zentrick de kans kregen om de real-time functionaliteit uit te testen op beperkte schaal. Daarbij hebben de ontwikkelaars van Zentrick een video verrijkt met drie interactieve apps zoals te zien op figuur 8.3.
Figuur 8.3: DigitasLBi NewFront interactieve video
De app linksboven gaf toegang tot een twitterwall waarop alle tweets verschenen waarin @Digitas werd vermeld. Daaronder bevond zich de social share app die toeliet om deze interactieve video op Twitter of LinkedIn te delen. De belangrijkste app in het kader van deze masterproef was echter deze rechtsboven. De inhoud van deze knop werd namelijk dynamisch aangepast wanneer een nieuwe spreker op het podium verscheen. Er was daarvoor ook een medewerker van Zentrick aanwezig op het live-event die a.d.h.v. een webpagina een event afvuurde indien er van spreker gewisseld werd. Door te klikken op de knop rechtsboven kon je dan meer informatie verkrijgen over de huidige spreker zoals te zien op figuur 8.4. Het triggeren van een nieuwe spreker ging eenvoudig door een nieuwe id af te vuren waarbij de Zentrick-client dan de bijhorende informatie kon afhalen via een HTTP-request naar de AWS CloudFront CDN (§3.2.5) a.d.h.v. deze id. De publisher-webpagina zoals te zien op figuur 8.5 bestond uit een eenvoudige UI die toeliet om a.d.h.v. een dropdown list een spreker te selecteren. Via de knop ‘Publish speaker’ was het dan
8.3 Project DigitasLBi
133
Figuur 8.4: DigitasLBi NewFront interactieve video
vervolgens mogelijk om het event te publiceren waardoor de inhoud van de knop rechtsboven bij alle videoclients werd aangepast. Het ging hier om een testcase die enkel het doel had om deze functionaliteit te demonstreren aan het bedrijf DigitasLBi. Het aantal simultane kijkers bleef dus eerder beperkt tot maximum 20 videoclients, het doel was dan ook niet om de schaalbaarheid van de real-time interactiviteit te gaan meten. Deze niet-functionele vereiste wordt namelijk aangepakt in het volgende hoofdstuk.
8.3 Project DigitasLBi
Figuur 8.5: DigitasLBi NewFront publisher-webpagina
134
PERFORMANTIE EN SCHAALBAARHEID
135
Hoofdstuk 9
Performantie en schaalbaarheid In dit hoofdstuk proberen we na te gaan hoe goed ons systeem scoort op het gebied van performantie en schaalbaarheid, twee begrippen die in respectievelijk §2.1.1 en §2.1.2 werden gedefinieerd. In §9.1 wordt de denkpiste uitgetest die we in §3.2.5 hebben besproken. Daarbij zullen we onderzoeken of het mogelijk is om onze load verder te distribueren over de verschillende edge servers van een CDN, daarbij gebruikmakend van HTTP-long-polling. Daarnaast zullen we in §9.2 de algemene performantie en schaalbaarheid van ons systeem meten a.d.h.v. load-testing.
9.1
Load-distributie
In §3.2.5 vermeldden we de mogelijkheid dat de edge servers van een CDN in combinatie met HTTP-long-polling een optie vormen om de load van onze applicatie verder te distribueren. Daarbij gingen we echter uit van twee veronderstellingen die moeten getoetst worden aan het werkelijke gedrag van CloudFront, de CDN-service van AWS. Ten eerste veronderstelden we dat een edge server geen nieuwe aanvraag zou sturen in het geval hij reeds een aanvraag heeft openstaan voor een bepaalde resource. Ten tweede vereisten we dat de edge server het antwoord op deze resource dan ook zou doorsturen naar alle clients die ondertussen een aanvraag deden voor diezelfde resource. We kunnen dit vrij eenvoudig testen door CloudFront tussen onze clients en de load balancer te positioneren. De edge servers van CloudFront zullen de HTTP-requests dan doorsturen naar de load balancer waar ze door ons reeds bestaande systeem verder worden verwerkt. Als test werken we met 100 clients waarbij het long-poll interval is ingesteld op 20s. Deze clients worden aangemaakt binnen ´e´en Node.js applicatie die draait op standaard hardware. Onze veronderstelling is dat deze 100 clients verbinding maken met de dichtstbijzijnde edge servers, die deze requests bundelen tot ´e´en HTTP-request per server naar onze load balancer. We spreken hier over meerdere edge servers aangezien een edge locatie beschikt over meerdere egde servers die samen de load afhandelen. Na 20s loopt het long-poll interval af en zullen onze publish/subscribe-
9.2 Load-testing
136
services een HTTP-response terugsturen. Deze response komt toe bij de edge servers die deze dan volgens onze veronderstelling doorgeeft aan alle clients. Om te weten hoeveel HTTP-requests de server ondertussen heeft afgehandeld, sturen we samen met de HTTP-response ook het totaal aantal afgehandelde requests tot op dat ogenblik. Wanneer we deze test echter uitvoeren, merken we direct op dat het gedrag van de CDN niet strookt met onze veronderstelling. Uit de resultaten van de test leiden we immers af dat slechts ´e´en client een HTTP-response ontvangt na 20s. Daarna worden er 2 nieuwe HTTP-requests verstuurd door de CDN, waarbij 23 clients het antwoord krijgen na 40s. Daarna volgt er 1 HTTP-request door de edge server waarvan het antwoord na 60s bij de volgende 12 clients toekomt. Tot slot wordt nog een laatste HTTP-request afgevuurd door de CDN waarbij het antwoord pas na 80s toekomt bij de overige 64 clients. Onze services moeten in totaal dus slechts 4 HTTP-requests afhandelen i.p.v. 100, maar we krijgen niet het gewenste en juiste gedrag in de plaats. Het gebruik van een CDN vormde een interessante piste om de load verder te distribueren maar onze veronderstellingen strookten niet met het werkelijke gedrag van CloudFront. Een CDN is in principe dan ook niet gemaakt voor zulke scenario’s en bijgevolg wordt deze piste geschrapt.
9.2
Load-testing
In deze paragraaf onderzoeken we de performantie en schaalbaarheid van ons ontworpen systeem. We zullen een testomgeving opstellen die toelaat om het gedrag van ons systeem onder load te monitoren. Daarbij wordt gebruikgemaakt van het publish/subscribe-systeem uit §8.1, zonder de toevoeging van het observable model uit §8.2. Het observable model bestaat namelijk enkel uit extra functionaliteit die geen invloed heeft op de performantie en de schaalbaarheid van de volledige architectuur.
9.2.1
Performantie
Zoals in §2.1.1 gedefinieerd, bestaat de performantie uit enkele verschillende karakteristieken zoals de doorvoercapaciteit en latency. Bij het opstellen van onze testomgeving hebben we gekozen om de latency te meten om twee verschillende redenen. Ten eerste is de latency gemakkelijk meetbaar aangezien deze enkel bestaat uit een verschil van twee tijdstippen, wat gemakkelijk op de clients te bepalen is. Ten tweede gaat latency ook hand in hand met het begrip real-time, waarbij gestreefd wordt naar een minimale latency. We zullen de latency meten door het publiceren van een event, waarbij dit event een timestamp bevat die het ogenblik van publicatie op de client aangeeft. Wanneer de subscribers het event ontvangen, vergelijken we deze timestamp met het huidige tijdstip waaruit we de latency kunnen
9.2 Load-testing
137
berekenen. Latency kan daarbij nog verder opgedeeld worden in verschillende componenten zoals de transmission latency tussen de verschillende architecturale componenten en de processing latency op onze applicatieservers. In het kader van deze masterproef bekijken we echter enkel de end-to-end latency aangezien een bespreking van de verschillende componenten ons te ver zou leiden. Om de latency te meten, hebben we dus nood aan verschillende clients die load genereren. We maken daarbij ook gebruik van AWS en zullen a.d.h.v. EB een cluster van servers defini¨eren die elk een vast aantal subscribers zullen huisvesten, deze noemen we de testcluster. We starten daarbij telkens met een klein aantal subscribers dat we langzaamaan laten toenemen. Wanneer dit aantal is toegenomen, vuren we een event af, waarna we de latency meten. Op die manier krijgen we een verband tussen het aantal clients en de overeenkomstige latency. De latency wordt hier bepaald door het gemiddelde te nemen van alle latency’s van alle clients samen. Om onze test aan te sturen, wordt er gebruikgemaakt van een client-applicatie die verschillende berichten stuurt naar de testcluster. Op die manier kunnen we het aantal clients laten toenemen, een event afvuren, en de resultaten terug opvragen. Deze resultaten worden dan lokaal weggeschreven naar een bestand, zodat ze later verder geanalyseerd kunnen worden. Een volledige test-run wordt aangestuurd door de client applicatie die zal starten met het toevoegen van 1 subscriber per server in de testcluster. We hebben gekozen voor een testcluster bestaande uit 10 EC2-instanties van het type m3.medium, het type dat we ook gebruiken voor ons publish/subscribe-systeem zelf. Dit resulteert in 10 subscribers die wachten op het optreden van een event. Nadat deze subscribers zijn toegevoegd, wordt er een bericht gestuurd naar ´e´en van de servers uit de cluster die een event zal afvuren richting het publish/subscribesysteem. Daarna wordt er 5s gewacht zodat alle clients het event kunnen ontvangen en zodat de gemeten latency kan opgevraagd worden aan de testcluster. De servers uit de testcluster communiceren onderling via een Redis-instantie, die uiteraard niet dezelfde is als deze van het publish/subscribe-systeem. Iedere server zal vervolgens het gemiddelde nemen van alle latency’s, waarna deze waarden worden verzameld door een server die nog eens het gemiddelde neemt van deze 10 bekomen waarden. Aangezien iedere server evenveel clients bevat, bekomen we hier dezelfde waarde als het rekenkundig gemiddelde van alle clients apart. Tot slot wordt deze waarde teruggestuurd naar onze client-applicatie die het resultaat wegschrijft naar een bestand. Daarna begint er een nieuwe iteratie waarbij er opnieuw 10 clients worden toegevoegd aan de testcluster. In totaal doen we acht test-runs waarbij we starten met een systeem bestaande uit 1 applicatieserver om te eindigen met een horizontaal geschaald systeem bestaande uit 8 applicatieservers. Waarbij we in de eerste test-run telkens 10 clients toevoegen per iteratie, worden er dat in de tweede test-run 20, enz. Op die manier voeren we altijd 80 iteraties uit per test-run, waarbij de toegevoegde load
9.2 Load-testing
138
per operatie afhangt van het aantal applicatieservers binnen ons publish/subscribe-systeem. Dit levert ons in totaal 8 grafieken met resultaten op die te vinden zijn in bijlage C.
Figuur 9.1: Cluster met 8 servers
Figuur 9.1 geeft de latency’s in het geval van een cluster bestaande uit 8 applicatieservers. Het resultaat is enigszins teleurstellend aangezien een cluster bestaande uit 8 servers een latency geeft van 1,5s in de aanwezigheid van ongeveer 4500 clients. Vanaf de 60ste iteratie (4800 subscribers) merkten we ook op dat niet alle clients het event nog ontvingen. Dit levert een vertekend beeld op i.v.m. de gemiddelde latency waardoor we deze resultaten niet meer hebben opgenomen in de grafiek. In het geval van lineaire schaalbaarheid zou dit tot de conclusie leiden dat de limiet voor 1 applicatieserver ligt op 4800/8 = 600 subscribers. Wanneer we echter deze grafiek in bijlage 9.1 bekijken, zien we dat de cluster bestaande uit ´e´en applicatieserver de test wel volledig heeft doorstaan. We hebben de test voor 1 applicatieserver dan nog verder uitgevoerd tot het ogenblik dat bepaalde subscribers geen event meer ontvingen en het systeem dus onbetrouwbaar werd. De grens lag in dat geval op 1000 subscribers, wat nog steeds een eind onder de verwachtingen ligt als we kijken naar de performantie van event-driven systemen in de praktijk. Kegel vermeldt in zijn artikel [44] namelijk systemen op standaard hardware die gemakkelijk de grens van 10000 simultane connecties doorbreken. Waar loopt het dan fout in ons systeem?
9.2 Load-testing
139
Ten eerste is een applicatieserver uit ons systeem ook in staat om meer dan 1000 simultane connecties te onderhouden, maar dit is niet hetgeen we getest hebben. Wij hebben namelijk het gedrag van ons systeem getest in de aanwezigheid van een piekbelasting die optreedt bij het afvuren van een event. In het geval van ´e´en applicatieserver en 1000 subscribers moet de server 1000 events over de netwerkinterface verzenden in een heel korte tijd. Als we 1KB rekenen voor een HTTP-response is dit nog altijd slechts 1MB aan data die moet worden verstuurd. Met de huidige netwerkinterfaces en netwerksnelheden duurt dit dan ook slechts een fractie van een seconde. De processorbelasting bleef daarnaast ook steevast onder de 50% gedurende iedere test-run, waardoor deze component ook niet direct een bottleneck lijkt te vormen. Dit is ook niet zo verwonderlijk aangezien ons systeem vooral I/O-gebonden werk doet en geen CPUintensieve berekeningen. Het geheugenverbruik van iedere instantie bleef verder ook mooi binnen aanvaardbare grenzen. We kunnen enkel concluderen dat verder onderzoek van het systeem en de Amazon-infrastructuur nodig zal zijn om de performantie-bottleneck op te sporen.
9.2.2
Schaalbaarheid
Naast de performantie willen we ook de schaalbaarheid van ons systeem bekijken. Schaalbaarheid werd in §2.1.2 gedefinieerd als het verband tussen de resources van een systeem en de capaciteit die daar mee overeenkomt. In onze test komt dit overeen met het verband tussen het aantal applicatieservers en het overeenkomstige aantal subscribers dat hiermee kan onderhouden worden. Om dit te onderzoeken, bepalen we eerst voor iedere cluster van de 8 geteste clusters wat het aantal subscribers zijn die overeenkomen met een vastgelegde latency. We maken hierbij de keuze voor een latency van 500ms wat in termen van real-time interactiviteit zeker aanvaardbaar lijkt. Aangezien we enkel beschikken over een puntenwolk moeten we eerst nog een methode kiezen om een bijpassende curve te bepalen. Deze curve kan dan gebruikt worden om bij een y-waarde van 500ms, de overeenkomstige x-waarde te bepalen. Uit de vorm van de puntenwolken bij iedere grafiek, zouden we kunnen vermoeden dat het hier gaat om een lineair verband. Dit kan natuurlijk niet kloppen aangezien we uiteindelijk zullen botsen op de limiet van onze hardware indien het aantal subscribers blijft toenemen. We willen deze puntenwolk dan ook niet inpassen in een bepaald model, maar enkel de meest waarschijnlijke waarde bepalen die overeenkomt met 500ms. Daarom zullen we gebruikmaken van niet-parametrische regressie a.d.h.v. de LOESS-methode [59]. Op die manier krijgen we een curve zoals zichtbaar op 9.1. A.d.h.v. deze curve bepalen we dan het aantal subscribers dat hoort bij een latency van 500ms. Als we deze waarde bepalen voor iedere cluster, bekomen we uiteindelijk de grafiek van figuur 9.2. Ook hier beantwoordt het resultaat niet direct aan de verwachtigingen aangezien we eerder een lineair verband hadden verwacht door onze keuze voor een horizontaal schaalbare architectuur.
9.2 Load-testing
140
Figuur 9.2: Schaalbaarheid
De punten zoals te zien op figuur 9.2 vertonen echter geen eigenschappen van een lineair verband. De laatst geteste servercluster toont zelfs het verschijnsel van negatieve schaalbaarheid wat heel bizar is. Wat kan de oorzaak zijn van dit onverwachte resultaat? Als we kijken naar de grafiek voor 8 applicatieservers (figuur 9.1) merken we dat er naar het einde toe een grotere spreiding is van de resultaten in vergelijking met de andere clusters. Dit kan duiden op een onbetrouwbare test die onderhevig was aan andere factoren. Zo werden deze testen uitgevoerd op virtuele servers die niet gebruikmaken van dedicated hardware. Dit heeft als gevolg dat de performantie van een virtuele server be¨ınvloed wordt door de andere virtuele servers die op dezelfde hardware draaien. In dat opzicht zouden extra testen meer duidelijkheid kunnen brengen. Als we kijken naar servercluster 2 tot 7 lijkt het alsof een toename aan resources langzaamaan minder invloed uitoefent op de belastingscapaciteit. Hier kunnen we proberen te bepalen welke factoren al zeker niet meespelen, en welke aanleiding geven tot verder onderzoek. Ten eerste is de architectuur volledig ontworpen met het oog op parallellisatie. Dit uit zich in de verschillende applicatieservers die nagenoeg altijd onafhankelijk van elkaar opereren. De eventbus is dan ook de enige component in de architectuur die niet geparallelliseerd is. Maar
9.2 Load-testing
141
door het gebruik van caching op de applicatieservers wordt deze nauwelijks belast. Als we kijken naar de statistieken die AWS ons aanlevert, zien we een processorbelasting van maximum 2% en nooit meer dan 30 gelijktijdige TCP-connecties1 . Dan blijft er nog slechts ´e´en component over die invloed kan hebben op de schaalbaarheid, namelijk de ELB. Deze component heeft een aantal eigenschappen die in bepaalde gevallen aanleiding kunnen geven tot een verminderde schaalbaarheid zoals [14] aangeeft. Ten eerste kan de ELB een heel grote load aan, zolang die gestaag stijgt. De ELB kan daarbij niet goed om met piekbelastingen, waarbij onze use case duidelijk een vorm van piekbelasting is. Daarbij is het mogelijk om een aanvraag naar Amazon te sturen om pre-warming toe te passen op de ELB. De ELB bestaat namelijk zelf ook uit meerdere instanties, waarbij pre-warming ervoor zorgt dat er genoeg instanties opgestart zijn om de verwachte load af te handelen. Ten tweede kunnen er ook problemen optreden indien alle inkomende verbindingen van eenzelfde IP-adres komen. De ELB werkt namelijk volgens een round-robin algoritme waarbij clients met hetzelfde IP-adres naar dezelfde applicatieserver worden geleid. Aangezien onze testcluster uit slechts 10 servers bestond, zal het effect hiervan groter worden naarmate er meer applicatieservers worden opgestart. Alle subscribers van ´e´en server uit de testcluster bezitten namelijk hetzelfde IP-adres. Naarmate er meer applicatieservers zijn, vergroot dan ook de kans op een onevenwichtige verdeling van de load. Vanaf het ogenblik dat er meer applicatieservers zijn dan testservers, heeft het vergroten van de cluster applicatieservers zelfs geen enkele positieve invloed meer. In het beste geval zal de load van de verschillende testservers bij allemaal verschillende applicatieservers terechtkomen, waarbij de overtollige applicatieservers geen enkele request request te verwerken krijgen. Vooral deze tweede reden lijkt een heel waarschijnlijke oorzaak van de verminderde schaalbaarheid. We willen daarbij ook nog opmerken dat een verhoging van het aantal applicatieservers gepaard ging met een verhoging van het aantal subscribers, aangezien we uitgingen van lineaire schaalbaarheid. Dit heeft als gevolg dat een onevenwichtige verdeling van de load kan leiden tot negatieve schaalbaarheid zoals te zien op figuur 9.1. Meer testservers waren aangewezen om deze effecten uit te sluiten, ook omdat de kans bestaat dat de testservers zelf een bottleneck vormden vanaf een bepaalde load. Op het ogenblik dat de testen zijn uitgevoerd, waren meer testservers echter niet mogelijk. AWS heeft namelijk een quota van maximum 20 EC2-instanties waarbij een speciale aanvraag nodig is om dit aantal te verhogen.
1
De klasse MessageStore heeft drie verbindingen openstaan met Redis: 1 voor de publisher, 1 voor de subscriber
en 1 voor lees- en schrijfoperaties.
BESLUIT
142
Hoofdstuk 10
Besluit In deze masterproef gingen we op zoek naar een oplossing om real-time interactiviteit toe te voegen binnen het Zentrick-videoplatform. In de vereistenanalyse hebben we deze functionaliteit onderverdeeld in twee grote delen. Een eerste deel bestond daarbij uit het ontwerp van een eventdriven architectuur die ondersteuning biedt voor het publish/subscribe-pattern. De videoclients nemen hierbij de rol op van subscriber en schrijven zich in op een channel dat bij een livestream hoort. Tijdens het verloop van de livestream kunnen vervolgens events afgevuurd worden door een publisher. Op die manier hebben de interactieve apps van de subscribers de mogelijkheid om in te spelen op de gebeurtenissen binnen de video. Het tweede deel van de functionaliteit maakt gebruik van het publish/subscribe-systeem om het concept van een observable model aan te bieden. De videoclients vragen daarbij in het begin de initi¨ele state van het model op waarna veranderingen van deze state voorgesteld worden als events. In de vereistenanalyse werden daarnaast ook de begrippen performantie en schaalbaarheid gedefinieerd. De performantie van het systeem draagt toe aan de ‘real-time’ eigenschap van de interactiviteit, de schaalbaarheid bepaalt hoe het systeem zal reageren op een grotere belasting. Na de vereistenanalyse gingen we op zoek naar de verschillende componenten van de architectuur voor ons probleem. Iedere component diende daarbij ondersteuning te bieden voor horizontale schaalbaarheid en een vorm van failover. Daarbij werd gekozen om gebruik te maken van de cloudinfrastructuur van AWS, het cloudplatform waarvan Zentrick reeds gebruikmaakt. Als eerste werd beslist dat de eigenlijke functionaliteit zal uitgevoerd worden door een cluster van applicatieservers. De videoclients maken daarbij verbinding met ´e´en van de servers a.d.h.v. een load balancer, waarbij gekozen werd voor de ELB-service van AWS. Om communicatie tussen de verschillende applicatieservers mogelijk te maken, werd Redis ingeschakeld als eventbus. Redis biedt daarnaast ook een databank aan, die gebruikt kan worden om de events en het model op te slaan. Als afsluiter werd ook nog het idee geopperd om gebruik te maken van een CDN om
BESLUIT
143
de load van de videoclients te verdelen over verschillende edge servers. Vervolgens gingen we op zoek naar mogelijkheden binnen het web om events te pushen van de server naar de client. Daarbij werd uiteindelijk de keuze gemaakt voor een combinatie van WebSocket en HTTP-long-polling. WebSocket is zonder twijfel het meest geschikte protocol voor het real-time web. In de aanwezigheid van verouderde proxy’s is het echter nog niet altijd bruikbaar waardoor we een fallback nodig hebben. HTTP-long-polling is dankzij zijn eenvoud een ideale kandidaat die binnen iedere netwerktopologie werkt. Na de bespreking van de netwerkprotocollen die real-time web mogelijk maken, was het tijd om de webserver zelf aan te pakken. De verzameling subscribers die gebruikmaken van de real-time interactiviteit kan namelijk heel groot zijn. Daarom hadden we nood aan een webserver die een goed concurrency model aanbiedt om al deze inkomende verbindingen te handhaven. Het event-driven model vermijdt daarbij de problemen van multithreading waarbij de vele threads voor extra overhead zorgen en aanleiding geven tot een foutgevoelig programmeermodel. Eventdriven concurrency gebruikt daarvoor een single-threaded event-loop waarbij alle I/O-operaties non-blocking zijn en asynchroon behandeld worden. Vervolgens werd gekozen voor Node.js als moderne implementatie van dit model. Daarbij wordt gebruikgemaakt van het single-threaded en event-driven karakter van de JavaScript-programmeertaal. JavaScript is echter niet ontworpen met het oog op grootschalige projecten waardoor het enkele cruciale concepten mist. We hebben daarom AMD en CommonJS besproken die elk op hun manier het probleem van modulariteit aanpakken. Vervolgens hebben we onze programmeerstijl in JavaScript verder ontwikkeld door een bespreking van zijn mogelijkheden tot objectgeori¨enteerd en functioneel programmeren. Daarbij werd ook een abstractie ontwikkeld om het concept van een klasse te kunnen gebruiken binnen JavaScript. Daarna werd kwaliteitscontrole bekeken a.d.h.v. linting en testing en hun bijhorende frameworks. Opdat deze tools vervolgens op een structurele manier zouden toegepast worden gedurende de ontwikkelingsfase, werd er geopteerd om Grunt te gebruiken als build-tool. Als laatste stap v´ o´ or het eigenlijke implementatiewerk gingen we op zoek naar een goede abstractie voor asynchrone code. Daarbij maakten we gebruik van enkele criteria om de verschillende abstracties t.o.v. elkaar af te wegen. Node.js maakt standaard gebruik van CPS, wat leidt tot onleesbare code door o.a. de aanwezigheid van vele geneste callbacks en een foutgevoelig error-handling model. Promises lossen deze problemen op door een asynchrone operatie voor te stellen als een object dat zich in een bepaalde state bevindt. Het enige nadeel is dat promises net zoals CPS niet compatibel zijn met de primitieve controlestructuren. Dit nadeel werd ook weggewerkt door promises te combineren met generator-functies waardoor we weer dezelfde leesbaarheid verkregen als de originele synchrone code.
BESLUIT
144
Nadat we een antwoord hadden verkregen op alle onderzoeksvragen, konden we overgaan tot de implementatie van het systeem. Er werd gestart met de client- en server-side module die de publish/subscribe-functionaliteit aanbieden. We hebben daarbij het ontwerp bekeken en de belangrijkste implementatiedetails besproken zoals het gebruik van het strategy-pattern om op een vlotte manier over te schakelen tussen HTTP-long-polling en WebSocket. Verder werd ook de WebSocket-implementatie in Flash behandeld, door gebruik te maken van het web-socket-js project. Daarnaast hebben we structuur gegeven aan de events van het publish/subscribepattern, door deze een id te geven en ze samen met hun channel op te slaan als een hash in Redis. Het concept van een LRU-cache werd ook opgepikt om de load op de Redis-instantie verder te minimaliseren. Na de bespreking van het publish/subscribe-systeem gingen we over tot de implementatie van het observable model. Dit was op de client niet meer dan een klein laagje boven de reeds bestaande functionaliteit. Op de server hebben we nog de ondersteuning voor het statische model besproken door de toevoeging van enkele hooks. Daarnaast werden ook nog enkele technische details vermeld in verband met cross-origin HTTP-requests en de policy file requests die nodig zijn om WebSocket binnen Flash aan de praat te krijgen. Tot slot hebben we nog een kleine vermelding gemaakt van het project dat samen met DigitasLBi tot stand werd gebracht. Dit project vormde een mooie testcase met het voornaamste doel om de mogelijkheden van real-time interactiviteit in de praktijk aan te tonen. Als afsluiter werden de niet-functionele vereisten van het resulterende systeem getest. Daarbij hebben we eerst gecontroleerd of het mogelijk was om de load van de inkomende verbindingen naar de egde servers van CloudFront te brengen. Het gedrag van CloudFront voldeed echter niet aan onze verwachtingen, waardoor deze piste werd geschrapt. Daarna hebben we het publish/subscribe-systeem aan een load-test onderworpen door de latency van een event te meten bij een toenemend aantal subscribers. Deze test werd acht keer uitgevoerd waarbij de cluster met applicatieservers en het aantal subscribers telkens vergroot werd. De resultaten stelden ons vervolgens in staat om een voorlopige conclusie te maken over de performantie en de schaalbaarheid van het systeem. De performantie in de vorm van latency bleek vrij snel af te nemen in de aanwezigheid van een toenemend aantal clients. Zo was ´e´en applicatieserver in staat om maximaal 1000 clients te behandelen zonder een verlies van events. Dit lijkt weinig als we naar de performantie kijken van andere event-driven systemen zoals beschreven in het artikel van Kegel. We konden geen bottleneck opsporen op het gebied van processor- en geheugenverbruik. Een overbelasting van de netwerkinterface lijkt ons daarnaast ook onwaarschijnlijk. We concluderen dat verder onderzoek van ons systeem en de Amazon-infrastructuur nodig is om de performantie-bottleneck aan te duiden.
BESLUIT
145
Daarna werd ook de schaalbaarheid van het systeem afgetoetst door het aantal subscribers te bepalen bij een latency van 500ms voor iedere geteste cluster. Daarbij werd gehoopt op een lineair verband, maar dit was ook niet zichtbaar in de resultaten. Zo vertoonde het toevoegen van een achtste applicatieserver zelfs het verschijnsel van negatieve schaalbaarheid. Nader onderzoek naar de werking van de ELB gaf echter wel een paar mogelijke oorzaken voor dit bizarre gedrag. Zo heeft de ELB nood aan pre-warming om voorbereid te zijn op een plotse piekbelasting. Een mogelijk nog belangrijkere oorzaak is echter de eigenschap van de ELB om netwerkverkeer van clients met hetzelfde IP-adres naar dezelfde applicatieserver te sturen. Dit kan bij onze specifieke testopstelling geleid hebben tot een onevenwichtige verdeling van de load, met in het extreme geval negatieve schaalbaarheid tot gevolg. Kunnen we tevreden zijn met deze resultaten? Wat betreft de kwalitatieve onderzoeksvragen hebben we voor ieder probleem een oplossing gevonden die we telkens grondig onderbouwd hebben. A.d.h.v. die oplossingen hebben we een systeem gebouwd dat alle gevraagde functionaliteit bezit, en deze ook in de praktijk heeft bewezen met het DigitasLBi-project. Als we kijken naar het kwantitatieve deel van onze eerste onderzoeksvraag, hebben we enkele tegenvallende resultaten geboekt. De niet-lineaire schaalbaarheid is daarbij echter hoogst waarschijnlijk het gevolg van een niet-ideale testomgeving omwille van de eerder genoemde argumenten. De performantie is daarnaast zonder meer een punt dat nog vatbaar is voor verdere verbetering en zal dan ook in de toekomst binnen Zentrick nog verder onderzocht worden.
KLASSE IN JAVASCRIPT
Bijlage A
Klasse in JavaScript ' use strict '; var Class = function (){}; Class . extend = function ( methods ) { var base = this . prototype ; // Setup the inheritance from the base class . var prototype = Object . create ( base ); // Copy all the methods to the prototype and add // this . base () behavior . for ( var name in methods ) { ( function (){ var method = methods [ name ]; var baseMethod = base [ name ]; prototype [ name ] = function () { var tmp = this . base ; this . base = baseMethod ; var result = method . apply ( this , arguments ); this . base = tmp ; return result ; } }()); }
146
KLASSE IN JAVASCRIPT
// Add a constructor if there wasn 't one defined // on the methods object . if ( typeof methods . constructor === ' undefined ') { prototype . constructor = function (){}; } // Setup the constructor and prototype references . var constructor = prototype . constructor ; constructor . prototype = prototype ; // Add the extend method to the constructor . constructor . extend = Class . extend ; return constructor ; }; module . exports = Class ;
147
CLIENT-SIDE REAL-TIME MODULE
Bijlage B
Client-side real-time module var EventBus = require ( ' pubsub - client ') , Class = require ( ' class ') , EventEmitter = require ( ' events '). EventEmitter ; EventEmitter . extend = Class . extend ; var eventBus = new EventBus ({ host : ' realtime . zentrick . com ', path : '/ realtime ', secure : true }); var Realtime = Class . extend ({ subscribe : function ( channel ) { return eventBus . subscribe ( channel ) . then ( function ( response ){ return new Model ( channel , response . model ); }); }, unsubscribe : function ( channel ) { eventBus . unsubscribe ( channel ); }, publish : function ( channel , object ) { return eventBus
148
CLIENT-SIDE REAL-TIME MODULE
. publish ( channel , object ); } }); var Model = EventEmitter . extend ({ constructor : function ( channel , model ) { this . base (); var me = this ; for ( var prop in model ) { this [ prop ] = model [ prop ]; } eventBus . on ( ' message ', function ( ch , message ){ if ( channel === ch ) { for ( var prop in message ) { me [ prop ] = message [ prop ]; me . emit ( ' change : ' + prop , message [ prop ]); } } }); } }); var realtime = new Realtime (); module . exports = realtime ;
149
RESULTATEN LOAD-TESTING
150
Bijlage C
Resultaten load-testing
Figuur C.1: Cluster met 1 server
Figuur C.2: Cluster met 2 servers
RESULTATEN LOAD-TESTING
Figuur C.3: Cluster met 3 servers
Figuur C.4: Cluster met 4 servers
Figuur C.5: Cluster met 5 servers
151
RESULTATEN LOAD-TESTING
Figuur C.6: Cluster met 6 servers
Figuur C.7: Cluster met 7 servers
Figuur C.8: Cluster met 8 servers
152
BIBLIOGRAFIE
153
Bibliografie [1] Amazon Auto Scaling. http://aws.amazon.com/autoscaling. [2] Amazon CloudFront. http://aws.amazon.com/cloudfront. [3] Amazon Elastic Beanstalk. http://aws.amazon.com/elasticbeanstalk. [4] Amazon Elastic Compute Cloud. http://aws.amazon.com/ec2. [5] Amazon Elastic Load Balancing. http://aws.amazon.com/elasticloadbalancing. [6] Amazon ElastiCache. http://aws.amazon.com/elasticache. [7] Amazon Web Services. http://aws.amazon.com. [8] Async. https://github.com/caolan/async. [9] Asynchronous Programming Patterns.
http://msdn.microsoft.com/en-us/library/
jj152938(v=vs.110).aspx. [10] Broccoli. https://github.com/broccolijs/broccoli. [11] Browserify. http://browserify.org. [12] Comet. http://en.wikipedia.org/wiki/Comet (programming). [13] Cross-Origin Resource Sharing. http://www.w3.org/TR/cors. [14] Dissecting Amazon ELB : 18 things you should know. http://harish11g.blogspot.be/ 2012/07/aws-elastic-load-balancing-elb-amazon.html. [15] Express. http://expressjs.com. [16] Facebook regenerator. https://github.com/facebook/regenerator. [17] Grunt. http://gruntjs.com. [18] Gulp. http://gulpjs.com.
BIBLIOGRAFIE
154
[19] Harmony Iterators. http://wiki.ecmascript.org/doku.php?id=harmony:iterators. [20] Harmony Specification Drafts.
http://wiki.ecmascript.org/doku.php?id=harmony:
specification drafts. [21] Jasmine. http://jasmine.github.io. [22] JSHint. http://jshint.com. [23] JSLint. http://jslint.com. [24] JSONP. http://en.wikipedia.org/wiki/JSONP. [25] Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP. http://www.ietf.org/rfc/rfc6202.txt. [26] LINQ. http://msdn.microsoft.com/en-us/library/bb397926.aspx. [27] Memcached. http://memcached.org. [28] Mocha. http://visionmedia.github.io/mocha. [29] Node Redis. https://github.com/mranney/node redis. [30] Node.js. http://nodejs.org. [31] Node.js API. http://nodejs.org/api. [32] Node.js LRU-cache. https://github.com/isaacs/node-lru-cache. [33] Policy file changes in Flash Player 9 and Flash Player 10. http://www.adobe.com/devnet/ flashplayer/articles/fplayer9 security.html. [34] Promises and generators. http://pag.forbeslindesay.co.uk. [35] Q. https://github.com/kriskowal/q. [36] QUnit. http://qunitjs.com. [37] Redis. http://redis.io. [38] Redis Cluster. http://redis.io/topics/cluster-tutorial. [39] Redis Data types. http://redis.io/topics/data-types. [40] Redis Persistence. http://redis.io/topics/persistence. [41] RequireJS. http://requirejs.org.
BIBLIOGRAFIE
155
[42] Same Origin Policy. http://www.w3.org/Security/wiki/Same Origin Policy. [43] Simple JavaScript Inheritance. http://ejohn.org/blog/simple-javascript-inheritance. [44] The C10K problem. http://www.kegel.com/c10k.html. [45] The WebSocket Protocol. http://tools.ietf.org/html/rfc6455. [46] Transmission Control Protocol. http://tools.ietf.org/html/rfc793. [47] web-socket-js. https://github.com/gimite/web-socket-js. [48] Web Workers. http://dev.w3.org/html5/workers. [49] ws. https://github.com/einaros/ws. [50] XMLHttpRequest Level 2. http://www.w3.org/TR/XMLHttpRequest2. [51] Zentrick App Development. http://support.zentrick.com/app-development. [52] ActionScript 3.0 Overview.
http://www.adobe.com/devnet/actionscript/articles/
actionscript3 overview.html, 2006. [53] Harold Abelson, Gerald Sussman, and Julie Sussman. Structure and Interpretation of Computer Programs. McGraw-Hill, Inc., 2 edition, 1997. [54] Gene M. Amdahl. Validity of the single processor approach to achieving large scale computing capabilities. In Proceedings of the April 18-20, 1967, Spring Joint Computer Conference, AFIPS ’67 (Spring), pages 483–485, New York, NY, USA, 1967. ACM. [55] Krste Asanovic, Rastislav Bodik, James Demmel, Tony Keaveny, Kurt Keutzer, John Kubiatowicz, Nelson Morgan, David Patterson, Koushik Sen, John Wawrzynek, David Wessel, and Katherine Yelick. A view of the parallel computing landscape. Commun. ACM, 52(10):56–67, October 2009. [56] Henry G. Baker. Cons should not cons its arguments, part ii: Cheney on the m.t.a. SIGPLAN Not., 30(9):17–20, September 1995. [57] Corrado B¨ ohm and Giuseppe Jacopini. Flow diagrams, turing machines and languages with only two formation rules. Commun. ACM, 9(5):366–371, May 1966. [58] Josiah L. Carlson. Redis in Action. Manning Publications Co., 2013. [59] William S. Cleveland and Susan J. Devlin. Locally weighted regression: An approach to regression analysis by local fitting. Journal of the American Statistical Association, 83:596– 610, 1988.
BIBLIOGRAFIE
156
[60] Melvin E. Conway. Design of a separable transition-diagram compiler. Commun. ACM, 6(7):396–408, July 1963. [61] Douglas Crockford. JavaScript: The Good Parts. O’Reilly Media Inc., 2008. [62] Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec. The many faces of publish/subscribe. ACM Comput. Surv., 35(2):114–131, June 2003. [63] Daniel Friedman. The impact of applicative programming on multiprocessing. International Conference on Parallel Processing, 1976. [64] Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-oriented Software. Addison-Wesley Longman Publishing Co., Inc., Boston, MA, USA, 1995. [65] Ilya Grigorik. High-Performance Browser Networking. O’Reilly Media Inc., 2013. [66] Robert Love. Linux System Programming. O’Reilly Media Inc., 2013. [67] Bertrand Meyer. Object-oriented Software Construction (2Nd Ed.). Prentice-Hall, Inc., 1997. [68] Gordon E. Moore. Cramming more components onto integrated circuits. Electronics, 38(8), April 1965. [69] Addy Osmani. Learning JavaScript Design Patterns. O’Reilly Media Inc., 2012. [70] Bryan O’Sullivan, John Goerzen, and Don Stewart. Real World Haskell. O’Reilly Media, Inc., 1st edition, 2008. [71] William Pugh. Skip lists: A probabilistic alternative to balanced trees. Commun. ACM, 33(6):668–676, June 1990. [72] Willy Tarreau.
Making applications scalable with Load Balancing.
articles/2006 lb, 2006.
http://1wt.eu/