Il protocollo WebSocket risolve questi problemi perchè viene mantenuta una connessione TCP persistente, bidirezionale, full-duplex assicurata da un sistema di handshaking client-key ed un modello basato sull'origine; inoltre la trasmissione è mascherata per evitare attacchi. Per maggiori dettagli vedere The WebSocket protocol e WebSocket API.
Il framework 4.5 NET fornisce un'implementazione gestita del protocollo WebSocket mentre i moderni browser come Chrome, Firefox, Safari, Opera e IE10 supportano la specifica.
Nativamente è supportato da Windows Server 2012 e Windows 8 con IIS8 e IIS8 Express. Con altre versioni di Windows si potrebbe utilizzare SignalR e Socket.IO che hanno anche il grande vantaggio di supportare strategie di fallback (ad esempio browser client che non supportano WebSocket).
Per installare WebSocket su Windows Server 2012:
- Aprire Server Manager;
- cliccare su Add Roles and Features;
- selezionare Role-based or Feature-based Installation e poi cliccare su Next;
- selezionare il server (il server locale è selezionato di default) e poi cliccare su Next;
- espandere Web Server (IIS) in Roles, poi espandere Web Server e poi espandere Application Development;
- selezionare WebSocket Protocol e poi cliccare su Next;
- se non sono necessarie funzionalità aggiuntive cliccare su Next;
- cliccare su Install;
- quando l'installazione è completata chiudere il wizard.
A questo punto l'IIS è abilitato a gestire il WebSocket.
Ora ci creiamo un generico handler HTTP asincrono (disponibile con .NET 4.5) e ne deriviamo una classe specializzata. Registriamo il nuovo HTTP handler nel web.config dell'applicazione e ci creiamo una pagina javascript di test utilizzando le API Esri Javascript. Nello specifico possiamo utilizzare la classe StreamLayer che permette di visualizzare feature in real time da GEP ma anche da un WebSocket che fornisce feature in formato Esri JSON.
namespace StreamLayerDemo { using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Web; using System.Web.WebSockets; [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Demo code")] public abstract class WebSocketAsyncHandler : HttpTaskAsyncHandler { /// <summary> /// Gets a value indicating whether this handler can be reused for another request. /// Should return false in case your Managed Handler cannot be reused for another request, or true otherwise. /// Usually this would be false in case you have some state information preserved per request. /// You will need to configure this handler in the Web.config file of your /// web and register it with IIS before being able to use it. For more information /// see the following link: <see cref="http://go.microsoft.com/?linkid=8101007" /> /// </summary> public override bool IsReusable { get { return false; } } private WebSocket Socket { get; set; } public override async Task ProcessRequestAsync(HttpContext httpContext) { await Task.Run(() => { if (httpContext.IsWebSocketRequest) { httpContext.AcceptWebSocketRequest(async delegate(AspNetWebSocketContext context) { this.Socket = context.WebSocket; while (this.Socket != null || this.Socket.State != WebSocketState.Closed) { try { switch (this.Socket.State) { case WebSocketState.Connecting: this.OnConnecting(); break; case WebSocketState.Open: this.OnOpen(); break; case WebSocketState.CloseSent: this.OnClosing(false, string.Empty); break; case WebSocketState.CloseReceived: this.OnClosing(true, string.Empty); break; } ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[1024]); WebSocketReceiveResult receiveResult = await this.Socket.ReceiveAsync(buffer, CancellationToken.None); switch (receiveResult.MessageType) { case WebSocketMessageType.Text: string message = Encoding.UTF8.GetString(buffer.Array, 0, receiveResult.Count); this.OnMessageReceived(message); break; case WebSocketMessageType.Binary: this.OnMessageReceived(buffer.Array); break; case WebSocketMessageType.Close: this.OnClosing(true, receiveResult.CloseStatusDescription); break; } } catch (Exception ex) { this.OnError(ex); } } }); } }); } protected virtual void OnConnecting() { } protected virtual void OnOpen() { } protected virtual void OnMessageReceived(string message) { } protected virtual void OnMessageReceived(byte[] bytes) { } protected virtual void OnClosing(bool isClientRequest, string message) { } protected virtual void OnClosed() { } protected virtual void OnError(Exception ex) { } [DebuggerStepThrough] protected async Task SendMessageAsync(byte[] message) { await this.SendMessageAsync(message, WebSocketMessageType.Binary); } [DebuggerStepThrough] protected async Task SendMessageAsync(string message) { await this.SendMessageAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text); } private async Task SendMessageAsync(byte[] message, WebSocketMessageType messageType) { await this.Socket.SendAsync( new ArraySegment<byte>(message), messageType, true, CancellationToken.None); } } }
Deriviamo dalla classe astratta HttpTaskAsyncHandler ed eseguiamo l'override della proprietà IsReusable e del metodo ProcessRequest ed utilizzeremo async/await e la classe task perchè processiamo task asincroni.
In questa classe ci limitiamo a memorizzare l'istanza del WebSocket con una proprietà, verificare se la richiesta http è una richiesta WebSocket (IsWebSocketRequest) e in funzione dello stato del WebSocket richiamare il corrispondente metodo virtuale. Inoltre implementiamo il codice per inviare messaggi al client. Il WebSocket permette di inviare e ricevere messaggi (testo e binario) in modalità asincrona. Questa classe generica permette di implementare la propria classe specializza handler WebSocket.
Le implementazioni dei metodi virtuali saranno nella classe derivata:
namespace StreamLayerDemo { using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Demo code")] public class StreamLayerWebSocketAsyncHandler : WebSocketAsyncHandler { protected override void OnOpen() { PointTicker.DefaultInstance.Update += this.PointTicker_Update; base.OnOpen(); } protected override void OnClosing(bool isClientRequest, string message) { PointTicker.DefaultInstance.Update -= this.PointTicker_Update; base.OnClosing(isClientRequest, message); } protected override void OnMessageReceived(string message) { // Assignment prevents warning "Because this call is not awaited...Consider applying the 'await' operator // This is intentional => fire and forget //Task task = this.SendMessageAsync("Your message is: " + message); } protected override void OnError(Exception ex) { // Assignment prevents warning "Because this call is not awaited...Consider applying the 'await' operator // This is intentional => fire and forget var task = this.SendMessageAsync(string.Format("Something exceptional happened: {0}", ex.Message)); } private void PointTicker_Update(object sender, PointTickerEventArgs e) { // Assignment prevents warning "Because this call is not awaited...Consider applying the 'await' operator // This is intentional => fire and forget var task = this.SendMessageAsync(e.Feature); } } }
Per simulare l'invio di dati (in questo esempio dei Point casuali in un certo extent) utilizziamo una singola istanza di una classe che implementa un semplice Timer che ogni 5 secondi invia una feature Point al client. I metodi Start e Stop della classe vengo richiamati negli eventi globali (global.asax.cs) Start e Stop dell'applicazione.
namespace StreamLayerDemo { using System; using System.Diagnostics.CodeAnalysis; [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Demo code")] public class Global : System.Web.HttpApplication { protected void Application_Start(object sender, EventArgs e) { // var hostFactory = new PointTickerHostFactory(); // var route = new ServiceRoute("PointTicker", hostFactory, typeof(PointTickerService)); // System.Web.Routing.RouteTable.Routes.Add(route); PointTicker.DefaultInstance.Start(); } protected void Application_End(object sender, EventArgs e) { PointTicker.DefaultInstance.Stop(); } } }
Classe per simulare l'invio di dati al client:
namespace StreamLayerDemo { using System; using System.Diagnostics.CodeAnalysis; using System.Timers; [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Demo code")] public class PointTicker { private const int TimerInterval = 5000; private static object lockField = new object(); private static PointTicker defaultInstanceField; private PointTicker() { } public event EventHandler<PointTickerEventArgs> Update; public static PointTicker DefaultInstance { get { lock (PointTicker.lockField) { if (PointTicker.defaultInstanceField == null) { PointTicker.defaultInstanceField = new PointTicker(); PointTicker.defaultInstanceField.Initialize(); } } return PointTicker.defaultInstanceField; } } private static Timer Timer { get; set; } public void Start() { lock (PointTicker.lockField) { if (!PointTicker.Timer.Enabled) { PointTicker.Timer.Start(); } } } public void Stop() { lock (PointTicker.lockField) { if (PointTicker.Timer.Enabled) { PointTicker.Timer.Stop(); } } } protected virtual void OnUpdate(string feature) { if (this.Update != null) { this.Update( this, new PointTickerEventArgs() { Feature = feature }); } } private void Initialize() { PointTicker.Timer = new Timer(PointTicker.TimerInterval); PointTicker.Timer.Elapsed += this.Timer_Elapsed; } private void Timer_Elapsed(object sender, ElapsedEventArgs e) { Random random = new Random(); string feature = string.Format("{{\"geometry\" : {{\"x\" : {0}, \"y\" : {1} }}, \"attributes\" : {{\"ObjectId\" : {2}, \"RouteID\" : 1, \"DateTimeStamp\" : {3} }}}}", random.NextDouble(8.40, 8.95).ToString(new System.Globalization.CultureInfo("en-US")), random.NextDouble(45.23, 45.85).ToString(new System.Globalization.CultureInfo("en-US")), random.Next(1, Int32.MaxValue), DateTime.Now.UnixTicks().ToString(new System.Globalization.CultureInfo("en-US"))); this.OnUpdate(feature); } } }
Ora registriamo l'handler nel web.config per indicare ad IIS di utilizzare questo handler quando richiamato.
<?xml version="1.0"?> <!-- For more information on how to configure your ASP.NET application, please visit http://go.microsoft.com/fwlink/?LinkId=169433 --> <configuration> <system.web> <compilation debug="true" targetFramework="4.5" /> <httpRuntime targetFramework="4.5" /> </system.web> <system.webServer> <handlers> <add name="StreamLayerWebSocketAsyncHandler" verb="*" path="wsStreamLayer" type="StreamLayerDemo.StreamLayerWebSocketAsyncHandler, StreamLayerDemo" resourceType="Unspecified" /> </handlers> </system.webServer> </configuration>
Nel Type indicheremo la classe comprensiva del namespace affinchè IIS possa trovarla e nel path indicheremo il nome del WebSocket che utilizzeremo per chiamarlo (in questo caso l'ho chiamato wsStreamLayer):
ws://<yourdomain>/<site:port>/wsStreamLayer
Come possiano notare da fiddler abbiamo anche Origin: questa origine è quella visionata dal server per capire da dove provengono i messaggi. Mentre Sec-WebSocket-Key è la chiave che compone la prima parte dell'handshake. E' generata in modo causale e codificata come stringa base64 di 16 byte. Sec-WebSocket-Version permette al server di rispondere con la versione del protocollo più adeguata alla versione supportata dal client.
In risposta dal server Sec-WebSocket-Accept ha la chiave non codificata inviata dal client concatenata con 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 e codificata in SHA-1 e successivamente in base64.
Inoltre come avviene per l'http è possibile effettuare la comunicazione WebSocket su ssl/tls tramite wss:
wss://<yourdomain>/<site:port>/wsStreamLayer
A questo punto testiamo con la classe StreamLayer delle API js Esri:
<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="initial-scale=1, maximum-scale=1,user-scalable=no"> <title>StreamLayer using ArcGIS API for JavaScript</title> <link rel="stylesheet" href="http://js.arcgis.com/3.10/js/dojo/dijit/themes/tundra/tundra.css"> <link rel="stylesheet" href="http://js.arcgis.com/3.10/js/esri/css/esri.css"> <style type="text/css"> html, body { height: 100%; width: 100%; margin: 0; padding: 0; } body { background-color: #fff; overflow: hidden; font-family: sans-serif; } #map { width: 100%; height: 80%; } </style> <script src="http://js.arcgis.com/3.10/"></script> </head> <body class="tundra"> <div id="map"></div> <div> <span>Enter websocket connection: </span><input type="text" id="txtWsUrl" value="ws://localhost:55555/wsStreamLayer" style="width: 400px" /><br /> <input type="button" id="cmdNewStream" value="Make Stream Layer" /> <input type="button" id="cmdDisconnect" value="Disconnect Stream Layer" /> </div> <script> var curTime = new Date(); var curTimeStamp = Date.parse(curTime.toUTCString()); var layerDefinition = { "geometryType": "esriGeometryPoint", "timeInfo": { "startTimeField": "DateTimeStamp", "endTimeField": null, "trackIdField": "RouteID", "timeReference": null, "timeInterval": 1, "timeIntervalUnits": "esriTimeUnitsMinutes", "exportOptions": { "useTime": true, "timeDataCumulative": false, "timeOffset": null, "timeOffsetUnits": null }, "hasLiveData": true }, "fields": [ { name: "ObjectId", type: "esriFieldTypeOID", alias: "ObjectId" }, { name: "DateTimeStamp", type: "esriFieldTypeDate", alias: "DateTimeStamp" }, { name: "RouteID", type: "esriFieldTypeInteger", alias: "RouteID" } ] }; var map, featureCollection, streamLayer; require(["esri/map", "esri/TimeExtent", "esri/layers/StreamLayer", "esri/InfoTemplate", "esri/symbols/SimpleMarkerSymbol", "esri/symbols/SimpleLineSymbol", "esri/renderers/SimpleRenderer", "esri/renderers/TimeClassBreaksAger", "esri/renderers/TemporalRenderer", "esri/Color", "dojo/dom", "dojo/on", "dojo/domReady!" ], function (Map, TimeExtent, StreamLayer, InfoTemplate, SimpleMarkerSymbol, SimpleLineSymbol, SimpleRenderer, TimeClassBreaksAger, TemporalRenderer, Color, dom, on) { var trackedBusses = {}, cnt = 0; map = new Map("map", { basemap: "gray", center: [8.675, 45.54], zoom: 10 }); // event listeners for button clicks on(dom.byId("cmdNewStream"), "click", makeNewStreamLayer); on(dom.byId("cmdDisconnect"), "click", disconnectStreamLayer); function makeStreamLayer() { //Make FeatureCollection to define layer without using url featureCollection = { "layerDefinition": null, "featureSet": { "features": [], "geometryType": "esriGeometryPoint" } }; featureCollection.layerDefinition = layerDefinition; // Instantiate StreamLayer // 1. socketUrl is the url to the GeoEvent Processor web socket. // 2. purgeOptions.displayCount is the maximum number of features the // layer will display at one time // 3. trackIdField is the name of the field that groups features var layer = new StreamLayer(featureCollection, { socketUrl: txtWsUrl.value, purgeOptions: { displayCount: 500 }, trackIdField: featureCollection.layerDefinition.timeInfo.trackIdField, infoTemplate: new InfoTemplate("Route Id: ${RouteID}", "Timestamp: ${DateTimeStamp}") }); console.log("TrackID: ", featureCollection.layerDefinition.timeInfo.trackIdField); console.log("TrackID: ", layer.timeInfo.trackIdField); //Make renderer and apply it to StreamLayer var renderer = makeRenderer(); layer.setRenderer(renderer); //Subscribe to onMessage event of StreamLayer so can adjust map time layer.on("message", processMessage); layer.on("connect", connectevt); layer.on("error", errorevt); return layer; } function connectevt() { console.log("Connesso"); } function errorevt() { console.log("error"); } // Process message that StreamLayer received. function processMessage(message) { if (featureCollection.layerDefinition.timeInfo && featureCollection.layerDefinition.timeInfo.startTimeField) { var timestamp = message.attributes[featureCollection.layerDefinition.timeInfo.startTimeField]; if (!map.timeExtent) { map.setTimeExtent(new esri.TimeExtent(new Date(timestamp), new Date(timestamp))); console.log("TIME EXTENT: ", map.timeExtent); } else { var tsEnd = Date.parse(map.timeExtent.endTime.toString()); if (timestamp > tsEnd) { map.setTimeExtent(new esri.TimeExtent(map.timeExtent.startTime, new Date(timestamp))); console.log("TIME EXTENT: ", map.timeExtent); } } } } // Make new StreamLayer and add it to map. function makeNewStreamLayer() { disconnectStreamLayer(); streamLayer = makeStreamLayer(); map.addLayer(streamLayer); } // Disconnect StreamLayer from websocket and remove it from the map function disconnectStreamLayer() { if (streamLayer) { streamLayer.suspend(); streamLayer.disconnect(); streamLayer.clear(); map.removeLayer(streamLayer); streamLayer = null; //map.timeExtent = null; } } // Make temporal renderer with latest observation renderer function makeRenderer() { var obsRenderer = new SimpleRenderer( new SimpleMarkerSymbol("circle", 8, new SimpleLineSymbol("solid", new Color([5, 112, 176, 0]), 1), new Color([5, 112, 176, 0.4]) )); var latestObsRenderer = new SimpleRenderer( new SimpleMarkerSymbol("circle", 12, new SimpleLineSymbol("solid", new Color([5, 112, 176, 0]), 1), new Color([5, 112, 176]) )); var temporalRenderer = new TemporalRenderer(obsRenderer, latestObsRenderer, null, null); return temporalRenderer; } }); </script> </body> </html>
Scaricare qui la soluzione.