SignalR出現ObjectDisposedException的解決
- 問題描述
-
- 功能要求
- 異常位置
- 解決辦法
- 問題原因
問題描述
功能要求
業務上要實作伺服器端定時向用戶端推送某些最新消息,方案的思路是:當用戶端連接配接上來時記錄下它的ConnectionId,使用Timer建立定時任務,定時向該ConnectionId的用戶端發送消息。
異常位置
在定時任務中使用Clients.Client(connId) 或其他方法擷取用戶端連接配接時冒出ObjectDisposedException。
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIwczX0xiRGZkRGZ0Xy9GbvNGL2EzXlpXazxSP9cXTxUERPpXRE9ke4wmYwhGWhxGZzwEMW1mY1RzRapnTtxkb5ckYplTeMZTTINGMShUYfRHelRHLwEzX39GZhh2css2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3Pn5GcuYzN4EjNyEjM0ITMxkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
異常位置代碼:
public async void LoopTask(object state)
{
// state是ConnectionId
string connId = state.ToString();
// 任何方式擷取用戶端連接配接都會冒ObjectDisposedException
var client = Clients.Client(connId);
string arg = "this is an argument";
await client.SendAsync("Test", arg);
}
有問題的代碼總覽:
public class TestHub : Hub
{
// 定時任務字典。Dictionary不是線程安全的,實際應用中應該加鎖或者使用ConcurrentDictionary
private static Dictionary<string, Timer> Tasks { get; set; }
static TestHub()
{
Tasks = new Dictionary<string, Timer>();
}
public override Task OnDisconnectedAsync(Exception exception)
{
string connId = Context.ConnectionId;
if(Tasks.TryGetValue(connId,out Timer timer))
{
timer.Dispose();
}
return base.OnDisconnectedAsync(exception);
}
public override Task OnConnectedAsync()
{
string connId = Context.ConnectionId;
// 0毫秒後開始每5秒執行一次
Timer timer = new Timer(LoopTask, connId, 0, 5000);
Tasks.Add(connId, timer);
return base.OnConnectedAsync();
}
public async void LoopTask(object state)
{
string connId = state.ToString();
// 任何方式擷取用戶端連接配接都會冒ObjectDisposedException
var client = Clients.Client(connId);
string arg = "this is an argument";
await client.SendAsync("Test", arg);
}
}
解決辦法
因為Hub是臨時性的,請求完成後會dispose掉,是以所有不在請求内的操作都不能直接使用Hub裡面的對象(Clients, Groups等)擷取用戶端連接配接。
這裡要單獨開一個用戶端連接配接的類,利用Asp.net core自帶依賴注入将IHubContext注入到類中,在類中用IHubContext進行操作。
這裡要注意的是,Asp.net 和Asp.net core是不一樣的,Asp.net中可以用GlobalHost中擷取到IHubContext,而Asp.net core中就不能這樣擷取。
修改後代碼總覽:
public class ClientConnection : IDisposable
{
// 連接配接辨別,也可以是userId等,反正能定位到用戶端就行
public string ConnectionId { get; set; }
// HubContext,用于擷取用戶端連接配接
private IHubContext<TestHub> Context { get; set; }
// 定時任務
private Timer Timer { get; set; }
public ClientConnection(IHubContext<TestHub> hubContext, string connId)
{
this.ConnectionId = connId;
this.Context = hubContext;
// 0毫秒後開始每5秒執行一次
Timer = new Timer(LoopTask, null, 0, 5000);
}
public async void LoopTask(object state)
{
var client = Context.Clients.Client(this.ConnectionId);
string arg = "this is an argument";
await client.SendAsync("Test", arg);
}
public void Dispose()
{
this.Timer.Dispose();
}
}
public class TestHub : Hub
{
// 用于擷取依賴注入對象
private IServiceProvider Service { get; set; }
// 這裡其實不應該把連接配接儲存在Hub中,demo的話随便了(記得加鎖)
private static List<ClientConnection> Connections;
static TestHub()
{
Connections = new List<ClientConnection>();
}
public TestHub(IServiceProvider service)
{
Service = service;
}
public override Task OnDisconnectedAsync(Exception exception)
{
string connId = Context.ConnectionId;
var conn = Connections.Find(s => s.ConnectionId == connId);
if(conn != null)
{
Connections.Remove(conn);
conn.Dispose();
}
return base.OnDisconnectedAsync(exception);
}
public override Task OnConnectedAsync()
{
string connId = Context.ConnectionId;
// 這裡不用在StartUp裡面AddScoped也可以擷取到
IHubContext<TestHub> context = Service.GetService(typeof(IHubContext<TestHub>)) as IHubContext<TestHub>;
ClientConnection conn = new ClientConnection(context, connId);
Connections.Add(conn);
return base.OnConnectedAsync();
}
}
問題原因
問題的原因就在于Hub執行個體的暫時性,跟Controller一樣,一個請求生成一個執行個體,似乎也合情合理。但是,雖然Hub是臨時性的但是它管理的連接配接是持久的(WebSocket長連接配接),這就很有迷惑性,容易讓人以為Hub也是持久存在的。
最後,建議入坑的過程中還是要多看文檔,其實微軟的文檔确實做得不錯,雖然很多都是機器翻譯,而且詞彙都比較晦澀,但重點都會有特别說明,這點可以給個贊。
參考
Asp.net Core中SignalR擷取IHubContext的文檔