Efficient SignalR Connection Management

Hello, Habrahabr. I'm currently working on a chat engine based on the SignalR library. In addition to the fascinating process of immersion in the world of real-time applications, I also had to face a number of technical challenges. About one of them, I want to share with you in this article.



Introduction



What is SignalR - it's a kind of facade over WebSockets , Long polling , Server-send events technologies. Thanks to this facade, you can work uniformly with any of these technologies and not worry about the details. In addition, thanks to Long polling technology, you can support clients who, for some reason, cannot work on web sockets, such as IE-8. The facade is represented by a high-level RPC -based API. In addition, SignalR offers to build communications according to the principle of ā€œpublisher-subscriberā€, which in API terminology is called groups. This will be discussed later.



Challenges



Perhaps the most interesting thing in programming is the ability to solve non-standard problems. And today we will designate one of these tasks and consider its solution.



In the era of the development of ideas of scaling and, first of all, horizontal, the main challenge is the need to have more than one server. And the developers of the specified library have already coped with this call, a description of the solution can be found on MSDN . In short, it is proposed, using the publisher-subscriber principle, to synchronize calls between servers. Each server subscribes to a shared bus and all commands sent from this server are sent first to the bus. Next, the command applies to all servers and only then to clients:



image



It is important to note here that each client connected to the server has its own unique connection identifier - ConnectionId - and all messages are ultimately addressed using this identifier. Therefore, each server stores these connections.



However, for unknown reasons, the SignalR library API does not provide access to this data. And here we are faced with a very acute question of access to these connections. This is our challenge.



Why do we need to connect



As noted earlier, SignalR offers a publisher-subscriber model. Here, the unit of message routing is not a ConnectionId but a group. A group is a collection of connections. By sending a message to a group, we send a message to all ConnectionId that are in this group. Itā€™s convenient to build groups - when connecting a client to the server, we simply call the AddToGroupAsync API method:



public override async Task OnConnectedAsync() { foreach (var chat in _options.Chats) await Groups.AddToGroupAsync(ConnectionId, chat); await Groups.AddToGroupAsync(ConnectionId, Client); }
      
      





And how to leave the group? Developers offer API method RemoveFromGroupAsync :



 public override async Task OnDisconnectedAsync(Exception exception) { foreach (var chat in _options.Chats) await Groups.RemoveFromGroupAsync(ConnectionId, chat); await Groups.RemoveFromGroupAsync(ConnectionId, Client); }
      
      





Note that the data unit is ConnectionId. However, from the point of view of the domain model, ConnectionId do not exist, but there are clients. In this regard, the organization of client mapping to the ConnectionId array and vice versa is assigned to users of the specified library.



It is the array of all ConnectionId client that is needed when it leaves the group. However, such an array does not exist. You need to organize it yourself. The task becomes much more interesting in the case of a horizontally scaled system. In this case, some of the connections can be located on one server, the rest on other servers.



Ways to map clients to connections



This topic has a full section on MSDN . The following methods are proposed for consideration:





How to track connections?
You can track connections using the OnConnectedAsync and OnDisconnectedAsync hub methods.



Immediately, I note that options that do not support scaling are not considered. These include the option of storing connections in server memory. There is no access to client connections on other servers, if any. The option of storing in external persistent storage is associated with its drawbacks, which include the problem of cleaning inactive connections. Such connections occur in the event of a hard reboot of the server. Detecting and cleaning these connections is not a trivial task.



Among the above options, the ā€œuser groupā€ option is interesting. Simplicity certainly applies to its advantages - no libraries, repositories are required. Equally important is the consequence of the simplicity of this method - reliability.



But what about Redis?
By the way, using Redis to store connections is also a bad option. This is an acute problem of organizing data in memory. On the one hand, the key is the client, on the other, the group.



"User Group"



What is a ā€œuser groupā€? This is a group in SignalR terminology where only one client can be a client - he himself. This guarantees 2 things:



  1. Messages will be delivered to only one person
  2. Messages will be delivered to all human devices


How will this help us? Let me remind you that our challenge is to solve the problem of leaving the client from the group. We needed that, leaving the group from one device, the rest would also unsubscribe, but we did not have a list of connections for this client, except the one from which we initiated the exit.



"User-group" is the first step towards solving this problem. The second step is to build a ā€œmirrorā€ on the client. Yes, yes, mirrors.



"Mirror"



The source of the commands sent from the client to the server are user actions. Post a message - send a command to the server:



 this.state.hubConnection .invoke('post', {message, group, nick}) .catch(err => console.error(err));
      
      





And we notify all clients of the group about the new post:



 public async Task PostMessage(PostMessage message) { await Clients.Group(message.Group).SendAsync("message", new { Message = message.Message, Group = message.Group, Nick = ClientNick }); }
      
      





However, a number of commands must be executed synchronously on all devices. How to achieve this? Either have an array of connections and execute a command for each connection on a specific client, or use the method described below. Consider this method by exiting the chat.



The team arriving from the client will first go to the "user group" for a special method, which will simply redirect it back to the server, i.e. " Mirrors ." Thus, not the server will unsubscribe the devices, but the devices themselves will ask them to unsubscribe.



Here is an example of a server chat unsubscribe command:



 public async Task LeaveChat(LeaveChatMessage message) { await Clients.OthersInGroup(message.Group).SendAsync("lost", new ClientCommand { Group = message.Group, Nick = Client }); await Clients.Group(Client).SendAsync("mirror", new MirrorChatCommand { Method = "unsubscribe", Payload = new UnsubscribeChatMessage { Group = message.Group } }); }
      
      





 public async Task Unsubscribe(UnsubscribeChatMessage message) { await Groups.RemoveFromGroupAsync(ConnectionId, message.Group); }
      
      





And here is the client code:



 connection.on('mirror', (message) => { connection .invoke(message.method, message.payload) .catch(err => console.error(err)); });
      
      





Let us examine in more detail what is happening here:



  1. The client initiates the unsubscribe - sends the "leave" command to the server
  2. The server sends the ā€œunsubscribeā€ command to the ā€œuser groupā€ on the ā€œmirrorā€
  3. The message is delivered to all client devices.
  4. A message on the client is sent back to the server using the method specified by the server
  5. On each server, the client is unsubscribed from the group


As a result, all devices themselves will unsubscribe from the servers to which they are connected. Each will unsubscribe from his own and we do not need to store anything. No problems will also arise in the event of a hard reboot of the server.



So why do we need to connect?



Having a ā€œuser groupā€ and a ā€œmirrorā€ on the client eliminates the need to work with connections. What do you think, dear readers, about this? Share your opinion in the comments.



Source code for examples:



github.com/aesamson/signalr-server

github.com/aesamson/signalr-client



All Articles