Dzisiaj chciałem podzielić się moimi doświadczeniami z użyciem biblioteki SignalR w projekcie .NET Core. Co prawda, nie mam jeszcze za dużo przykładów użycia w projekcie, ponieważ dopiero udało mi się zmusić ją do działania. Wydaje mi się jednak, że to co tutaj opiszę przyda się komuś, jeżeli napotka na podobne problemy co ja.
Co to jest SignalR?
SignalR to biblioteka pozwalająca na tworzenie aplikacji aktualizujących dane w czasie rzeczywistym. Co to znaczy? W standardowym scenariuszu, po uruchomieniu strony czy aplikacji pobierane są dane. Jeżeli chcemy je odświeżyć, musimy przeładować stronę lub wywołać jakąś akcję która odświeży dane (np. ponowne przefiltrowanie listy). W taki czy inny sposób wysyłamy żądanie na serwer i otrzymujemy od niego odpowiedź. Tutaj mamy do czynienia z czymś innym. To serwer informuje klienta o tym, że dane zostały zmienione. Dzięki temu natychmiast możemy mieć dostęp do interesujących nas informacji. W tym celu stosowane jest na przykład API WebSockets, czyli komunikacja dwukierunkowa klient-serwer. Do naszej dyspozycji jest także HTML5 Server-Sent Events. Tutaj komunikacja jest tylko jednokierunkowa, z serwera do klienta. Bywa jednak, że przeglądarki nie wspierają obu tych rozwiązań. Wtedy z pomocą przychodzi SignalR. Korzystając z niego nie interesuje nas w jaki sposób przebiegać będzie komunikacja, framework zajmie się tym za nas. Jeżeli tak jak wspomniałem przed chwilą, przeglądarka nie dostarcza nam tych rozwiązań, wykonane zostaną po prostu zwykłe zapytania. Ale to już jest dla programisty mało ważne, stanie się to automatycznie. To tyle w skrócie, nie są to technologie nowe, dlatego po więcej szczegółów zachęcam do dalszej lektury we własnym zakresie.
SignalR w .NET Core
Na początek pokaże, co zrobić żeby wszystko ładnie działało po stronie API naszej aplikacji. Największym problemem na jaki napotkałem był brak implementacji biblioteki pod .NET Core. Jest ona jeszcze w przygotowaniu, i żeby mieć tą wersję, trzeba ją pobrać ze specjalnego źródła, gdzie wrzucane są wersje dopiero rozwijane. Stwierdziłem, że nie będę się katował wersjami testowymi tej technologi. Sami autorzy tego nie zalecają. Do tego musiałbym podnieść wersję samego .NET Core, a tego nie chciałem robić. Wystarczą mi problemy, które mam z wersją stabilną… Jako, że byłem zmotywowany do użycia tej technologi i nie chciałem po prostu odpytywać serwera co chwilę o to, czy pozostali gracze nie wykonali ruchu, znalazłem inne rozwiązanie. Postanowiłem, że użyję frameworka .NET w wersji 4.6.2. Nie wiązało się to z przepisywaniem aplikacji od nowa. Moja aplikacja dalej pisana jest w .NET Core, ale mam możliwość korzystania ze sprawdzonych bibliotek poprzedniej wersji .NET. Wystarczy zmienić trochę plik .csproj
naszej aplikacji.
1 2 3 4 5 6 |
//CSPROJ <PropertyGroup> <TargetFramework>net462</TargetFramework> <!-- <TargetFramework>netcoreapp1.1</TargetFramework> --> </PropertyGroup> |
Zamiast netcoreapp1.1
, jako TargetFramework
podajemy net462
. Reszta aplikacji pozostaje w zasadzie bez zmian. Dzięki temu, dalej korzystamy z nowego .NET Core, ale możemy dodać zależności do sprawdzonego Microsoft.AspNet.SignalR.Core
. W ten sposób omijamy pierwszą przeszkodę, czyli wersję biblioteki. To jednak nie wszystko.
Żeby móc podpiąć SignalR do pipeline’u naszej aplikacji, musimy dodać następujące extension methods.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
//IApplicationBuilder extensions public static IApplicationBuilder UseAppBuilder(this IApplicationBuilder app, Action<IAppBuilder> configure) { app.UseOwin(addToPipeline => { addToPipeline(next => { var appBuilder = new AppBuilder(); appBuilder.Properties["builder.DefaultApp"] = next; configure(appBuilder); return appBuilder.Build<AppFunc>(); }); }); return app; } public static IApplicationBuilder UseSignalR2(this IApplicationBuilder app) { app.UseAppBuilder(appBuilder => { appBuilder.UseAesDataProtectorProvider(); appBuilder.MapSignalR("/api/signalr", new Microsoft.AspNet.SignalR.HubConfiguration()); }); return app; } |
Kluczowe tutaj było dodanie do pipeline UseAesDataProtectorProvider
. Bez tego, SignalR działał lokalnie, ale już w środowisku produkcyjnym nie chciał nawiązać połączenia. Objawiało się to błędem Error during negotiation request...
. Po wykonaniu tych czynności możemy dodać SignalR do naszej aplikacji!
1 2 3 4 5 6 7 |
//Configure in Startup.cs app.UseCorsConfig(env) .UseIdentity() .UseJwtBearerTokenAuthentication(authConfig) .UseMiddleware<ExceptionHandlingMiddleware>() .UseSignalR2() .UseMvc(); |
A poniżej pierwszy Hub, którym testowałem, czy komunikacja działa. Nie robi nic innego, niż tylko przekazanie wszystkim połączonym klientom otrzymanej wiadomości.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//SignalR Hub public interface IBroadcaster { void HandleMessage(string message); } [HubName("broadcaster")] public class Broadcaster : Hub<IBroadcaster> { public void HandleMessage(string message) { Clients.All.HandleMessage("Joined: " + message); } } |
SignalR w Angular
Kolejne problemy, na szczęście mniejsze, pojawiły się po stronie frontendu aplikacji. Jak zwykle jakąś rolę odegrał tutaj Webpack, ale do tego już się przyzwyczaiłem. Do komunikacji z serwerem użyłem biblioteki ng2-signalr, która dostarcza implementacje pod Angulara. Problem polegał na tym, że nie widziała ona dodanych do projektu bibliotek jQuery oraz signalr, które są konieczne do jej działania. Do obejścia tego problemu w pliku webpack.config.js
należało dodać nowy plugin, który automatycznie ładuje moduły do wybranego identyfikatora. Dodatkowo w app.module.ts
należało dodać importy tych bibliotek. Dzięki temu problem zniknął.
1 2 3 4 5 6 7 8 9 10 |
//plugin ... plugins: [..., new webpack.ProvidePlugin({ jQuery: 'jquery', $: 'jquery', jquery: 'jquery' })] ... //app.module.ts ... import 'expose-loader?jQuery!jquery'; import 'signalr'; ... |
Teraz można przejść do właściwej implementacji naszego rozwiązania. Dodajemy resolver połączenia do SignalR, który już na starcie komponentu zwraca nam obiekt połączenia. W obecnej implementacji, zawsze łączy się do tego samego Huba.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//signalr-connection.resolver.ts import { Resolve } from '@angular/router'; import { SignalR, SignalRConnection } from 'ng2-signalr'; import { Injectable } from '@angular/core'; @Injectable() export class SignalRConnectionResolver implements Resolve<SignalRConnection> { constructor(private _signalR: SignalR) { } resolve() { console.log('ConnectionResolver. Resolving...'); return this._signalR.connect(); } } |
Połączenie tworzone jest na podstawie domyślnej konfiguracji, która jest podawana podczas deklaracji modułu SingalR. Oto implementacja funkcji, podającej domyślną konfigurację.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
//signalr.config.ts import { isDevMode } from '@angular/core'; import { DevConfig, ProdConfig } from './app.config'; import { IConfig } from './app.iconfig'; import { SignalRConfiguration } from 'ng2-signalr'; export function CreateSignalRConfig() { let signalrConfiguration: SignalRConfiguration = new SignalRConfiguration(); signalrConfiguration.hubName = "broadcaster"; var cfg: IConfig; if (isDevMode()) { cfg = new DevConfig(); signalrConfiguration.logging = true; } else { cfg = new ProdConfig(); signalrConfiguration.logging = false; } signalrConfiguration.url = cfg.API_URL + 'signalr'; return signalrConfiguration; } |
Korzystam z tego samego mechanizmu, który wykorzystałem do konfiguracji całej aplikacji. Inne połączenie stosowane jest lokalnie, a inne na serwerze. Taką domyślną konfigurację przekazujemy w pliku app.module.ts
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//app.module.ts signalr config ... import { CreateSignalRConfig } from './config/signalr.config'; import { SignalRModule, SignalRConfiguration } from 'ng2-signalr'; @NgModule({ imports: [ ... SignalRModule.forRoot(CreateSignalRConfig) ], ... }) export class AppModule { } |
Do funkcji connect
obiektu SingalR w naszym resolverze możemy przekazać dodatkowe opcje, które nadpiszą te domyślne. W ten sposób możemy przekazać na przykład tylko nazwę Huba, do którego chcemy się połączyć. Serwer pobrany zostanie wtedy z domyślnej konfiguracji, jest to bardzo wygodne. W samym komponencie możemy teraz dostać obiekt połączenia do serwera w funkcji ngOnInit
, o ile w deklaracji routingu dodaliśmy resolve przy pomocy wcześniej zaimplementowanego resolvera.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//App.routing.module.ts ... { path: 'game/:id', loadChildren: './game/game.module#GameModule', canActivate: [AuthGuard], resolve: { connection: SignalRConnectionResolver } } ... //Component in Angular connection: ISignalRConnection; ngOnInit() { this.connection = this.route.snapshot.data['connection']; } |
Podsumowanie
Na tą chwilę w aplikacji udało mi się zrobić tylko tyle w kwestii SignalR’a. Teraz mogę przejść do konkretnej implementacji mojego rozwiązania. Na pewno w kolejnych postach podzielę się jak ona wygląda.