游戏开发中,服务端主动通知客户端是必不可少的需求场景,比如:
- 玩家A发送一条聊天消息,要推送给所有在线玩家
- MMO中玩家B移动,要推送给AOI范围内所有玩家
在端游或Native手游中,常用的是TCP或基于框架的可靠UDP(比如enet、kcp);
在H5及小游戏平台,由于平台限制,通常使用WebSocket。
小游戏近些年势头很猛,大部分游戏通常都会发布到小游戏平台,同时Native也支持WebSocket,因此这类游戏可以考虑直接采用WebSocket来做。
那如何在Orleans中集成WebSocket功能呢?
- 本文不具体介绍如何实现WebSocket服务器,而是讲如何与Orleans配合使用。
- 本文仅讨论通知客户端的流程。客户端上行流程较为简单,可直接使用WebSocket,也可使用HTTP
单进程
适合小型游戏,单个服务端进程即可承载整个游戏或单服的服务端。
在这种情况下集成较为简单,无需考虑跨进程问题。
主要逻辑在WSConnectionMgrGrain中,负责管理PlayerId↔ConnectionId,主要接口如下:
public interface IWSConnectionMgrGrain : IGrainWithIntegerKey
{
/// 添加连接,有连接上来就添加,同时内部可以有定时器清理过期没有验证的连接
Task AddConnection(string connectionId);
/// 添加有效连接,验证通过后调用
Task AddValidConnection(string playerId, int serverId, string connectionId);
/// 连接断开,移除连接
Task RemoveConnection(string connectionId);
/// 下发通知消息
void Notify(string playerId, byte[] notify);
// 其他Notify
}
- 客户端连接上来后,WebSocket框架会调用AddConnection接口添加连接
await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).AddConnection(connectionId);
2. 客户端验证通过后,调用AddValidConnection接口添加有效连接
await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).AddValidConnection(playerId, serverId, connectionId);
3. 客户端断开连接时,调用RemoveConnection接口移除连接
await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).RemoveConnection(connectionId);
4. 其他业务Grain需要下发通知时,调用Notify接口
_grainFactory.GetGrain<IWSConnectionMgrGrain>(0).Notify(playerId, notify);
具体代码可以参考我写的Sample
单WebSocket节点多Soil
适合中等规模的游戏:整个服务由多个进程组成,但单个WebSocket节点可以承载所有连接。
在此场景下集成较为简单,无需处理跨节点的问题。
主要类和接口与单进程方案相同,数据流向与单进程方案类似。但部署方式不同:
WebSocket框架仅在该节点启动一次,WSConnectionMgrGrain需要在WebSocket框架节点上- 其他业务 Grain 可部署在任意节点。
为了实现这种部署方案,可以使用Orleans的Placement策略,指定这个Grain只部署在提供WebSocket的Soil节点上:
- 启动时通过配置指定是否启动
WebSocket服务,区分提供WebSocket的Soil节点与其他节点。 - 在提供
WebSocket的节点上,启动时创建WSConnectionMgrGrain实例,并将其Placement指定为LocalPlacement,同时设置 KeepAlive,以保证该Grain仅部署在该节点上。
当然还可以自定义Placement策略来实现只在指定节点上部署Grain实例。具体可以参考Orleans官方文档。
具体代码可以参考我写的Sample
多WebSocket节点
这种扩展性最好,适合大型游戏。但是实现起来也比较复杂,需要考虑跨节点问题。
核心改动:
- 将之前的
WSConnectionMgrGrain改为WSConnectionService,负责管理当前 Soil 节点上的 WebSocket 连接 WSConnectionService订阅Orleans的Stream以接收其他节点发送的通知。WSConnectionService主要接口去掉Notify(由Stream传递),仅保留连接相关操作WSConnectionService线程不安全的,需要自行处理并发导致的线程安全问题
public interface IWSConnectionService
{
/// 添加连接,有连接上来就添加,同时内部可以有定时器清理过期没有验证的连接
void AddConnection(string connectionId);
/// 添加有效连接,验证通过后调用
void AddValidConnection(string playerId, int serverId, string connectionId);
/// 连接断开,移除连接
void RemoveConnection(string connectionId);
}
订阅Stream的示例代码如下(示例中使用JSON序列化):
_streamProvider = _clusterClient.GetStreamProvider("MemoryStreamProvider");
var streamId = StreamId.Create("", "WSConnection");
_stream = _streamProvider.GetStream<NotifyStreamMessage>(streamId);
_subscriptionHandle = await _stream.SubscribeAsync(async (msg, token) =>
{
if (msg.Id == 1)
{
// 有其他服务器的玩家连接进来,断开本服务器的该玩家连接
// 解析消息(使用JSON序列化)
var json = System.Text.Encoding.UTF8.GetString(msg.Data);
var obj = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(json);
// 获取玩家ID和连接ID
var playerId = obj["PlayerId"];
var connectionId = obj["ConnectionId"];
lock (_lock)
{
// 检查是否有该玩家的旧连接
if (_playerId2ConnectionIdDict.TryGetValue(playerId, out var existingConnectionId))
{
if (existingConnectionId != connectionId)
{
// 断开旧连接,断开之前可以发个顶号消息
var oldConnection = _hubContext.Clients.Client(existingConnectionId) as HubCallerContext;
oldConnection?.Abort();
_playerId2ConnectionIdDict.Remove(playerId);
// ... 其他清理工作 ...
}
}
}
}
else if (msg.Id == 2)
{
// 向指定玩家发送通知
// 解析消息(使用JSON序列化)
var json = System.Text.Encoding.UTF8.GetString(msg.Data);
var obj = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(json);
// 获取玩家ID和消息内容
var playerId = obj["PlayerId"];
var message = obj["Message"];
lock (_lock)
{
// 查找玩家对应的连接ID
if (_playerId2ConnectionIdDict.TryGetValue(playerId, out var connectionId))
{
// 发送通知
_ = _hubContext.Clients.Client(connectionId).SendAsync("Notify", System.Text.Encoding.UTF8.GetBytes(message));
}
}
}
});
其他业务Grain需要下发通知时,调用Stream的Push方法(本文示例在Controller中调用):
// 发送通知消息给指定玩家
await _clusterClient.GetStreamProvider("MemoryStreamProvider")
.GetStream<NotifyStreamMessage>(StreamId.Create("", "WSConnection"))
.OnNextAsync(new NotifyStreamMessage
{
Id = 2,
Data = Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string>
{
{ "PlayerId", playerId },
{ "Message", message }
}))
});
具体代码可以参考我写的Sample
总结
以上就是我对Orleans集成WebSocket的思路和方案介绍,不同规模的游戏可以选择不同的方案来集成WebSocket功能。希望对大家有所帮助!