Cutting Edge - Social-Style Notifications with ASP.NET Core SignalR

Cutting Edge - Social-Style Notifications with ASP.NET Core SignalR

By Dino Esposito | August 2018

Dino EspositoSocial networks and mobile OSes made pop-up, balloon-style notifications incredibly popular and mainstream, but Microsoft Windows 2000 was probably the first software to extensively use them. Balloon notifications make it possible to communicate notable events to the user without requiring immediate action and attention—unlike conventional pop-up windows. Key to these balloon-style notifications is the underlying infrastructure that delivers the message in real time, right to the open window where the user is working.

In this article, you’ll see how to use ASP.NET Core SignalR to produce pop-up notifications. The article presents a sample application that keeps track of logged users and gives each a chance to build and maintain a network of friends. Like in a social-network scenario, any logged user may be added or removed from a friend list at any time. When this happens in the sample application, the logged user receives a context-sensitive notification.

Authenticating Application Users

Balloon-style notifications are not plain broadcast notifications sent to whoever is listening to SignalR messages on a Web socket channel. Rather, they’re sent to specific users, logged into the appli­cation. Opening and listening to a plain Web socket channel is a good way to approach the problem, but ASP.NET Core SignalR just provides a more abstract programming interface and offers support for alternate network protocols beyond WebSockets.

The first step in the building of the application is adding a layer for user authentication. The user is presented a canonical login form and provides her credentials. Once properly recognized as a valid user of the system, she receives an authentication cookie packed with a number of claims, as shown here:

C#
Copy
var claims = new[] {   new Claim(ClaimTypes.Name, input.UserName),   new Claim(ClaimTypes.Role, actualRole) }; var identity = new ClaimsIdentity(claims,   CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(   CookieAuthenticationDefaults.AuthenticationScheme,     new ClaimsPrincipal(identity));

The sample code shows how to create an ad hoc IPrincipal object in ASP.NET Core around a user name once credentials have been successfully verified. It’s key to note that for authentication to work properly in ASP.NET Core, when used in combination with the newest SignalR library, you should enable authentication in the Configure method of the startup class quite early (and in any case, earlier than completing the SignalR route initialization). I’ll return on this point in a moment; meanwhile, let’s review the infrastructure of friendship in the sample application.

Defining Friends in the Application

For the purpose of the sample application, a friend relationship is merely a link between two users of the system. Note that the demo application doesn’t use any database to persist users and relationships. A few users and friend relationships are hardcoded and reset when the application restarts or the current view is reloaded. Here’s the class that represents a friend relationship:

C#
Copy
public class FriendRelationship {   public FriendRelationship(string friend1, string friend2)   {     UserName1 = friend1;     UserName2 = friend2;   }   public string UserName1 { get; set; }   public string UserName2 { get; set; } }

As the user logs in, he’s served an index page that provides the list of friends. Through the UI of the page, the user can both add new friends and remove existing ones (see Figure 1).

The Home Page of a Logged User
Figure 1 The Home Page of a Logged User

When the user types the name of a new friend, an HTML form posts and a new friend relationship is created in memory. If the typed name doesn’t match an existing user, a new User object is created and added to the in-memory list, like so:

C#
Copy
[HttpPost] public IActionResult Add(string friend) {   var currentUser = User.Identity.Name;   UserRepository.AddFriend(currentUser, friend);   // More code here   ...   return new EmptyResult(); }

As you may notice, the controller method returns an empty action result. It’s assumed, in fact, that the HTML form posts its content via JavaScript. Therefore, a JavaScript click handler is attached to the submit button of the form programmatically, as shown here:

HTML/XHTML
Copy
<form class="form-horizontal" id="add-form" method="post"       action="@Url.Action("add", "friend")">       <!-- More input controls here -->       <!-- SUBMIT button -->   <button id="add-form-submit-button"        class="btn btn-danger" type="button">        SAVE  </button></form>

The JavaScript posting code triggers the server-side operation and returns. The new list of friends, whether as a JSON array or an HTML string, can be returned by the controller method and integrated in the current page document object model by the same JavaScript caller code. It works well, but there’s a glitch to take into account that could possibly be an issue in some scenarios.

Imagine the user holds multiple connections to the same server page. As an example, the user has multiple browser windows opened on the same page and interacts with one of those pages. In a situation in which the call brings back a direct response (whether JSON or HTML), only the page from where the request originated ends up being updated. Any other open browser window remains static and unaffected. To work around the problem, you can leverage a feature of ASP.NET Core SignalR that lets you broadcast changes to all connections related to the same user account.

Broadcasting to User Connections

The ASP.NET MVC controller class that receives calls to add or remove friends incorporates a reference to the SignalR hub context. This code shows how that’s done:

C#
Copy
[Authorize] public class FriendController : Controller {   private readonly IHubContext<FriendHub> _friendHubContext;   public FriendController(IHubContext<FriendHub> friendHubContext)   {     _friendHubContext = friendHubContext;   }   // Methods here   ... }

As usual in SignalR programming, the friend hub is defined in the startup class, as shown in Figure 2.

Figure 2 Defining The Hub
C#
Copy
public void Configure(IApplicationBuilder app) {   // Enable security   app.UseAuthentication();   // Add MVC   app.UseStaticFiles();   app.UseMvcWithDefaultRoute();   // SignalR (must go AFTER authentication)   app.UseSignalR(routes =>   {     routes.MapHub<FriendHub>("/friendDemo");   }); }

It’s key that the UseSignalR call follows the UseAuthentication call. This ensures when a SignalR connection is established on the given route that information about the logged user and claims is available. Figure 3 offers a deeper look at the code that handles the form post when the user adds a new friend to the list.

Figure 3 Handling the Form Post
C#
Copy
[HttpPost] public IActionResult Add(string friend) {   var currentUser = User.Identity.Name;   UserRepository.AddFriend(currentUser, friend);   // Broadcast changes to all user-related windows   _friendHubContext.Clients.User(currentUser).SendAsync("refreshUI");   // More code here  ...   return new EmptyResult(); }

The Clients property of the hub context has a property called User, which takes a user ID. When invoked on the User object, the SendAsync method notifies the given message to all currently connected browser windows under the same user name. In other words, SignalR has the ability to group automatically all connections from the same authenticated user to a single pool. In light of this, SendAsync invoked from User has the power to broadcast the message to all windows related to the user, whether they come from multiple desktop browsers, in-app Web views, mobile browsers, desktop clients or whatever else. Here’s the code:

C#
Copy
_friendHubContext.Clients.User(currentUser).SendAsync("refreshUI");

Sending the refreshUI message to all connections under the same user name ensures that all opened windows are in a way synchronized to the add-friend function. In the sample source code, you’ll see that the same thing happens when the currently logged-in user removes a friend from his list.

Configuring the User Client Proxy

In SignalR, the User object has nothing to do with the User object associated with the HTTP context. In spite of the same property name, the SignalR User object is a client proxy and a container of claims. Yet, a subtle relationship exists between the SignalR user-­specific client proxy and the object representing the authenticated user. As you may have noticed, the User client proxy requires a string parameter. In the code in Figure 3, the string parameter is the name of the currently logged-in user. That’s just a string identifier, though, and can be anything you configure it to be.

By default, the user ID recognized by the user client proxy is the value of the NameIdentifier claim. If the list of claims of the authenticated user doesn’t include NameIdentifier, there’s no chance the broadcast would work. So you have two options: One is to add the NameIdentifier claim when creating the authentication cookie, and the other is to write your own SignalR user ID provider. To add the NameIdentifier claim, you need the following code in the login process:

C#
Copy
var claims = new[] {   new Claim(ClaimTypes.Name, input.UserName),   new Claim(ClaimTypes.NameIdentifier, input.UserName),   new Claim(ClaimTypes.Role, actualRole), };

The value assigned to the NameIdentifier claim doesn’t matter as long as it’s unique to each user. Internally, SignalR uses an IUserIdProvider component to match a user ID to the connection groups rooted to the currently logged user, like so:

C#
Copy
public interface IUserIdProvider {   string GetUserId(HubConnectionContext connection); }

The IUserIdProvider interface has a default implementation in the DI infrastructure. The class is DefaultUserIdProvider and is coded as follows:

C#
Copy
public class DefaultUserIdProvider : IUserIdProvider {   public string GetUserId(HubConnectionContext connection)   {     var first = connection.User.FindFirst(ClaimTypes.NameIdentifier);     if (first == null)       return  null;     return first.Value;   } }

As you can see, the DefaultUserIdProvider class uses the value of the NameIdentifier claim to group user-specific connection IDs. The Name claim is meant to indicate the name of the user, but not necessarily to provide the unique identifier through which a user is identified within the system. The NameIdentifier claim, instead, is designed to hold a unique value, whether a GUID, a string or an integer. If you switch to User instead of NameIdentifer, make sure any value assigned to Name is unique per user.

All connections coming from an account with a matching name-­identifier will be grouped together and will automatically be notified when the User client proxy is used. To switch to using the canonical Name claim, you need a custom IUserIdProvider, as follows:

C#
Copy
public class MyUserIdProvider : IUserIdProvider {   public string GetUserId(HubConnectionContext connection)   {     return connection.User.Identity.Name;   } }

Needless to say, this component must be registered with the DI infrastructure during the startup phase. Here’s the code to include in the ConfigureServices method of the startup class:

C#
Copy
services.AddSignalR(); services.AddSingleton(typeof(IUserIdProvider), typeof(MyUserIdProvider));

At this point, everything is set up to have all matching user windows synchronized on the same state and view. How about balloon-style notifications?

The Final Touch

Adding and removing friends cause the need of refresh notifications being sent to the index page the current user is viewing. If a given user has two browser windows opened on different pages of the same application (index and another page), she will receive refresh notifications only for the index page. However, adding and removing friends also causes add and remove notifications to be sent to the users that have been added or removed from the friend relationship list. For example, if user Dino decides to remove user Mary from his list of friends, user Mary will also receive a Remove notification. Ideally, a Remove (or Add) notification should reach the user regardless of the page being viewed, whether index or any other.

To achieve this, there are two options:

  • Use a single SignalR hub with the connection setup moved to the layout page and then inherited by all pages based on that layout.
  • Use two distinct hubs—one for refreshing the UI after adding or removing friends, and one to notify added or removed users.

If you decide to go with distinct hubs, the add/remove notification hub must be set up in all the pages where you want notifications to appear—most likely the layout pages you have in the application.

The sample application uses a single hub completely set up in the layout page. Note that the JavaScript object that references the current connection is globally shared within the client, meaning that the SignalR initialization code is better placed at the top of the layout body and before the Razor’s RenderBody section.

Let’s look at the final code of the Add method in the controller. This method is where the form in Figure 1 ultimately posts. The method makes any necessary changes in the back-end to save friend relationships and then issues two SignalR messages—one to visually refresh the list of friends of the current user that did the operation, and a second to notify the added (or removed) user. The user is actually notified if she’s currently connected to the application and from a page configured to receive and handle those specific notifications. Figure 4 shows this.

Figure 4 The Final Add Method Code
C#
Copy
public IActionResult Add(string addedFriend) {   var currentUser = User.Identity.Name;   // Save changes to the backend   UserRepository.AddFriend(currentUser, addedFriend);   // Refresh the calling page to reflect changes   _friendHubContext.Clients.User(currentUser).SendAsync("refreshUI");   // Notify the added user (if connected)   _friendHubContext.Clients.User(addedFriend).SendAsync("added", currentUser);   return new EmptyResult(); }

In the layout page, some JavaScript code displays a balloon-style notification (or whatever type of UI adjustment you want to make). In the sample application, the notification takes the form of a message displayed on a notification bar for up to 10 seconds, as indicated in the code here:

JavaScript
Copy
friendConnection.on("added", (user) => {   $("#notifications").html("ADDED by <b>" + user + "</b>");   window.setTimeout(function() {             $("#notifications").html("NOTIFICATIONS");     },     10000); });

The results are shown in Figure 5.

Cross-User Notifications
Figure 5 Cross-User Notifications

A Word on SignalR Groups

In this article, I covered the SignalR support for selected notifications sent to a related group of users. SignalR offers a couple of approaches—the User client proxy and groups. The difference is subtle: The User client proxy implicitly generates a number of groups where the name is determined by the user ID and members are all connections opened by the same application user. A group is a more general mechanism that programmatically appends connections to a logical group. Both connections and name of the group are set programmatically.

Balloon-style notifications could have been implemented in both these approaches, but for this particular scenario the User client proxy was the most natural solution. Source code can be found at bit.ly/2HVyLp5.

Dino Esposito has authored more than 20 books and 1,000 articles in his 25-year career. Author of “The Sabbatical Break,” a theatrical-style show, Esposito is busy writing software for a greener world as the digital strategist at BaxEnergy. Follow him on Twitter: @despos.

Nguồn: msdn.microsoft.com