什么是WebSocket和SignalR

  1. WebSocket基于TCP协议,支持二进制通信,双工通信。
  2. 性能和并发能力更强。
  3. WebSocket服务器端部署到Web服务器上,因为可以借助HTTP协议完成初始的握手(可选),并且共享HTTP服务器的端口(主要)

虽然WebSocket是独立于HTTP的,但是我们一般仍然把WebSocket服务器端部署到Web服务器上,因为我们需要借助HTTP完成初始的握手,并且共享HTTP服务器的端口,这样就可以避免为WebSocket单独打开新的服务器端口。因此,SignalR的服务器端一般运行在ASP.NET Core项目中。

SignIR

  1. ASP.NET Core SignalR(以下简称SignalR),是.NET Core平台下对WebSocket的封装。
  2. Hub(集线器),数据交换中心。

SignalR基本使用

第一步,创建一个继承自Hub类的ChatRoomHub类,所有的客户端和服务器端都通过这个集线器进行通信.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ChatRoomHub:Hub
{
//客户端可以通过 SignalR 连接调用此方法,并传递消息内容
public Task SendPublicMessage(string message)
{
//表示当前客户端连接的上下文。
//每个客户端连接的唯一标识符,由 SignalR 自动生成
string connId = this.Context.ConnectionId;
string msg = $"{connId}{DateTime.Now}:{message}";
//Clients.All:表示所有连接到该 Hub 的客户端。
/*SendAsync:异步调用客户端方法。
第一个参数 "ReceivePublicMessage":客户端需要实现的方法名。
第二个参数 msg:传递给客户端的数据。*/
return Clients.All.SendAsync("ReceivePulicMessage", msg);
}
}

ChatRoomHub类中定义的方法可以被客户端调用,也就是客户端可以向服务器端发送请求,方法的参数就是客户端向服务器端传送的消息,参数的个数原则上来讲不受限制,而且参数的类型支持string、bool、int等常用的数据类型。
在ChatRoomHub类中,我们定义了一个方法SendPublicMessage,方法的参数message为客户端传递过来的消息。在第5行代码中,我们获得了当前发送消息的客户端连接的唯一标识ConnectionId;在第6行代码中拼接出一个包含连接ID、当前时间、客户端消息的字符串;随后我们把msg字符串以名字为“ReceivePublicMessage”的消息发送到所有连接到集线器的客户端上。

第二步,编辑Program.cs,在builder.Build之前调用builder.Services.AddSignalR注册所有SignalR的服务,在app.MapControllers之前调用app.MapHub<ChatRoomHub>(“/hubs/ChatRoomHub”)启用SignalR中间件,并且设置当客户端通过SignalR请求“/Hubs/ChatRoomHub”这个路径的时候,由ChatRoomHub进行处理。

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
31
32
33
34
35
36
37
38
39
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
//注入所有SignalR的服务
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = true; // 开发环境显示详细错误
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});

//注入CORS服务
string[] urls = new[] { "http://localhost:5173" };
builder.Services.AddCors(options =>
options.AddDefaultPolicy(builder =>
builder.WithOrigins(urls).AllowAnyMethod()
.AllowAnyHeader().AllowCredentials())
);

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatRoomHub>("/Hub/ChatRoomHub");//启用SignalR中间件
app.MapControllers();

app.Run();

第三步,我们需要编写一共静态HTML页面提供交互界面。

执行如下命令安装SignalR的JavaScript客户端SDK:npm install @microsoft/signalr

在SignalR的JavaScript客户端中,我们使用HubConnectionBuilder来创建从客户端到服务器端的连接;通过withUrl方法来设置服务器端集线器的地址,该地址必须是包含域名等的重连机制。虽然withAutomaticReconnect不是必须设置的,但是设置这个选项之后,如果连接被断开,客户端就会尝试重连,因此使用起来更方便。需要注意的是,客户端重连之后,由于这是个新的连接,因此在服务器端获得的ConnectionId是一个新的值。对HubConnectionBuilder设置完成后,我们调用build就可以构建完成一个客户端到集线器的连接。
通过build获得的到集线器的连接只能是逻辑上的连接,还需要调用start方法来实际启动连接。一旦连接建立完成,我们就可以通过连接对象的invoke函数来调用集线器中的方法,我们可以通过on函数来注册监听服务器适用SendAsync发送的消息的代码。

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
31
32
33
34
35
36
37
<template>
<input type="text" v-model="state.userMessage" v-on:keypress="textMsgOnkeypress"/>
<div>
<ul>
<li v-for="(msg,index) in state.messages" :key="index">{{msg}}</li>
</ul>
</div>
</template>

<script>
import { reactive, onMounted } from 'vue';
import * as signalR from "@microsoft/signalr"
let connection;
export default {
name: "Login",
setup() {
const state = reactive({ userMessage: "", messages: [] });
const textMsgOnkeypress = async function (e) {
if (e.keyCode != 13) return;
await connection.invoke("SendPublicMessage", state.userMessage);
state.userMessage = "";
};
onMounted(async function () {
connection = new signalR.HubConnectionBuilder()//创建从客户端到服务器端的连接
.withUrl("https://localhost:7122/Hub/ChatRoomHub")//设置服务器端集线器的地址
.withAutomaticReconnect() //设置自动重连机制
.build();//构建完成
await connection.start();//自动
//通过on函数来注册监听服务器端使用SendAsync发送的消息的代码
connection.on("ReceivePulicMessage", (msg) => {
state.messages.push(msg);
});
});
return { state, textMsgOnkeypress };
},
};
</script>

功能实现
SignalR 连接初始化:

1
2
3
4
5
connection = new signalR.HubConnectionBuilder()
.withUrl("https://localhost:7122/Hub/ChatRoomHub")
.withAutomaticReconnect()
.build();
await connection.start();

创建 SignalR 连接并配置自动重连
连接到服务器端的 ChatRoomHub 集线器
组件挂载完成后立即启动连接
消息接收处理:

1
2
3
connection.on("ReceivePulicMessage", (msg) => {
state.messages.push(msg);
});

注册事件监听器,监听服务器端发送的ReceivePulicMessage事件
接收到消息后将其添加到messages数组,触发视图更新
消息发送处理:

1
2
3
4
5
const textMsgOnkeypress = async function (e) {
if (e.keyCode != 13) return;
await connection.invoke("SendPublicMessage", state.userMessage);
state.userMessage = "";
};

监听输入框的按键事件,仅处理 Enter 键 (13)
通过connection.invoke调用服务器端的SendPublicMessage方法
发送成功后清空输入框

协议协商

SignalR其实并不只是对WebSocket的封装,它支持多种服务推送的实现方式,包括WebSocket、服务器发送事件(server-sent events)和长轮询。SiganlR的JavaScript客户端会先尝试用WebSocket连接服务器;如果失败了,他再用服务器发送事件方式连接服务器;如果又失败了,它再用长轮询方式连接服务器。因此SignalR会自适应复杂的客户端、网络、服务器环境来支持服务器端推送的实现。

协议协商的问题

  1. 集群中协议协商的问题:“协商”请求被服务器A处理,而接下来的WebSocket请求却被服务器B处理。
  2. 解决方法:粘性会话和禁用协商。
  3. 粘性会话(Sticky Session):把来自同一个客户端的请求都转发给同一台服务器上。缺点:因此共享公网IP等造成请求无法被平均的分配到服务器集群;扩容的自适应性不强。
  4. 禁用协商:直接向服务器发出WebSocket请求。WebSocket连接一旦建立后,在客户端和服务器端直接就建立了持续的网络连接通道,在这个WebSocket连接中的后续往返WebSocket通信都是有同一台服务器来处理。缺点:无法降级到“服务器发送事件”或“长轮询”,不过不是大问题。
1
2
3
4
5
6
7
8
const options = {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
};
connection = new signalR.HubConnectionBuilder() // 创建从客户端到服务器端的连接
.withUrl("https://localhost:7122/Hubs/ChatRoomHub",options) // 设置服务器端集线器的地址
.withAutomaticReconnect() // 设置自动重连机制
.build(); // 构建完成

SignalR分布式部署

在多台服务器组成的分布式环境中,我们可以采用粘性会话或者禁用协商的方式来保证来自同一个客户端的请求被同一台服务器处理,但是在分布式环境中,还有其他问题需要解决。

客户端1发消息3、4收不到,客户端3发消息1、2收不到。因为这两台服务器之间的ChatRoomHub没有通信。为了解决这个问题,我们可以让多台服务器上的集线器连接到一个消息队列中,通过这个消息队列完成跨服务器的消息投递。

微软官方提供了用Redis服务器来解决SignalR部署在分布式环境中数据同步的方案————Redis backplane。

  1. 所有服务器连接到同一个消息中间件。必须使用粘性会话或者跳过协商。

  2. 安装NuGet:
    Micrsosft.AspNetCore.SignalR.StackExchangeRedis

1
2
3
4
builder.Services.AddSignalR().AddStackExchangeRedis("127.0.0.1",options =>
{
options.Configuration.ChannelPrefix = "SignalRChat";
});

如果有多个SignalR应用程序连接同一台Redis服务器,那么我们需要为每一个应用程序配置唯一的ChannelPrefix。

SignalR身认证

配置JWT的代码

  1. 在配置系统中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位为秒)。我们在创建一个对应JWT节点的配置类JWTOptions,类中包含SigningKey、ExpireSwconds这两个属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"JWT": {
"SigningKey": "jlkerujoigsnvjlkasjfwehiojkgnsgss",
"ExpireSeconds": "3600"
},
"ConnectionStrings": {
"Default": "Server=localhost;Database=T_youxianyu;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True;Encrypt=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
1
2
3
4
5
public class JWTOptions
{
public string? SigningKey { get; set; }
public int ExpirSeconds { get; set; }
}
  1. 安装:
    Microsoft.AspNetCore.Authentication.JwtBearer

  2. 编写代码对JWT进行配置。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
var scheme = new OpenApiSecurityScheme()
{
/*Description:在 Swagger UI 中显示的描述信息,指导用户如何输入认证信息*/
Description = "Authorization header.\r\nExample:'Bearer 12345abcdef'",
/*Reference:引用类型为安全方案,ID 为 "Authorization"*/
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Authorization"
},
/*Scheme:指定为 "oauth2",尽管我们使用的是 JWT,但 Swagger UI 使用此值来显示授权按钮*/
Scheme = "oauth2",
/*Name:HTTP 请求头的名称,通常为 "Authorization"*/
Name = "Authorization",
/*In:指定在请求头中传递认证信息*/
In = ParameterLocation.Header,
/*Type:指定为 API 密钥类型*/
Type = SecuritySchemeType.ApiKey,
};
/*将上面定义的安全方案添加到 Swagger 生成器中,名称为 "Authorization"*/
c.AddSecurityDefinition("Authorization", scheme);
/*创建一个安全需求对象,要求所有 API 操作都需要上述定义的安全方案*/
var requirement = new OpenApiSecurityRequirement();
/*空的字符串列表表示不需要任何特定的作用域(scopes),适用于 JWT 认证*/
requirement[scheme] = new List<string>();
c.AddSecurityRequirement(requirement);
});

IServiceCollection services = builder.Services;
//配置数据库
services.AddDbContext<IdDbContext>(opt =>
{
string? connStr = builder.Configuration.GetConnectionString("Default");
opt.UseSqlServer(connStr);
});
services.AddDataProtection(); //对数据进行保护

//调用AddIdentityCore添加标识框架的一些重要的基础服务
services.AddIdentityCore<User>(options =>
{
//对密码进行设置
options.Password.RequireDigit = false; //必须是数字
options.Password.RequireLowercase = false; //小写
options.Password.RequireNonAlphanumeric = false; //非字母数字
options.Password.RequireUppercase = false; //大写字母
options.Password.RequiredLength = 6;//长度6
options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
//在使用标识框架的时候也需要注入和初始化非常多的服务
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
idBuilder.AddEntityFrameworkStores<IdDbContext>()
.AddDefaultTokenProviders()
.AddRoleManager<RoleManager<Role>>()
.AddUserManager<UserManager<User>>();

//从配置文件(如appsettings.json)的 "JWT" 部分读取配置,并注册为JWTOptions类型的选项服务
//后续可通过依赖注入使用这些配置
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));
//设置应用的主要认证方案为 JWT Bearer
//添加 JWT 承载认证处理器
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(x =>
{
//再次读取 JWT 配置(存在重复读取问题)
/*var jwtOptions = builder.Configuration.GetSection("JWT").Get<JWTOptions>();

// 注册JWT选项到依赖注入容器
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

// 生成安全密钥
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));*/
//从配置中获取签名密钥并转换为安全密钥对象
var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTOptions>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey);
var secKey = new SymmetricSecurityKey(keyBytes);
x.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = false, // 不验证令牌发行人
ValidateAudience = false, // 不验证令牌受众
ValidateLifetime = false, // 不验证令牌有效期
ValidateIssuerSigningKey = true, // 验证签名密钥
IssuerSigningKey = secKey // 设置签名密钥
};
/*JwtBearerEvents:这是 JWT 身份验证中间件的事件处理类,允许在身份验证过程的不同阶段插入自定义逻辑。*/
x.Events = new JwtBearerEvents
{
/*OnMessageReceived:当中间件从请求中接收令牌时触发的事件。这里重写了该事件处理方法。*/
OnMessageReceived = context =>
{
//从 URL 查询参数中获取access_token值
var accessToken = context.Request.Query["access_token"];
//检查当前请求路径是否是 SignalR 集线器路径 / Hubs / ChatRoomHub
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/Hub/ChatRoomHub")))
{
//如果两个条件都满足,则将查询参数中的令牌赋值给context.Token,供后续验证使用
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
/*// 从配置中读取JWT选项
var jwtOptions = builder.Configuration.GetSection("JWT").Get<JWTOptions>();

// 注册JWT选项到依赖注入容器
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

// 生成安全密钥
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));

// 配置JWT认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, // 验证发行人
ValidateAudience = true, // 验证受众
ValidateLifetime = true, // 验证过期时间
ValidateIssuerSigningKey = true, // 验证签名密钥
ValidIssuer = jwtOptions.Issuer, // 设置有效发行人
ValidAudience = jwtOptions.Audience, // 设置有效受众
IssuerSigningKey = securityKey, // 设置签名密钥
ClockSkew = TimeSpan.Zero // 严格验证过期时间
};

// 可选:添加事件处理(如令牌验证失败时的自定义响应)
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Add("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});*/
//注入所有SignalR的服务
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = true; // 开发环境显示详细错误
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});

//注入CORS服务
string[] urls = new[] { "http://localhost:5173" };
builder.Services.AddCors(options =>
options.AddDefaultPolicy(builder =>
builder.WithOrigins(urls).AllowAnyMethod()
.AllowAnyHeader().AllowCredentials())
);
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatRoomHub>("/Hub/ChatRoomHub");//启用SignalR中间件
app.MapControllers();

app.Run();
  1. 在app.UseAuthorization();之前添加app.UseAuthentication();

  2. 在控制器类Test1Controller中增加登录并且创建JWT的操作方法Login。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
 private static string BuildToken(IEnumerable<Claim> claims, JWTOptions options)
{
///从配置中读取过期秒数,计算令牌过期时间
DateTime expires = DateTime.Now.AddSeconds(options.ExpirSeconds);
//使用配置中的签名密钥创建安全密钥对象
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
//指定 HMAC SHA-256 算法进行签名
var secKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
//创建 JWT 令牌描述符,包含过期时间, 签名凭证, 用户声明
var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims);
//使用JwtSecurityTokenHandler将令牌描述符转换为 JWT 字符串
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}

[HttpPost]
public async Task<IActionResult> Login(LoginRequest loginRequest, [FromServices] IOptions<JWTOptions> jwtOptions)
{ //LoginRequest:包含用户名和密码的登录请求模型
//IOptions<JWTOptions>:从依赖注入容器获取 JWT 配置选项
string userName = loginRequest.UserName;
string password = loginRequest.Password;
//通过用户名查找用户
var user = await _userManager.FindByNameAsync(userName);
//验证密码是否正确
var success = await _userManager.CheckPasswordAsync(user, password);
if (!success&&user==null)
{
//验证失败返回 HTTP 400 错误
return BadRequest("用户名或者密码错误");
}
var claims = new List<Claim>();
//NameIdentifier:用户唯一标识
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
//Name:用户名
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
var roles = await _userManager.GetRolesAsync(user);
foreach (var role in roles)
{
//Role:用户角色(可包含多个)
claims.Add(new Claim(ClaimTypes.Role, role));
}
//调用辅助方法生成 JWT 字符串
//返回 HTTP 200 响应,包含生成的令牌
string jwtToken = BuildToken(claims, jwtOptions.Value);
return Ok(jwtToken);
}
  1. 在需要登录才能访问的集线器类上或者方法上添加[Authorize]。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Authorize]
public class ChatHub : Hub
{
private readonly UserManager<User> _userManager;

public ChatHub(UserManager<User> userManager)
{
_userManager = userManager;
}

// 发送公共消息
public Task SendPublicMessage(string message)
{
string userName = Context.User.Identity.Name;
string time = DateTime.Now.ToString("HH:mm");
string msg = $"{userName}({Context.ConnectionId}) {time}: {message}";

return Clients.All.SendAsync("ReceivePublicMessage", msg);
}
}
  1. 前端代码
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<template>
<fieldset>
<legend>登录</legend>
<div>
用户名:<input type="text" v-model="state.loginData.userName"/>
</div>
<div>
密码:<input type="password" v-model="state.loginData.password">
</div>
<div>
<input type="button" value="登录" v-on:click="loginClick"/>
</div>
</fieldset>
公屏:<input type="text" v-model="state.userMessage"
v-on:keypress="txtMsgOnkeypress" />
<div>
<ul>
<li v-for="(msg,index) in state.messages" :key="index">{{msg}}</li>
</ul>
</div>
</template>
<script>
import { reactive, onMounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import axios from 'axios';
let connection;
export default {name: 'Login',
setup() {
// state中增加一个对用户名、密码进行绑定的属性,以及一个保存登录JWT的属性
const state = reactive({
accessToken:"",userMessage: "", messages: [],
loginData: { userName: "", password: "" },
privateMsg: { destUserName:"",message:""},
});
// 启动 SignalR 连接
const startConn = async function () {
// 配置 SignalR 连接
const transport = signalR.HttpTransportType.WebSockets;
const options = { skipNegotiation: true, transport: transport };
// 通过options的accessTokenFactory回调函数把JWT传递给服务器端
// 创建并启动连接
options.accessTokenFactory = () => state.accessToken;
connection = new signalR.HubConnectionBuilder()
.withUrl('https://localhost:7122/Hubs/ChatRoomHub', options)
.withAutomaticReconnect().build();
try {
await connection.start();
} catch (err) {
alert(err);
return;
}
// 注册消息接收处理函数
connection.on('ReceivePublicMessage', msg => {
state.messages.push(msg);
});
alert("登陆成功可以聊天了");
};
// 登录按钮的响应函数
const loginClick = async function () {
// 发送登录请求获取 JWT
const resp = await axios.post('https://localhost:7122/Test/Login',
state.loginData);
state.accessToken = resp.data;
// 启动 SignalR 连接
startConn();
};
// 公屏消息输入框的按键处理
const txtMsgOnkeypress = async function (e) {
if (e.keyCode != 13) return;
try {
// 调用服务器端方法发送消息
await connection.invoke("SendPublicMessage", state.userMessage);
}catch (err) {
alert(err);
return;
}
state.userMessage = "";// 清空输入框
};
// 私信输入框的按键处理
const txtPrivateMsgOnkeypress = async function (e) {
if (e.keyCode != 13) return;
const destUserName = state.privateMsg.destUserName;
const msg = state.privateMsg.message;
try {
const ret = await connection.invoke("SendPublicMessage",
destUserName, msg);
if (ret != "ok") { alert(ret);};
} catch (err) {
alert(err);
return;
}
state.privateMsg.message = "";
};
return { state, loginClick, txtMsgOnkeypress, txtPrivateMsgOnkeypress};
},
}
</script>

SignalR:针对部分客户端的消息推送

在我们进行客户端筛选的时候,有3个筛选参数:ConnectionId、组合用户ID。ConnectionionId是SignalR为每个连接分配的唯一标识,我们可以通过集线器的Context属性中的ConnectionId属性获取当前连接的ConnectionId:每个组有唯一的名字,对于连接到同一个集线器中的客户端,我们可以把它们分组;用户ID是登录用户的ID,它对应的是类型为ClaimTypes.NameIdentifier的Claim的值,如果使用用户ID进行筛选,我们需要在客户端登录的时候设定类型为ClaimTypes.NameIdentifier的Claim。

Hub类
Hub类的Groups属性为IGroupManager类型,它可以用来对组成员进行管理,IGroupManager类包含如下所示的方法。

1
2
3
4
//执行后,该客户端将被添加到指定名称的组中
await this.Groups.AddToGroupAsync(connId, message);
//执行后,该客户端将不再属于指定的组
await this.Groups.RemoveFromGroupAsync(connId, message);

我们在把连接加入组中的时候,如果指定名字的组不存在,SignalR会自动创建组。因为连接和组的关系是通过ConnectionId建立的,所以客户端重连之后,我们就需要把连接重新加入组。

Hub类的Clients属性为IHubCallerClients类型,它可以用来对连接到当前集线器的客户端进行筛选。IHubCallerClients类包含如下所示的成员。

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
//获取当前连接的客户端
T Caller { get; }
//获取除了当前连接外的所有客户端
T Others { get; }
//获取名字为groupName组中除了当前连接外的其他客户端
T OthersInGroup(string groupName);

//获取所有连接的客户端
T All { get; }
//获取除了excludedConnectionIds的客户端
T AllExcept(IReadOnlyList<string> excludedConnectionIds);
//获取connectionId客户端
T Client(string connectionId);
//获取包含在connectionIds中的客户端
T Clients(IReadOnlyList<string> connectionIds);
//获取组名groupName中的客户端
T Group(string groupName);
//获取组名groupName中的客户端,除了在excludedConnectionIds中
T GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds);
//获取组名包含在groupNames中的客户端
T Groups(IReadOnlyList<string> groupNames);
//获取用户id的客户端
T User(string userId);
//获取用户id包含在userIds中的客户端
T Users(IReadOnlyList<string> userIds);

发送私聊消息

下面我们来为之前编写的Web聊天室增加“发送私聊消息”的功能。

  1. 在ChatRoomHub中增加一个发送私聊消息的SendPrivateMessage方法。
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
31
32
33
34
35
36
37
38
39
[Authorize]
public class ChatHub : Hub
{
private readonly UserManager<User> _userManager;

public ChatHub(UserManager<User> userManager)
{
_userManager = userManager;
}

// 发送公共消息
public Task SendPublicMessage(string message)
{
string userName = Context.User.Identity.Name;
string time = DateTime.Now.ToString("HH:mm");
string msg = $"{userName}({Context.ConnectionId}) {time}: {message}";

return Clients.All.SendAsync("ReceivePublicMessage", msg);
}

// 发送私人消息
public async Task<string> SendPrivateMessage(string destUserName, string message)
{
var destUser = await _userManager.FindByNameAsync(destUserName);

if (destUser == null)
{
return "目标用户不存在";
}

string srcUserName = Context.User.Identity.Name;
string time = DateTime.Now.ToString("HH:mm");

await Clients.User(destUser.Id.ToString())
.SendAsync("ReceivePrivateMessage", srcUserName, time, message);

return "ok";
}
}

需要注意的是,SignalR不会对消息进行持久化,因此即使目标用户当前不在线,SendAsync方法的调用也不会出错,而且用户上线后也不会收到离线期间的消息。同样的道理也适用于分组发送消息,用户在上线后才能加入一个分组,因此用户也无法收到离线期间该组内的消息。

如果我们的系统需要实现接收离线期间的消息的功能,就需要再自行额外开发消息的持久化功能,比如服务器端在向客户端发送消息的同时,也要把消息保存到数据库中;在用户上线时,程序要先到数据库中查询历史消息。

  1. 在前端页面增加私聊功能的界面和代码。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// 客户端代码
<template>
<div>
<div v-if="!state.isConnected">
<fieldset>
<legend>登录</legend>
<div>
用户名:<input type="text" v-model="state.loginData.userName"/>
</div>
<div>
密码:<input type="password" v-model="state.loginData.password">
</div>
<div>
<input type="button" value="登录" v-on:click="loginClick"/>
</div>
</fieldset>
</div>

<div v-else>
<div>
公屏:<input type="text" v-model="state.userMessage"
v-on:keypress="txtMsgOnkeypress" />
</div>

<div>
私聊给:<input type="text" v-model="state.privateMsg.destUserName"/>
说:<input type="text" v-model="state.privateMsg.message"
v-on:keypress="txtPrivateMsgOnkeypress"/>
</div>

<div>
<h3>消息列表</h3>
<ul>
<li v-for="(msg, index) in state.messages" :key="index"
:class="{ 'private-message': msg.isPrivate }">
{{ msg.content }}
</li>
</ul>
</div>
</div>
</div>
</template>

<script>
import { reactive, onMounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import axios from 'axios';

export default {
name: 'ChatComponent',
setup() {
const state = reactive({
accessToken: "",
userMessage: "",
messages: [],
loginData: { userName: "", password: "" },
privateMsg: { destUserName: "", message: "" },
isConnected: false
});

let connection = null;

const startConn = async function () {
if (connection) {
try {
await connection.stop();
} catch (err) {
console.error('停止连接时出错:', err);
}
}

// 连接到正确的 Hub(ChatHub)
const options = {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
accessTokenFactory: () => state.accessToken
};

connection = new signalR.HubConnectionBuilder()
.withUrl('https://localhost:7122/Hubs/ChatHub', options)
.withAutomaticReconnect().build();

// 监听连接状态
connection.onclose(error => {
console.log('连接已关闭:', error);
state.isConnected = false;
});

connection.onreconnected(connectionId => {
console.log('已重新连接,ID:', connectionId);
state.isConnected = true;
});

try {
await connection.start();
state.isConnected = true;

// 注册消息处理函数
connection.on('ReceivePublicMessage', (msg) => {
console.log('收到公共消息:', msg);
state.messages.push({
content: msg,
isPrivate: false
});
});

connection.on('ReceivePrivateMessage', (srcUser, time, msg) => {
console.log('收到私信:', srcUser, time, msg);
state.messages.push({
content: `${srcUser}在${time}发来私信: ${msg}`,
isPrivate: true
});
});

alert("登录成功,可以开始聊天了");
} catch (err) {
console.error('连接失败:', err);
alert('连接服务器失败: ' + err.message);
state.isConnected = false;
}
};

const loginClick = async function () {
try {
const resp = await axios.post('https://localhost:7122/Test/Login',
state.loginData);
state.accessToken = resp.data;
startConn();
} catch (err) {
alert('登录失败: ' + err.message);
}
};

const ensureConnection = () => {
if (!connection || connection.state !== signalR.HubConnectionState.Connected) {
throw new Error('连接未建立或已断开,请先登录');
}
};

const txtMsgOnkeypress = async function (e) {
if (e.keyCode !== 13) return;

try {
ensureConnection();
await connection.invoke("SendPublicMessage", state.userMessage);
state.userMessage = "";
} catch (err) {
alert(err.message);
}
};

const txtPrivateMsgOnkeypress = async function (e) {
if (e.keyCode !== 13) return;

const destUserName = state.privateMsg.destUserName;
const msg = state.privateMsg.message;

if (!destUserName || !msg) {
alert('请输入目标用户和消息内容');
return;
}

try {
ensureConnection();
const ret = await connection.invoke("SendPrivateMessage", destUserName, msg);
if (ret !== "ok") {
alert(ret);
} else {
// 在自己的消息列表中显示已发送的私信
const time = new Date().toLocaleTimeString();
state.messages.push({
content: `你在${time}给${destUserName}发送了私信: ${msg}`,
isPrivate: true
});
}
state.privateMsg.message = "";
} catch (err) {
alert(err.message);
}
};

// 组件卸载时关闭连接
onMounted(() => {
return () => {
if (connection) {
connection.stop().catch(err => {
console.error('关闭连接时出错:', err);
});
}
};
});

return { state, loginClick, txtMsgOnkeypress, txtPrivateMsgOnkeypress };
}
}
</script>

<style scoped>
.private-message {
color: blue;
font-weight: bold;
}
</style>

在外部向集线器推送消息

不要再Hub做事务,违法单一原则。

可以在MVC控制器、托管服务等外部向客户端推送消息。
可以通过注入IHubContext<THub>来获取对集线器进行操作的服务。

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
        private readonly ILogger<TestController> _logger;
private readonly UserManager<User> _userManager;
private readonly RoleManager<Role> _roleManager;
private readonly IHubContext<ChatHub> hubContext;

public TestController
(
ILogger<TestController> logger,
UserManager<User> userManager,
RoleManager<Role> roleManager
,
IHubContext<ChatHub> hubContext)
{
_logger = logger;
_userManager = userManager;
_roleManager = roleManager;
this.hubContext = hubContext;
}
[HttpPost]
public async Task<ActionResult> AddUser(string userName,string password)
{
User user = new User { UserName = userName };
await _userManager.CreateAsync(user,password);
hubContext.Clients.All.SendAsync("PublicMsgReceived", "欢迎" + userName + "加入我们");
return Ok("创建成功");
}