[SignalR] 03 - Single-User-Group

接續先前的練習,持續調整為 single-user-group 及多個 Hub

在 website 的環境下,同一個使用者可以開啟多個網頁,那如何針對同一個使用者的瀏覽器發送訊息呢?官方有給出幾種方法及範例,優缺點也有列出,這邊採用的是單一用戶組的方式

Single-User Groups

在官方的範例是透過群組做到這一點,但是如果網站並沒有實作 Identity,透過Context.User.Identity.Name抓到的應該會是空字串,所以問題又變成了如何辨識使用者連線,既然是網頁,那只要從前端傳遞該使用者的 PKey 就可以了

如何辨識使用者連線?


如此一來就可以透過 MVC 後端讀取使用者資訊,並在 Web 頁面與 SignalR 連線之前,將資料透過下列的方式塞入 QueryString,並可由後端取得資訊,應可利用 QueryString 來區分使用者的組別,然後在送出訊息的時候判斷組別,並在該組別發言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[HubName("oneuser")]
public class OneUserHub : Microsoft.AspNet.SignalR.Hub
{
public override Task OnConnected()
{
var id = Context.QueryString["id"];
Groups.Add(Context.ConnectionId, id);

return base.OnConnected();
}

public void SendPrivateMsg(string userId, string msg)
{
Clients.Group(userId).Received(msg);
}

public void Send(string msg)
{
Clients.All.Received($"{msg} at {DateTime.Now:f}");
}
}

將 SignalR 服務從網站拆出來

之前練習的時候是透過網站直接安裝套件並建立服務,為了更好的模擬實務情境,網站應該是與 SignalR 切開來的會比較洽當,參考這篇實作,以及這篇解決 CORS 問題

備註:之後實際上線發現,實務上還是直接掛在網站上使用,並沒有另外拆出來,這邊就存查看看就好了

新增一個 WinForm 專案,並安裝 nuget 套件Microsoft.Owin.SelfHostMicrosoft.AspNet.SignalR.SelfHost,並加入 OWin Startup 類別,服務建立在另外一個 port,所以也要針對 Owin Startup 來做一些修改,避過前端 CORS 的問題,所以也要安裝Microsoft.Owin.Cors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNet.SignalR;
using Microsoft.Owin;
using Microsoft.Owin.Cors;
using Owin;

[assembly: OwinStartup(typeof(SignalRService.Startup))]

namespace SignalRService
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.Map("/signalr", map =>
{
map.UseCors(CorsOptions.AllowAll);
var hubConfiguration = new HubConfiguration { };
map.RunSignalR(hubConfiguration);
});
}
}
}

另外在 Client 端透過 javascript 連線的時候,必須要先指定連線路徑

1
$.connection.hub.url = "http://localhost:22641/signalr";

同時在 HTML 內原先載入的 signalr/hub 也要改成服務的路徑

1
<script src="http://localhost:22641/signalr/hubs"></script>

重新改寫 winform

先前 winform 的實作方式並不是像 javascript client 那樣建立事件給 server 端的 hub 呼叫,而是在 winform client 端有接收到資料,就笨笨的去判斷資料是甚麼,然後再去處理,這個方式對於維護是很不便的,所以我們現在要重新改寫一下,將 winform 的部分也像 js client 一樣,寫好事件等 server 呼叫

原先的_conn.Received還有_conn.Closed事件的委派就通通刪掉,取而代之的是一開始就把事件註冊下去給 proxyHub,因為要模擬上次所實作的概念,我也假設 winform 端是有登入的,然後取得使用者資料,再依據使用者的頻道去動態的 create,在一開始就先處理這件事情

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
private CurrectUser GetUser()
{
//TODO:這邊應該要從資料庫中取得使用者的資料
return new CurrectUser
{
Name = "張三",
Channel = new List<ChannelInfo>
{
new ChannelInfo {Name = "team1", Id = 0},
new ChannelInfo {Name = "leader", Id = 2},
new ChannelInfo {Name = "notice", Id = 3}
}
};
}

private Dictionary<string, IHubProxy> GetUserHubs(IEnumerable<ChannelInfo> channels)
{
var result = new Dictionary<string, IHubProxy>();
foreach (var info in channels)
{
result.Add(info.Name,_conn.CreateHubProxy(info.Name));
}

// 這邊是為了 V1 的測試
result.Add("chathub", _conn.CreateHubProxy("chathub"));

return result;
}

internal class ChannelInfo
{
public string Name { get; set; }
public int Id { get; set; }
}

當我們取得了使用者的頻道之後,接著要為這些 hub 註冊事件給 server 呼叫

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
_hubs = GetUserHubs(_currectUser.Channel);
foreach (var currectHub in _hubs)
{
currectHub.Value.On<string>("received", (msg) =>
{
DoUiCallBack(() =>
{
GenerateNewLabel(msg);
});
});
}

private void GenerateNewLabel(string msg)
{
var lb = new Label
{
Location = new Point(15, y += 25),
Text = msg,
ForeColor = Color.Blue,
Width = 200
};
this.Controls.Add(lb);
}

private void DoUiCallBack(UiCallBack cb)
{
if (this.InvokeRequired)
{
this.Invoke(new UiCallBack(
cb.Invoke
));
}
}

private delegate void UiCallBack();

同時,畫面也做一些調整,不過這個練習主要只是做一個簡單的 POC,所以很多地方就沒有再去精細處理了,例如訊息會一直往下長….

WinForm 單一用戶組 & Multi Hubs

連線時取得使用者的唯一識別碼,再將該連線加入至 Group (由登入機制提供給 client,再由 Client 透過 QueryString 傳遞給 SignalR)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Hub
public override Task OnConnected()
{
var id = Context.QueryString["id"];
Groups.Add(Context.ConnectionId, id);

return base.OnConnected();
}

/// <summary>
/// 傳遞訊息給單一用戶組
/// </summary>
/// <param name="userId">要傳遞的對象</param>
/// <param name="msg">訊息內容</param>
public void SendPrivateMsg(string userId, string msg)
{
Clients.Group(userId).Received(msg);
}
1
2
3
4
5
6
7
8
9
10
// js client
// PM msg btn event
$sendPrivateBtn.on("click", function () {
// userId is a pk for user
$.connection.notice.server.sendPrivateMsg(
userId,
`[PM]${data.name}${$msgDom.val()}`
);
$msgDom.val("");
});
1
2
3
4
5
6
7
8
9
10
11
12
// Winform Client
// .net client 傳遞querystring的方式,模擬從DB抓到的使用者PKey
var querystringData = new Dictionary<string, string> {{"id", "10001"}};
_conn = new HubConnection(SignalRurl, querystringData);


private void btnPM_Click(object sender, EventArgs e)
{
// 使用notice是因為每一個人都會登入,所以單一用戶組相互溝通,在這邊才找的到人
_hubs["notice"].Invoke("sendPrivateMsg", "10002", $"[PM-winform]{_currectUser.Name}{textBox1.Text} at {DateTime.Now:f}");
textBox1.Text = string.Empty;
}

比較重要的事情是,因為單一用戶群組是在某個 Hub 底下所建立的,如果在 Invoke 的時候密語的對象不在同一個 Hub,訊息是沒辦法傳給對方的,解決辦法其實就是讓所有人都會加入同一個 Hub,也就是 Notice,當使用密語功能的時候,就一律透過 NoticeHub 來傳遞即可。

Sample Code:Github

參考資料

  1. https://docs.microsoft.com/zh-tw/aspnet/signalr/overview/guide-to-the-api/hubs-api-guide-net-client
  2. https://docs.microsoft.com/zh-tw/aspnet/signalr/overview/guide-to-the-api/hubs-api-guide-server