ASP.NET Core SignalR 配置与集成测试究极指南

这篇文章也可以在我的博客中查看

前言

哥们最近都在埋头苦干,沉默是金,有一段时间没更新博客了。然而今儿SignalR集成测试实属是给我整破防了。虽说SignalR是.NET官方维护的实时通信库,已经开发了有十几年,甚至已经编入至了core dll,然而更新迭代异常迅速,导致文档不全,出了事不知所措。这不最近在集成测试SignalR这点上就踩了大坑。

今天就给大伙分享一下如何配置SignalR,并重点讲解如何在 .NET 8 中使用xUnitMicrosoft.AspNetCore.Mvc.Testing.WebApplicationFactory对最新版(ASP.NET Core)SignalR进行集成测试,希望后来者可以少走弯路。

痛点

SignalR测试为何困难,原因有下:

  1. WebApplicationFactory,或者说其背后的TestServer,并不提供真的服务器环境,所有默认配置下的网络客户端(当然包括HttpClient)都无法连接至该模拟的服务器。
    • 然而SignalR客户端所有连接都是在默认网络环境下的,需要替换成TestServer环境下的客户端
  2. HttpClient并不提供WebSocket连接支持。
    • 然而SignalR实时通讯首选的是WebSocket,所以我们还要配个TestServer环境下的WebSocket客户端
  3. Hub受身份验证保护。
    • 替换成TestServer客户端的时候还需要考虑身份验证

汗流浃背了家人们

关于本文

本文按这三个问题为思路逐步进行,最合理的解决方案会在文末给出。

如果你觉得TL;DR、不想关注过程、或者认为看代码比看文章舒服,可以跳转到文章最后获取项目源码👇

本文只介绍SignalR配置与集成测试,阅读本文前建议做以下准备工作(本文可能不会介绍以下内容):

  1. SignalR的使用(只提及部分)
  2. 配置[Authorize]身份认证(只一笔带过)
  3. 配置.NET集成测试框架,如 xUnit
  4. 配置WebApplicationFactory

本文操作环境:

  1. .NET 8
  2. xUnit 测试框架

无身份验证SignalR

在引入复杂性之前,应先处理最核心的配置,因此先不配置身份验证。

基本配置

配置Hub

在 .NET 8 中,SignalR已经集成至ASP.NET Core中,因此不需要下载任何Nuget包就能够使用。

配置也十分简洁,首先需要创建一个HubHub相当于是SignalR中的控制器。

创建Hub非常简单,只需要继承Hub即可。以下例子展示了一个最基本的收发消息ChatHubSendMessage向所有连接广播一条消息:

csharp 复制代码
using Microsoft.AspNetCore.SignalR;

namespace SignalR.IntegrationTests;

public class ChatHub : Hub
{
    public async Task SendMessage(string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", message);
    }
}
  1. SendMessage是客户端向服务端发送消息的入口
    • 该方法可以有返回值,返回值会传回调用者
  2. ReceiveMessage是服务端向客户端发送消息的入口
    • message是参数,参数不一定只有一个,也不一定为string
  3. A向B发送一条聊天信息其实需要经历两次交互
    1. A向服务器发送消息
    2. 服务器向B发送消息

配置Program.cs

Program.cs中注册SignalR组件,最简单的配置如下:

csharp 复制代码
const string HubsPrefix = "/hubs"; // <-- Grouped by prefix /hubs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(); // <-- Add SignalR

var app = builder.Build();

app.MapGroup(HubsPrefix).MapHub<ChatHub>("/chat"); // <-- Map your ChatHub to /hubs/chat

app.Run();

强类型Hub

上面的例子中,服务端消息方法ReceiveMessage是字符串,众所周知字符串意味着弱类型,无编译时提示,稍不留神可能就会写错。

.NET提供了一个做法强类型化这些方法。

首先定义一个接口:

csharp 复制代码
public interface IChatClientProxy
{
    public Task ReceiveMessage(string message);
}

由于客户端还是需要以字符串订阅消息,因此函数应以客户端的角度进行命名:

  1. Receive而不是Send
  2. 虽然是异步方法,但不加Async后缀

然后将ChatHub修改如下:

csharp 复制代码
public class ChatHub : Hub<IChatClientProxy>
{
    public async Task SendMessage(string message)
    {
        await Clients.All.ReceiveMessage(message);
    }
}

SignalR会自动实现IChatClientProxy接口,当调用这个接口的方法时,对应名称的消息就会被发出。

在Hub外向客户端发送消息

更多时候我们会在Hub之外发送消息,就需要借助IHubContext获取Hub上下文。这个接口也支持强类型化。

以下实现了一个简单的服务,先做一系列检测和记录,再使用IHubContext实现实时发送消息:

csharp 复制代码
using Microsoft.AspNetCore.SignalR;

namespace SignalR.IntegrationTests;

public class ChatService(IHubContext<ChatHub, IChatClientProxy> _hubContext)
{
    public async Task SendMessageToAllAsync(string message)
    {
        // Chek for permissions...
        // Record to database...
        // ...
        await _hubContext.Clients.All.ReceiveMessage(message);
    }
}

为了保持程序中的一致性,通常情况下也会希望在Hub中引用自己的服务,而不是直接发送消息:

csharp 复制代码
public class ChatHub(ChatService _chatService) : Hub<IChatClientProxy>
{
    public async Task SendMessage(string message)
    {
        await _chatService.SendMessageToAllAsync(message);
    }
}

别忘了在Program.cs中为自己的服务注册依赖注入:

csharp 复制代码
builder.Services.AddScoped<ChatService>();

在客户端中接收SignalR消息

呃,严格意义上你无法在服务端中接收SignalR消息,你需要一个客户端接收服务端发出的信息。

以下代码是客户端代码,它可能位于另一个项目,可以是另一种语言实现,甚至可以处于另一个平台(e.g. Android)

但是它也可以碰巧是同一个平台,又碰巧是C#实现,甚至碰巧在同一个项目 😉

总之如果要在C#中接收SignalR消息,你需要安装客户端Nuget包Microsoft.AspNetCore.SignalR.Client

下面的例子展示了如何向服务端的ChatHub收发消息:

csharp 复制代码
using Microsoft.AspNetCore.SignalR.Client;
using System.Diagnostics;

var connection = new HubConnectionBuilder().WithUrl("http://localhost/hubs/chat").Build();
// Add receive message handler.
connection.On<string>("ReceiveMessage", (message) => Debug.WriteLine(message));

await connection.StartAsync();

// Send message.
await connection.InvokeAsync("SendMessage", "Hello World");

await connection.StopAsync();
  1. On方法用于接收消息。注意泛型参数一定要与服务端的类型兼容,否则可能收不到对应消息
  2. InvokeAsync方法用于发送消息。第一个参数是远程方法名,第二个起是远程方法对应的参数
    1. 该方法可以有泛型参数TResult,以接受对应类型的返回值
  3. HubConnectionBuilder还可以配置断线重连、身份验证等功能,具体请查阅官方文档

集成测试

准备工作

进行下一步之前,需要先:

  1. 新建一个 xUnit 项目
  2. 添加主项目为依赖项
  3. 在测试项目中安装并配置WebApplicationFactory
  4. 在测试项目中安装Microsoft.AspNetCore.SignalR.ClientNuget包

测试用例

根据含义,我们会尝试使用SignalR客户端发送一条消息,然后断言能够收到消息:

csharp 复制代码
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR.Client;

public class WebAppFactory : WebApplicationFactory<Program> { }

public class HubIntegrationTests(WebAppFactory _factory) : IClassFixture<WebAppFactory>
{
    private HubConnection SetupHubConnection(string path)
    {
        var uri = new Uri(_factory.Server.BaseAddress, path);
        return new HubConnectionBuilder().WithUrl(uri).Build();
    }

    [Fact]
    public async Task MessageTest()
    {
        // --> Arrange
        var connection = SetupHubConnection("/hubs/chat");

        string? received = null;
        connection.On<string>("ReceiveMessage", (m) => received = m);

        await connection.StartAsync();

        string message = "Hello World";


        // --> Act
        await connection.InvokeAsync("SendMessage", message);
        // Wait for messages to be received. You may need to increase the delay if you're running in a slow environment.
        await Task.Delay(1);


        // --> Assert
        Assert.Equal(message, received);
    }
}
  • SetupHubConnection函数用作连接SignalR服务器。
  • 其中等待了1毫秒以确保有足够的时间接收消息
    • 如果你的测试环境是老爷机,可能需要增加等待时间

然而这个用例会失败,错误如下:

text 复制代码
System.Net.Http.HttpRequestException : No connection could be made because the target machine actively refused it. (localhost:80)

原因是TestServer并不是真的服务器,它只模拟ASP.NET应用服务器的行为,而不会在宿主机环境中启动真的服务器。因此我们使用常规的方式进行连接是无法访问的。但没有关系......

非WebSocket传输模式的测试

TestServer提供了一个用于连接至测试服务器的HttpMessageHandler对象,也就是任何支持HttpMessageHandler进行Http数据交换的库都可以通过使用该对象访问TestServer

经常接触.NET测试的伙伴此时已经要素察觉了:HttpClientHttpMessageHandler就是原生支持的!

然后还有两个好消息:

  1. WebSocket模式下的SignalR发起的连接使用的就是HttpClient
    • 没错,只是非WebSocket,但总比连接失败要好!
  2. SignalR提供了一个配置项,可以替换内部HttpClient使用的HttpMessageHandler

所以解决方案很简单,只需要将上述SetupHubConnection函数修改成以下形式:

csharp 复制代码
private HubConnection SetupHubConnection(string path)
{
    var server = _factory.Server;

    var uri = new Uri(server.BaseAddress, path);

    return new HubConnectionBuilder()
        .WithUrl(uri, o =>
        {
            o.HttpMessageHandlerFactory = _ => server.CreateHandler();
        })
        .Build();
}

TestServer.CreateHandler()生成了一个HttpMessageHandler,将它赋值给HttpMessageHandlerFactory,可以改变其内部HttpClient的连接行为,使其得以与TestServer进行交互。

问题

虽然测试是能通过了,但是注意到测试时间长达4秒,这对于本地服务器来讲显然是不正常的:

text 复制代码
========== Starting test run ==========
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.5.3.1+6b60a9e56a (64-bit .NET 8.0.4)
[xUnit.net 00:00:00.04]   Starting:    SignalR.IntegrationTests.Tests
[xUnit.net 00:00:04.37]   Finished:    SignalR.IntegrationTests.Tests
========== Test run finished: 1 Tests (1 Passed, 0 Failed, 0 Skipped) run in 4.4 sec ==========

原因是因为产生了等待。事实上,这个用例并没有建立WebSocket连接,而是在等待WebSocket连接超时后,转为了使用LongPolling模式连接。

如果我们强制限制SignalR客户端使用WebSocket连接:

csharp 复制代码
private HubConnection SetupHubConnection(string path)
{
    var server = _factory.Server;

    var uri = new Uri(server.BaseAddress, path);

    return new HubConnectionBuilder()
        .WithUrl(uri, o =>
        {
            o.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; // WebSockets only.
            o.HttpMessageHandlerFactory = _ => server.CreateHandler();
        })
        .Build();
}

这个用例会在4秒后超时失败:

text 复制代码
System.AggregateException : Unable to connect to the server with any of the available transports. (WebSockets failed: Unable to connect to the remote server) (ServerSentEvents failed: The transport is disabled by the client.) (LongPolling failed: The transport is disabled by the client.)

轮询并不是一般情况下的连接方式,而且我们也不希望每个连接都等待4秒,所以,有没有办法能够进行Socket连接?

WebSocket传输模式的测试

WebSocket连接失败的原因是WebSocketClient独立于HttpClient,虽然我们构建了SignalR内部HttpClientTestServer之间的连接,但是并没有改变WebSocketClient,它仍然是向真正的宿主机环境建立连接,所以必然会失败。

但是没有关系,这个问题早在几年前就被SignalR团队注意到,并提供了替换WebSocketClient的配置项:

csharp 复制代码
private HubConnection SetupHubConnection(string path)
{
    var server = _factory.Server;

    var uri = new Uri(server.BaseAddress, path);

    return new HubConnectionBuilder()
        .WithUrl(uri, o =>
        {
            o.Transports = HttpTransportType.WebSockets;
            o.HttpMessageHandlerFactory = _ => server.CreateHandler();
            // Support WebSocket transports.
            o.WebSocketFactory = async (context, cancellationToken) =>
            {
                var wsClient = server.CreateWebSocketClient();
                return await wsClient.ConnectAsync(context.Uri, cancellationToken);
            };
            o.SkipNegotiation = true;
        })
        .Build();
}

通过配置WebSocketFactory,可以将默认的WebSocketClient换成TestServer提供的客户端。从而能够对其进行WebSocket访问。

在WebSocket模式下,顺便设置了SkipNegotiation,可以减少协商时间,而不会影响结果。

这里其实可以省略HttpMessageHandlerFactory的配置,因为使用WebSocket时不会用到HttpClient。但如果使用LongPolling则很重要,因此还是保留以供选择。

修改了WebSoketClient配置后,重新运行测试用例,这次可以快速以WebSocket模式通过测试:

text 复制代码
========== Starting test run ==========
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.5.3.1+6b60a9e56a (64-bit .NET 8.0.4)
[xUnit.net 00:00:00.03]   Starting:    SignalR.IntegrationTests.Tests
[xUnit.net 00:00:00.21]   Finished:    SignalR.IntegrationTests.Tests
========== Test run finished: 1 Tests (1 Passed, 0 Failed, 0 Skipped) run in 216 ms ==========

带身份验证SignalR

身份配置

SignalR的身份验证方式

SignalR可以使用CookieToken令牌两种方式进行身份认证。

Cookie是浏览器环境下的首选方式,可以自动传递凭证;而Token则是非浏览器客户端下最简便的做法。

由于Cookie开箱即用,不需要做额外配置,因此本文只重点介绍Token做法。

SignalR Token令牌传递方式

根据SignalR文档,在不同情况下有不同的传达方式:

  1. 在非浏览器环境中,以Authorization请求头的方式传递
  2. 在浏览器环境的WebSocket, Server Side Event模式下,无法使用自定义请求头,需要以查询字符串的方式传递
    • 该查询字符串需要在身份验证服务器自行读取接收

服务端配置接收access_token

所有无法自定义连接请求头的情况下,都约定使用一个写死的 (😅微软你也干这事啊)查询字符串access_token作为身份认证的参数。

你写死不要紧,要紧的是我们使用SignalR是需要手动处理这个查询字符串的,否则这种情况下永远无法触发身份验证。

虽然官网有说明,但是总有像我一样的愣头青不喜欢看官方文档然后捣鼓了一整天才发现涅麻麻的要手动配置这个查询字符串。

所以为了减少愣头青,请你务必:
按照以下操作配置查询字符串!
按照以下操作配置查询字符串!
按照以下操作配置查询字符串!

接收查询字符串

需要在SignalR服务端中主动接收这个查询字符串。

使用不同的身份验证库,需要以不同的方式进行接收:

  1. 你使用了内置的JWT库或者Identity Server,可以参照官方文档进行配置
  2. 你使用了Identity内置的BearerToken,可以在Bearer Token中间件进行配置(见下文)
  3. 你使用了其它的身份验证库,基本也是相同的套路:需要在验证请求事件中手动将该查询字符串赋值为用户凭证
Identity 内置Bearer Token身份验证

我这里使用了 .NET 8 Identity的内置BearerToken,所以能够实现目标的最小配置Program.cs是这样的:

csharp 复制代码
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SignalR.IntegrationTests;

const string HubsPrefix = "/hubs";

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddAuthentication(IdentityConstants.BearerScheme)
    .AddCookie(IdentityConstants.ApplicationScheme)
    .AddBearerToken(IdentityConstants.BearerScheme, o =>
    {
        o.Events = new()
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                // If the request is for our hub...
                if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubsPrefix))
                {
                    // Read the token out of the query string
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
builder.Services.AddIdentityCore<IdentityUser>()
    .AddApiEndpoints()
    .AddEntityFrameworkStores<IdentityDbContext>();
builder.Services.AddDbContext<IdentityDbContext>(x => x.UseInMemoryDatabase("db"));

builder.Services.AddSignalR();

var app = builder.Build();

app.MapIdentityApi<IdentityUser>();

app.MapGroup(HubsPrefix).MapHub<ChatHub>("/chat");

app.Run();

使用身份验证保护Hub

与Controller一样,通过使用AuthorizeAllowAnonymous特性控制对Hub的访问

csharp 复制代码
[Authorize]
public class ChatHub(ChatService _chatService) : Hub<IChatClientProxy>
{
    // ......
}

为Hub连接提供身份验证

Cookie验证
  1. 浏览器环境中,正常使用Cookie登录,凭证会在请求时自动携带
  2. 非浏览器环境中,可以通过手动设置Cookie请求头实现Cookie验证
    • 但这种做法不如使用Token更加正规
Token令牌验证

Token可以在客户端发起连接前使用AccessTokenProvider提供。

csharp 复制代码
var connection = new HubConnectionBuilder()
    .WithUrl("http://localhost/hubs/chat", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(token);
    })
    .Build();

考虑到重连与Token过期问题,AccessTokenProvider接受的是一个工厂函数,你可以选择动态获取新Token,而不是写死一个值

集成测试

由于我们替换了默认的WebSocketClient,我们需要手动携带Token令牌,以支持WebSocket模式下的身份验证;非WebSocket的身份验证仍然使用AccessTokenProvider配置项,无需修改。因此修改SetupHubConnection方法:

  1. 配置AccessTokenProvider参数,使非WebSocket连接方式能够携带令牌
  2. token添加至WebSocketClient中,使WebSocket连接方式能够携带令牌。由于是非浏览器环境,有两种方案可以选择:
    1. 添加名为access_token的查询字符串
    2. 添加Authorization请求头

小孩子才做选择,我全都要。

csharp 复制代码
private HubConnection SetupHubConnection(string path, string? token = null)
{
    var server = _factory.Server;

    var uri = new Uri(server.BaseAddress, path);

    return new HubConnectionBuilder()
        .WithUrl(uri, o =>
        {
            o.Transports = HttpTransportType.WebSockets;
            o.HttpMessageHandlerFactory = _ => server.CreateHandler();
            o.WebSocketFactory = async (context, cancellationToken) =>
            {
                var wsClient = server.CreateWebSocketClient();

                if (token != null)
                {
                    // Authentication for socket transports. (Chooses one of these.)
                    // Option1: Use request headers.
                    wsClient.ConfigureRequest = request => request.Headers.Authorization = new($"Bearer {token}");
                    // Option2: Add access token to query string.
                    uri = new Uri(QueryHelpers.AddQueryString(context.Uri.ToString(), "access_token", token));
                    // I like both ;)
                }
                else
                {
                    uri = context.Uri;
                }

                return await wsClient.ConnectAsync(uri, cancellationToken);
            };
            o.SkipNegotiation = true;
            // Authentication for non-socket transports. (Can be omitted here.)
            o.AccessTokenProvider = () => Task.FromResult(token);
        })
        .Build();
}

最后在用例中指定token参数,即可成功通过测试。

Q: 我应该如何生成token令牌?

如何生成令牌取决于你身份验证的实现方式。

在使用WebApplicationFactory的集成测试中,你可以比较容易地使用真实的用户与正常的登录方式获取令牌;如果身份认证本身并不是集成测试的关键,你可以设法使用测试替身替换掉原有的身份验证程序。(但一般情况下这只会更麻烦)

如果你想了解如何以正常登录方式获取令牌,可以查看我的源码👇


至此,所有问题解决!现在我们可以用SignalR WebSocket模式对带身份认证的Hub进行集成测试了!

项目源码

参考资料

  1. Authentication and authorization in ASP.NET Core SignalR
  2. [SignalR] Better integration with TestServer
  3. SignalR Hub auth?
相关推荐
向宇it6 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎
向宇it7 小时前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
坐井观老天12 小时前
在C#中使用资源保存图像和文本和其他数据并在运行时加载
开发语言·c#
pchmi14 小时前
C# OpenCV机器视觉:模板匹配
opencv·c#·机器视觉
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭16 小时前
C#都可以找哪些工作?
开发语言·c#
boligongzhu18 小时前
Dalsa线阵CCD相机使用开发手册
c#
向宇it1 天前
【从零开始入门unity游戏开发之——C#篇23】C#面向对象继承——`as`类型转化和`is`类型检查、向上转型和向下转型、里氏替换原则(LSP)
java·开发语言·unity·c#·游戏引擎·里氏替换原则
sukalot1 天前
windows C#-命名实参和可选实参(下)
windows·c#
小码编匠1 天前
.NET 下 RabbitMQ 队列、死信队列、延时队列及小应用
后端·c#·.net