如何使用 SignalR 向 Blazor 服务器中的特定用户发送消息?

How To Send Message To Specific User In Blazor Server Using SignalR?

我已经实现了一个简单的 public 聊天室系统,使用 blazor 服务器和带有数据库的 signalR 来存储用户名和消息。 IN系统用户输入名字加入聊天,出现UI聊天

现在,我想要的是添加另一个功能,可以将消息发送到 public 聊天室中的特定用户。
任何帮助都会很棒,谢谢。

下面是我的 public 聊天室代码。
下面是我的中心

    public class Chat:Hub
    {
        public async Task SendMessage(Message message)
        {
            await Clients.All.SendAsync("Receiver", message);
        }
    }

下面是在数据库中保存用户名和消息的代码

    public string MsgBody { get; set; }
    public string UserName { get; set; }
    public Message chatmsg { get; set; } = new Message();
    public bool isChatting = false;
    public string errorMsg;
    public List<Message> messages = new List<Message>();
    public List<Message> messagesList = new List<Message>();
    public HubConnection hubConnection;
    [Inject]
    public NavigationManager NavigationManager { get; set; }
    [Inject]
    public MainService mainService { get; set; }
    public async Task SendAsync()
    {
        chatmsg.UsrName = UserName;
        chatmsg.MessageBody = MsgBody;
        mainService.SaveMessage(chatmsg);
        await hubConnection.SendAsync("SendMessage", chatmsg);
        MsgBody = string.Empty;
        chatmsg = new Message();
    }

下面是加载数据的代码
    public async Task Chat()
    {
        if (string.IsNullOrWhiteSpace(UserName))
        {
            errorMsg = "Enter your name";
        }
        try
        {
            isChatting = true;
            messagesList.Clear();
            hubConnection = new HubConnectionBuilder().WithUrl(NavigationManager.ToAbsoluteUri("/chat")).Build();
            hubConnection.ServerTimeout = TimeSpan.FromMinutes(60);
            hubConnection.On<Message>("Receiver", BroadcastMessage);
            await hubConnection.StartAsync();
            LoadMessage();
            await ChatJoinLeftMessage($"[Notice] {UserName} joined chat room.");                
        }
        catch (Exception e)
        {

            errorMsg = $"ERROR: Failed to start chat client: {e.Message}";
            isChatting = false;
        }
    }
    private void BroadcastMessage(Message message)
    {
        bool isMine = message.UsrName.Equals(UserName, StringComparison.OrdinalIgnoreCase);
        messagesList.Add(new Message(message.UsrName, message.MessageBody, isMine));    
        StateHasChanged();
    }

    private void LoadMessage()
    {
        messages = mainService.GetAllMessages();
        foreach (var item in messages)
        {
            bool isMine = item.UsrName.Equals(UserName, StringComparison.OrdinalIgnoreCase);
            messagesList.Add(new Message(item.UsrName, item.MessageBody, isMine));
        }
    }

下面是我的 UI
@if (!isChatting)
{
<div class="col-lg-5">
    <p>Enter your name to start chatting:</p>

    <div class="input-group  my-3">
        <input @bind="UserName" type="text" class="form-control my-input">
        <div class="input-group-append">
            <button class="btn btn-outline-secondary" type="button" @onclick="@Chat"><span class="oi oi-chat" aria-hidden="true"></span> Chat!</button>
        </div>
    </div>
</div>
   if (errorMsg != null)
   {
    <div class="col-lg-5">
        <small id="emailHelp" class="form-text text-danger">@errorMsg</small>
    </div>
   }
}
else
{
<div class="alert alert-secondary mt-4" role="alert">
    <span class="oi oi-person mr-2" aria-hidden="true"></span>
    <span>you are connected as <b>@UserName</b></span>
    <button class="btn btn-sm btn-warning ml-md-auto" @onclick="@DisconnectAsync">disconnect</button>
</div>
<div id="scrollbox">
    @foreach (var item in messagesList)
    {
        @if (item.IsNotice)
        {
            <div class="alert alert-info">@item.MessageBody</div>
        }
        else
        {
            <div class="@item.CSS">
                <div class="user">@item.UsrName</div>
                <div class="msg">@item.MessageBody</div>
            </div>
        }
    }
    <hr />
    <textarea class="input-lg" placeholder="enter your comment" @bind="MsgBody"></textarea>
    <button class="btn btn-default" @onclick="()=>SendAsync()">Send</button>
</div>
}

我知道有两种方法。

  1. 您需要记录用户的 ConnectionId 并将其映射到服务器上的用户。
Clients.Client(ConnectionId).SendAsync(...);
  1. 当用户连接到集线器时,将他们放入仅供他们使用的组中。例如,使用他们的 userId 作为组名。然后你可以广播到那个组,只有那个用户会收到消息。
 Clients.Group($"userId").SendAsync(...);

覆盖集线器上的 OnConnectedAsync() 以设置任一方法。不要忘记覆盖 OnDisconnectedAsync() 以在断开连接时整理任何状态变量或存储。如果操作正确,这还有利于跟踪谁已连接。

在集线器上使用 [Authorize] 属性。请参阅 了解如何使用 WASM。

以下代码也可能有所帮助。 (中心)

var connectionId = Context.ConnectionId;

var userId = Context.UserIdentifier;
var applicationUser = await userManager.FindByIdAsync(userId);

恕我直言:出于安全原因,除了他们的显示名称之外,我不会将其他用户的详细信息发送给客户端。这就是我使用 [Authorize] 属性的原因。这两种技术都会混淆其他用户的详细信息和与集线器的连接。

您只需将另一个 属性 添加到您的 Message class 即可确定邮件的预定收件人,如果它为空则发送给所有人。然后在您的集线器中使用此 属性 在广播方法之间进行选择。

这是一个完整的解决方案。您可以从 Github:

下载有效和更新的代码示例

注意:此答案的目的不是如何创建 SignlR 应用程序,以及如何管理用户。这显示在文档和许多其他教程中。但缺少一件事,那就是如何保护 SignalR 中心的端点,以及如何使用户的声明在 Blazor Server App 的中心可用。我没有找到一个使用 Blazor Server App 进行操作的示例。我向 Blazor 团队寻求了一些提示,但无济于事...

一些说明

注意:在 Blazor Server 应用程序的前端对用户进行身份验证不会使您获得访问集线器上受保护端点的授权。您应该像对待 Web Api 端点一样对待 Hub,这要求您在对它执行 HTTP 调用时传递访问令牌。例如,如果您想从 Web Api 中的 WeatherForecastController 检索数据到使用 HttpClient 服务的 FetchData 页面,则需要在 Authorization header

中传递访问令牌

当您使用具有 API 身份验证的 WebAssembly 应用程序时,您可以在创建集线器连接时将访问令牌传递给集线器。这很简单,文档中有一个示例代码对此进行了演示,实际上您无需做太多事情就可以保护 Hub 和访问...................... ...,即使这样也有一些问题需要处理,因为在 Hub 中只能访问 UserIdentifier,而不是所有用户的声明。

然而,这里的答案是关于 Blazor Server App,解决方案是将安全 cookie (".AspNetCore.Identity.Application") 传递给集线器。因此,解决该问题的第一步是在呈现 Blazor SPA 之前从 HttpContext 中捕获 cookie,并将该 cookie 作为参数传递给 Blazor 应用程序发送到 App 组件。现在 Cookie 在 Blazor 应用程序中可用,您可以从聊天页面访问它并将其传递到中心。请注意,与带有 SignalR 的 WebAssembly 应用示例不同,所有 ClaimPrincipal 对象都在 Hub 中可用,您可以访问其所有声明,例如:

var user = Context.User.Identity.Name
var userid = Context.UserIdentifier

_Host.cshtml

    @{
        // Capture the security cookie when the the initial call
        // to the Blazor is being executed. Initial call is when 
        // the user type the url of your Blazor App in the address
        // bar and press enter, or when the user is being redirected 
        // from the Login form to your Blazor SPA
        // See more here:  
         var cookie = 
     HttpContext.Request.Cookies[".AspNetCore.Identity.Application"];
    
    }


  <body>
     @* Pass the captured Cookie to the App component as a paramter*@
    <component type="typeof(App)" render-mode="Server" param- 
       Cookie="cookie" />
  </body>

App.razor

@inject CookiesProvider CookiesProvider

@* code omitted here... *@

@code{

    [Parameter]
    public string Cookie { get; set; }

    protected override Task OnInitializedAsync()
    {
        // Pass the Cookie parameter to the CookiesProvider service
        // which is to be injected into the Chat component, and then 
        // passed to the Hub via the hub connection builder
        CookiesProvider.Cookie = Cookie;

        return base.OnInitializedAsync();
    }
}

CookiesProvider.cs(完整代码)

using Microsoft.JSInterop;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SignalRServerIdentityAuthentication
{
    public class CookiesProvider
    {
        public string Cookie { get; set; }
    }
}

Startup.ConfigureService

 services.AddScoped<CookiesProvider>();
 services.AddSignalR();

Startup.Configure

 app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapBlazorHub();
                endpoints.MapHub<ChatHub>("/chatHub");
                endpoints.MapFallbackToPage("/_Host");
            });

请注意,NavMenu 包含一个 AuthorizeView 组件,其目的是防止用户访问聊天组件,除非她已经过身份验证。另请注意,聊天页面受授权属性保护。

NavMenu.razor

<li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
        <AuthorizeView>
            <li class="nav-item px-3">
                <NavLink class="nav-link" href="chat">
                    <span class="oi oi-chat" aria-hidden="true"></span> Chat
                </NavLink>
            </li>
        </AuthorizeView>

Chat.razor(完整代码)

    @page "/chat"

    @attribute [Authorize]

@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.AspNetCore.SignalR

@using SignalRServerIdentityAuthentication.Hubs

@inject NavigationManager NavigationManager


@using System.Net.Http
@using System.Net.Http.Json

@using System;

@using System.Net.Http.Headers;
@using System.Threading.Tasks;
@using Microsoft.AspNetCore.Http.Connections;
@using System.Net


@implements IAsyncDisposable
<p>@messageForBoard</p>
<hr />

<div>
    <label for="user">User:</label>
    <span id="user">@userName</span>
</div>
<div class="form-group">
    <label for="messageInput">Message:</label>
    <input onfocus="this.select();" @ref="elementRef" id="messageInput" @bind="messageInput" class="form-control my-input"/>
</div>

<div>
<button @onclick="Send" disabled="@(!IsConnected)" class="btn btn-outline- 
     secondary">Send Message</button> 

@if (UserList != null)
    {
        <select id="user-list" @bind="selectedUser">
            <option value="">All.....</option>
            @foreach (var user in UserList)
            {
                <option value="@user">@user</option>
            }
        </select>
    }
  
 </div>

 <div>
    <label for="messagesList">Public Message Board:</label>
    <ul id="messagesList">
       @foreach (var message in messages)
       {
          <li>@message</li>
       }
    </ul>
</div>

<div>
    <label for="private-messages-list">Private Message Board:</label>
    <ul id="private-messages-list">
       @foreach (var message in privateMessages)
       {
          <li>@message</li>
       }
    </ul>
</div>

@code {
    HubConnection hubConnection;
    private List<string> messages = new List<string>();
    private List<string> privateMessages = new List<string>();
    private string messageForBoard;
    private string userName;
    private string messageInput;
    private string selectedUser;
    private List<string> UserList;

    private ElementReference elementRef;

    [Inject]
    public CookiesProvider CookiesProvider { get; set; }
  

    protected override async Task OnInitializedAsync()
    {
        var container = new CookieContainer();
        var cookie = new Cookie() 
         {
             Name = ".AspNetCore.Identity.Application", 
             Domain = "localhost",
             Value = CookiesProvider.Cookie
         };

         container.Add(cookie);

      hubConnection = new HubConnectionBuilder()
    .WithUrl(NavigationManager.ToAbsoluteUri("/chathub"), options => 
    {
        // Pass the security cookie to the Hub. This is the way to do 
        // that in your case. In other cases, you may need to pass
        // an access token, but not here......
        options.Cookies = container; 
    }).Build();

        hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            messages.Add(encodedMsg);
           InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<string>("ReceiveUserName", (name) =>
        {
            userName = name;

            InvokeAsync(() => StateHasChanged());
        });

         hubConnection.On<string>("MessageBoard", (message) =>
        {
            messageForBoard = message;

           InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<string, string>("ReceivePrivateMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            privateMessages.Add(encodedMsg);

            InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<List<string>>("ReceiveInitializeUserList", ( list) =>
        {
            UserList = list ;

            InvokeAsync(() => StateHasChanged());
        });


        await hubConnection.StartAsync();
        await hubConnection.InvokeAsync("InitializeUserList");
      
    }
    protected override void OnAfterRender(bool firstRender)
    {
         elementRef.FocusAsync();
    }
       
   async Task Send() => await hubConnection.SendAsync("SendMessage", 
                                       selectedUser, messageInput);
    public bool IsConnected => hubConnection.State == 
                                      HubConnectionState.Connected;

    public void Dispose()
    {
       hubConnection.DisposeAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await hubConnection.DisposeAsync();
    }
}

请注意,为了传递私人消息,您需要有 UserIdentifier,但您还需要将您想要 post 私人消息的用户与 UserIdentifier 相关联。您可以简单地在聊天中存储 UserIdentifiers 列表,然后传递 reiured 的。这当然会带来一些安全风险,应该避免。请参阅我的代码,我是如何处理这个问题的。用户只能查看用户名列表(是的,这些是已连接用户的电子邮件。回想一下,在数据库中,UserName 列包含用户的电子邮件)。您当然可以将其更改为更易于显示的值;您的显示名称可以是名字+姓氏等。这取决于您。请记住,您需要为此添加一个新声明。如何做到这一点值得提出一个新问题...

Hubs/ChatHub.cs(完整代码需要清理一些不必要的 using 语句)

    using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.IdentityModel.Tokens;

using Microsoft.IdentityModel;

using System.Net.Http;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authentication;
using System.Net.Http.Json;

namespace SignalRServerIdentityAuthentication.Hubs
{
    [Authorize()]
    public class ChatHub : Hub
    {
        private static List<ConnectedUser> connectedUsers = new List<ConnectedUser>();
        public async Task InitializeUserList() 
        {
            var list = (from user in connectedUsers
                       select user.Name ).ToList();

            await Clients.All.SendAsync("ReceiveInitializeUserList", list);
        }
        public async Task SendMessage(string userID, string message)
        {
            if (string.IsNullOrEmpty(userID)) // If All selected
            {
                await Clients.All.SendAsync("ReceiveMessage", Context.User.Identity.Name ?? "anonymous", message);
            }
            else
            {
                var userIdentifier = (from _connectedUser in connectedUsers
                                      where _connectedUser.Name == userID
                                      select _connectedUser.UserIdentifier).FirstOrDefault();

                await Clients.User(userIdentifier).SendAsync("ReceivePrivateMessage",
                                       Context.User.Identity.Name ?? "anonymous", message);
            }

        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
           
            var user = connectedUsers.Where(cu => cu.UserIdentifier == Context.UserIdentifier).FirstOrDefault();

            var connection = user.Connections.Where(c => c.ConnectionID == Context.ConnectionId).FirstOrDefault();
            var count = user.Connections.Count;

            if(count == 1) // A single connection: remove user
            {
                connectedUsers.Remove(user);

            }
            if (count > 1) // Multiple connection: Remove current connection
            {
                user.Connections.Remove(connection);
            }

            var list = (from _user in connectedUsers
                        select new { _user.Name }).ToList();

           await Clients.All.SendAsync("ReceiveInitializeUserList", list);

           await   Clients.All.SendAsync("MessageBoard", 
                      $"{Context.User.Identity.Name}  has left");

            // await Task.CompletedTask;
         
        }

       
        public override async Task OnConnectedAsync()
        {
            var user = connectedUsers.Where(cu => cu.UserIdentifier == Context.UserIdentifier).FirstOrDefault();

            if (user == null) // User does not exist
            {
                ConnectedUser connectedUser = new ConnectedUser
                {
                    UserIdentifier = Context.UserIdentifier,
                    Name = Context.User.Identity.Name,
                    Connections = new List<Connection> { new Connection { ConnectionID = Context.ConnectionId } }
                };

                connectedUsers.Add(connectedUser);
            }
            else
            {
                user.Connections.Add(new Connection { ConnectionID = Context.ConnectionId });
            }

            // connectedUsers.Add(new )

            await Clients.All.SendAsync("MessageBoard", $"{Context.User.Identity.Name}  has joined");

            await  Clients.Client(Context.ConnectionId).SendAsync("ReceiveUserName", Context.User.Identity.Name);
       
        
        }
  }
}

Hubs/ConnectedUser.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SignalRServerIdentityAuthentication.Hubs
{
    public class ConnectedUser
    {
        public string Name { get; set; }
        public string UserIdentifier { get; set; }

        public List<Connection> Connections { get; set; } 
    }
    public class Connection
    {
         public string ConnectionID { get; set; }
              
    }
}