SignalR – Why? Because its easy

CODING ·
angular signalr realtime asp-net-core

SignalR has been around for a few years now (since 2013), offering real-time notifications to our .Net applications.

SignalR has been around for a few years now (since 2013), offering real-time notifications to our .Net applications. I was always hesitant to implement any type of real-time notifications into my applications based on the following assumptions:

  1. I work on enterprise applications, and my users don’t really need/expect it
  2. It’s too hard

Assumption 1: Users don’t need it

Well, this is just an excuse to avoid the pain of implementation. In the modern web, our users are trained to expect responsive applications and to have information pushed to them: Message notifications, Emails, Tweets, it all happens quite subtly, but honestly, our users these days (especially in the world of Single-Page-Apps) really aren’t used to hitting refresh. My users are no different. If they don’t receive real-time feedback, they think the entire processing pipeline has faulted.

Assumption 2: It’s too hard

Wrong. Boy, was I wrong in that one too. With the release of ASP.NET Core 2.1 Microsoft has baked SignalR into the framework and made it so simple for us to add it to our websites.

As I’ve added SignalR to my production Angular/Web API apps, I’ve distilled the process into four simple steps for both the server and the client, and I want to show how easy it really is

Step 1. Create a Hub

namespace ShoppingList.Api.SignalR
{
  public class ShoppingListHub : Hub
  {
    public const string SHOPPINGLIST_GROUP = "ShoppingList_";
    public async Task JoinList(string listId)
    {
        await Groups.AddToGroupAsync(this.Context.ConnectionId, $"{SHOPPINGLIST_GROUP}{listId}");
    }

    public async Task LeaveList(string listId)
    {
        await Groups.RemoveFromGroupAsync(this.Context.ConnectionId, $"{SHOPPINGLIST_GROUP}{listId}");
    }
  }
}

Our hub is effectively the controller of our SignalR channel. There is very little (actually nothing) that needs to live in our hub. In this implementation, I do have two methods: JoinList() and LeaveList() these methods allow users to subscribe and unsubscribe to groups, so we can broadcast messages only to those clients that have subscribed to the group.

Step 2: Create a notification service (optional)

public class ShoppingListNotificationService : IShoppingListNotificationService
{
  private readonly IHubContext<ShoppingListHub> hub;
  private readonly ShoppingListDbContext db;

  public ShoppingListNotificationService(IHubContext<ShoppingListHub> hub, ShoppingListDbContext db)
  {
      this.hub = hub;
      this.db = db;
  }
  
  public async Task NotifyShoppingListItemAdded(int shoppingListId, ShoppingListItem item)
  {
      var group = hub.Clients.Group($"{ShoppingListHub.SHOPPINGLIST_GROUP}{shoppingListId}");
      await group.SendAsync("ShoppingListItem_Added", item);
  }
  
  public async Task NotifyShoppingListItemUpdated(int shoppingListId, ShoppingListItem item)
  {
      var group = hub.Clients.Group($"{ShoppingListHub.SHOPPINGLIST_GROUP}{shoppingListId}");
      await group.SendAsync("ShoppingListItem_Updated", item);
  }
  
  public async Task NotifyShoppingListUpdated(Entities.ShoppingList item)
  {
      var group = hub.Clients.Group($"{ShoppingListHub.SHOPPINGLIST_GROUP}{item.Id}");
      await group.SendAsync("ShoppingList_Updated", item);
  }
  
  public async Task NotifyRefreshList()
  {
      await hub.Clients.All.SendAsync("ShoppingLists_Refresh", db.ShoppingLists);
  }
}

This service is completely optional, but it allows us to encapsulate the calls to SignalR in one central location, and use it throughout our application. Within each message, we then broadcast the message to either all connected clients, or to a specific group of clients (Those that subscribed via the previous JoinList() method).

Step 3: Update API Controllers

[HttpPost("{shoppingListId}/Items")]
public async Task<IActionResult> PostItem(int shoppingListId, [FromBody] ShoppingListItem item)
{
//...code removed for brevity
  await notifications.NotifyShoppingListItemAdded(shoppingListId, item);
  return CreatedAtRoute("ShoppingListItem", new { shoppingListId, item.Id }, item);
}

As items are Created, Updated or Deleted, we now ask the notification service to send the appropriate message.

Step 4: Configure Startup.cs

public void ConfigureServices(IServiceCollection services)
{
//Excess code removed for readability

  //Turn on SignalR
  services.AddSignalR();

  //Register our Notification service in the DI Container
  services.AddTransient<IShoppingListNotificationService, ShoppingListNotificationService>();
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
//Excess code removed for readability

  //Configure the Hub endpoint
  app.UseSignalR(route =>
  {
    route.MapHub<ShoppingListHub>("/hubs/shoppingLists");
  });
}

In ASP.NET Core 2.1 there is really only 2 steps required here:

  1. Add SignalR to your services a. optional: Register your notification service with the DI container
  2. Map the hub to a SignalR endpoint

Step 1: Install the SignalR client

  npm -i --save @aspnet/signalr

Step 2: Create a service to manage the connection

@Injectable({
  providedIn: 'root'
})
export class SignalRService {

  private _hubConnection: HubConnection;
  isConnected = false;

  constructor() {
    this.init();
  }

  private init() {
    this._hubConnection = new HubConnectionBuilder()
    .withUrl('https://localhost:18111/hubs/shoppingLists')
    .build();

    this._hubConnection.onclose(e => {
      this.isConnected = false;
        if (e) {
          console.log('Hub connection closed due to the following error' + e.name);
          console.log(e.message);
          this.connect();
        } else {
          console.log('Hub connection closed');
        }
    });
  }

  connect() {
    this._hubConnection.start()
    .then(() => {
      this.isConnected = true;
      console.log('Hub connection started');
    })
    .catch(err => {
        this.isConnected = false;
        console.log('Error while establishing connection');
    });
  }
}

The first part here is to build our hub connection and point it to the endpoint configured on the server. Then we need to remember to Start() our connection when we want to start listening to events.

Next, we want to listen to all the events that we configured in the notification service on the server

shoppingListItemAdded = new Subject<ShoppingListItem>();
shoppingListItemUpdated = new Subject<ShoppingListItem>();

init(){
//previous steps removed for clarity

  this._hubConnection.on('ShoppingListItem_Added', (item: ShoppingListItem) => {
    console.log('Item Added: ' + item.id);
    this.shoppingListItemAdded.next(item);
  });

  this._hubConnection.on('ShoppingListItem_Updated', (item: ShoppingListItem) => {
    console.log('Item Updated: ' + item.id);
    this.shoppingListItemUpdated.next(item);
  });

//The remaining events are handled in the same way
}

We are now listening to each event being raised and storing the results in an RxJS Subject.

joinShoppingList(id: number) {
  this._hubConnection.invoke('JoinList', id);
}

leaveShoppingList(id: number) {
  this._hubConnection.invoke('LeaveList', id);
}

Finally, we need the ability to invoke the JoinList() and LeaveList() methods we created on the hub.

SignalR.service.ts

@Injectable({
  providedIn: 'root'
})
export class SignalRService {

  private _hubConnection: HubConnection;
  isConnected = false;

  shoppingListsRefresh = new Subject();
  shoppingListItemAdded = new Subject<ShoppingListItem>();
  shoppingListItemUpdated = new Subject<ShoppingListItem>();
  shoppingListUpdated = new Subject<ShoppingList>();

  constructor() {
    this.init();
  }

  private init() {
    this._hubConnection = new HubConnectionBuilder()
      .withUrl('https://localhost:18111/hubs/shoppingLists')
      .build();
  
    this._hubConnection.on('ShoppingListItem_Added', (item: ShoppingListItem) => {
      console.log('Item Added: ' + item.id);
      this.shoppingListItemAdded.next(item);
    });

    this._hubConnection.on('ShoppingListItem_Updated', (item: ShoppingListItem) => {
      console.log('Item Updated: ' + item.id);
      this.shoppingListItemUpdated.next(item);
    });

    this._hubConnection.on('ShoppingList_Updated', (list: ShoppingList) => {
      console.log('ShoppingListUpdated: ' + list.id);
      this.shoppingListUpdated.next(list);
     });

     this._hubConnection.on('ShoppingLists_Refresh', () => {
      console.log('Refreshing Lists');
      this.shoppingListsRefresh.next();
     });

    this._hubConnection.onclose(e => {
      this.isConnected = false;
      if (e) {
        console.log('Hub connection closed due to the following error' + e.name);
        console.log(e.message);
        this.connect();
        } else {
        console.log('Hub connection closed');
      }
    });
  }

  connect() {

    this._hubConnection.start()
    .then(() => {
        this.isConnected = true;
        console.log('Hub connection started');
    })
    .catch(err => {
        this.isConnected = false;
        console.log('Error while establishing connection');
    });
  }

  joinShoppingList(id: number) {
    this._hubConnection.invoke('JoinList', id);
  }

  leaveShoppingList(id: number) {
    this._hubConnection.invoke('LeaveList', id);
  }
}

Step 3: Update the Components

ngOnInit() {
  const param$ = this.route.params.pipe(
    pluck('id'),
    tap(id => {
      if (this.currentId) {
        this.signalR.leaveShoppingList(this.currentId);
      }
    }),
    tap(id => {
      this.signalR.joinShoppingList(+id);
      this.currentId = +id;
    }),
    switchMap(id => {
      if (id === 'new') {
        return of(new ShoppingList());
      } else {
        return this.service.get(+id);
      }
    })
  );

  const itemRefresh$ =
    merge(this.signalR.shoppingListItemAdded, this.signalR.shoppingListItemUpdated).pipe(
      switchMap(() => this.service.get(this.currentId))
    );

    this.list$ = merge(param$, this.signalR.shoppingListUpdated, itemRefresh$);
  }

Here, we do a few things. First up, configure our listener to the route being updated, and unsubscribe/subscribe to the desired lists notifications, then we also fetch the desired list from the server. Secondly, we merge the two Subjects from the notification service that we are interested in, and finally, we merge all the required triggers into one observable that ultimately populates the list$ property with the most current data from the server.

Conclusion

  1. Microsoft has made it almost too trivial to add push notifications to your ASP.NET Core applications.
  2. The client library is simply JS, as such can be used on any front-end
  3. RxJS really simplifies our client code to merge the varying streams of information into one simple observable.

If you are interested in any of the code on this post, you can find it here on Github.