SignalR的基本概念
前言
最近在自己的项目中实践了SignalR的使用,asp.net core 2.1版本的时候建立了对SignalR的支持,SignalR的可使用Web Socket, Server Sent Events 和 Long Polling作为底层传输方式.SignalR基于这三种技术构建, 抽象于它们之上, 它让你更好的关注业务问题而不是底层传输技术问题.它分为了客户端和服务端,服务端支持到asp.net core和asp.net,客户端分语言支持java、javascript、C#等。
SignalR会优先使用websocket连接,浏览器因为版本问题没办法支持的话会使用回落机制,转用SSE或者长轮询的方式来进行。SignalR将上述机制进行封装,采用了一致的API进行编程,使得开发人员不必纠结与技术细节的选型,提高了编程效率。
SignalR采用RPC(remote procedure call)的编程范式来进行客户端和服务端之间的调用。
SignalR利用底层传输来让服务器可以调用客户端的方法, 反之亦然, 这些方法可以带参数, 参数也可以是复杂对象, SignalR负责序列化和反序列化.
HUB
Hub是SignalR的一个组件, 它运行在ASP.NET Core应用里. 所以它是服务器端的一个类.
Hub使用RPC接受从客户端发来的消息, 也能把消息发送给客户端. 所以它就是一个通信用的Hub.
在ASP.NET Core里, 自己创建的Hub类需要继承于基类Hub.
在Hub类里面, 我们就可以调用所有客户端上的方法了. 同样客户端也可以调用Hub类里的方法.
之前说过方法调用的时候可以传递复杂参数, SignalR可以将参数序列化和反序列化. 这些参数被序列化的格式叫做Hub 协议, 所以Hub协议就是一种用来序列化和反序列化的格式.
Hub协议的默认协议是JSON, 还支持另外一个协议是MessagePack. MessagePack是二进制格式的, 它比JSON更紧凑, 而且处理起来更简单快速, 因为它是二进制的.
此外, SignalR也可以扩展使用其它协议..
横向扩展
随着系统的运行, 有时您可能需要进行横向扩展. 就是应用运行在多个服务器上.
这时负载均衡器会保证每个进来的请求按照一定的逻辑分配到可能是不同的服务器上.
在使用Web Socket的时候, 没什么问题, 因为一旦Web Socket的连接建立, 就像在浏览器和那个服务器之间打开了隧道一样, 服务器是不会切换的.
但是如果使用Long Polling, 就可能有问题了, 因为使用Long Polling的情况下, 每次发送消息都是不同的请求, 而每次请求可能会到达不同的服务器. 不同的服务器可能不知道前一个服务器通信的内容, 这就会造成问题.
针对这个问题, 我们需要使用Sticky Sessions (粘性会话).
Sticky Sessions 貌似有很多中实现方式, 但是主要是下面要介绍的这种方式.
作为第一次请求的响应的一部分, 负载均衡器会在浏览器里面设置一个Cookie, 来表示使用过这个服务器. 在后续的请求里, 负载均衡器读取Cookie, 然后把请求分配给同一个服务器.
在ASP.NET Core 中使用SignalR
我在项目中使用SignalR主要是用来通知用户事件创建的结果。
首先你要创建一个HUB:
public class EventMessageHub : Hub<IEventNotification> { }
我就建立了一个空的Hub,它继承自Hub<T>,带泛型的Hub基类表示他是一个强类型的Hub,那么T就是一个接口,这个接口定了了一些发送消息的方法,我的IEventNotification的定义如下:
public interface IEventNotification { Task Notify(string principal,string time, string message); }
我在里面定义了一个方法,用于发送消息到客户端
定义好Hub后你要做的是为这个Hub创建一个URL,使得客户端可以访问得到这个Hub,只有访问到这个Hub才能执行远程的从客户端调用服务端的代码。配置如下:
①首先在StartUp方法中的ConfigureServices方法中配置SignalR的服务:
//signalR services.AddSignalR();
②然后在Configure方法中配置SignalR的中间件:
app.UseSignalR(route => { route.MapHub<EventMessageHub>("/eventMessage"); });
这样,就把这个Hub配置好,你就可以从服务端发送消息给客户端了。
但是SignalR还需要有认证的过程,SignalR在asp.net core中使用的认证是分开的,也就是说,asp.net core本身做好认证后,SignalR是不会识别这个认证的,需要单独为SignalR配置认证。
SignalR中有一个接口,是IUserIdProvider,这个接口用于标志当前的用户ID,他的定义如下:
public interface IUserIdProvider { // // 摘要: // Gets the user ID for the specified connection. // // 参数: // connection: // The connection to get the user ID for. // // 返回结果: // The user ID for the specified connection. string GetUserId(HubConnectionContext connection); }
可见该接口包含一个方法GetUserId,该方法用于标志当前用户。
我给了一个该接口的实现:
public class SignalRUserIdProvider : IUserIdProvider { public string GetUserId(HubConnectionContext connection) { //tokenvalidationparameter中配置的RoleClaimType和NameClaimType在这里不起作用,要用原始的claim var orgIdentifier = connection.User.FindFirst("orgIdentifier")?.Value; if (string.IsNullOrEmpty(orgIdentifier)) { return null; } return orgIdentifier; } }
connection实参上有一个User属性,这个属性的类型是ClaimsPrincipal,这个类型属于asp.net core认证框架下的基本概念,你可以上网查,不管你用什么类型的认证方式,最后识别用户都要转换成这个ClaimsPrincipal.
那想要获取这个User属性的值的话就要进行一些配置。我的项目是前后端分离,采用的是jwt token bearer的认证方式,所以,在这里我只讲这种类型的,等到用mvc开发使用cookie的时候再回来补充。
有一个http的请求头是Authorization(授权的意思,我很奇怪这个头的名字为什么不是Authentication),该请求头标志了认证的类型,比如Basic,或Bearer,格式就是Authorization Bearer XXXXjhhhhhX........这样子。那我们进行认证授权的时候就要配置这个头,请求到达了服务端后服务端就会解析这个头,然后识别发送该请求的客户,下面是我服务端配置的认证的一些代码:
private static void AddJwt(IServiceCollection services, IConfiguration config) {
//添加认证类型 services.AddAuthentication(option => { option.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; option.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(option => { option.TokenValidationParameters = new TokenValidationParameters { //RoleClaimType和NameClaimType的作用是将自定义的claim转化成标准的claim RoleClaimType = "orgRole", NameClaimType = "orgIdentifier", //ensure the token was issued by a trusted authorization server (default value true) ValidateIssuer = true, ValidIssuer = configSection.GetSection("Issuer").Value, //ensure the token audience matches our audience value(default value true) ValidateAudience = true, ValidAudience = configSection.GetSection("Audience").Value, ValidateIssuerSigningKey = true, //specify the key used to sign the token IssuerSigningKey = signingKey, RequireSignedTokens = true, //ensure the token hasn't expired ValidateLifetime = true, RequireExpirationTime = true, //clock skew compensates fro server time drift. we recommend 5 minutes or less ClockSkew = TimeSpan.FromMinutes(5), }; //signalr需要这个配置 option.Events = new JwtBearerEvents() { OnMessageReceived = context => { // If the request is for our hub... var path = context.HttpContext.Request.Path; if (path.StartsWithSegments("/eventMessage")) { var accessToken = context.Request.Query["access_token"]; // Read the token out of the query string context.Token = accessToken; } return Task.CompletedTask; } }; }); }
上面展示了我项目中配置的认证方式,前面的TokenValidationParameters主要用于配置asp.net core服务端认证用户的方式,后面的option.Events = new JwtBearerEvents()。。。。这个就是用来配置SignalR发送请求的时候是不会携带http header头的,所以你不能使用Authorization Bearer xxxooooxxx....这种方式来对服务端进行认证,SignalR采用的都是这样的:
WebSocket connected to ws://localhost:5003/eventMessage?id=RydCi063Wh0kZLzivQLG-w&access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdJZCI6ImU1MjY0ODRiLWNhYjUtNGU1Yy04MTBiLWEwYzQ3MDljYmU3ZCIsImp0aSI6IjE3ZmM2YzY3LTM0NTMtNDA0Yy05MWQ1LWI0MDZjZTZmYzc0MyIsImlhdCI6MTU1NjYxMzg1Mywib3JnUm9sZSI6InNlY29uZGFyeWFkbWluIiwib3JnTmFtZSI6IuS4reWbvemTtuihjOWMheWktOWIhuihjOS_oeaBr-enkeaKgOmDqCIsIm9yZ0lkZW50aWZpZXIiOiJBNDY0MCIsIm9yZzIiOiJCMzQyNiIsIm1hbmFnZW1lbnRMaW5lSWQiOiI3NWIzZjNjMS0zMTU3LTRjMjMtYjc0Ni0yMjhiNDY3OWUwNjQiLCJuYmYiOjE1NTY2MTM4NTMsImV4cCI6MTU1NjYxNTY1MywiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIn0.pmRqvqNGXATprIhmDTCpMGJ-tutgdv4V6GZ3GzMwo3s.
从上面可以看到token是被放到了url的查询字段中的,option.TokenValidationParameters = new TokenValidationParameters{.........}这种方式只适用于http的Authorization头部携带token的方式,所以你就要为你的SignalR设置一套程序来让他能够识别当前用户,也就是认证的过程。上面的代码中的OnMessageReceived 就是要干这个事儿。
也可以看出,jwt token本身就相当于是明文保存,只不过是用base64编码的,你随便把一个jwt token粘贴到一个支持base64 decode的网站上面都可以获取这里面的详细信息。
当我们做好这一步后,我们就可以识别当前用户,在我的项目中我要向特定的用户发送消息:
await _hubContext.Clients.User(notification.TargetOrgIdentifier).Notify(notification.RequestOrgNam, DateTime.Now.ToLocalTime().ToString(), $"发起了{notification.ToString()}");
稍微说明一下这个代码的意图:
_hubContext.Clients.User(string userId)这个方法接受一个UserId,用于标志是哪一个客户,这个UserId我们在前面的IUserIdProvider就已经配置好,只要这个UserId对应的客户端连接上Hub,那么我们就能给这个客户端发送消息。Notify是我们使用强类型的Hub<T>中T接口定义的方法,使用强类型的Hub的好处就是你不用硬编码一个方法,这样就不会因为书写硬编码产生错误。
下面在说一下不使用强类型Hub来编程Hub
public class ChatHub : Hub { public async Task SendMessageAsync(string userId, string message) { await this.Clients.User(userId).SendAsync("ReciveMessage", message); } }
我定义了一个ChatHub的hub,这个hub里面的SendMessageAsync是一种硬编码的写法,SendMessageAsync可以被客户端调用,而方法中的Clients.User(userId).SendAsync(.....方法本身可以作为一个事件在客户端进行监听。比如说JavaScript客户端可以写这样的代码来监听从ChatHub发送过来的消息:
async connect() { this.connection = new HubConnectionBuilder() .withUrl(`${environment.api_url}/eventMessage`, { accessTokenFactory: this.jwtHelper.tokenGetter }) .build();//①首先创建一个远程服务器的连接,使用的是建造者模式来创建 this.connection.on('ReciveMessage', (principal: string, time: string, message: string) => { this.messageList.push({ principal, time, message }); });//②然后让这个连接来监听服务端传回来的事件 await this.connection.start();//③配置好以后就启动这个连接 }
上面的代码使用TypeScript书写,connection是类中的一个私有字段,类型就是HubConnection。
这就是我使用SignalR的过程。后续使用过程中再补充吧。