游戏服务器之聊天频道设计
1.背景:
对于一款网络游戏来说,聊天是基础的交互功能。常见了聊天频道有私聊,世界,公会,队伍,甚至所有服都连接起来的全服聊天。
本文主要目的是,设计一套简单通用的聊天框架。
基本思想:
使用统一的API处理所有的聊天频道。
并非所有聊天记录都需要存放到数据库,例如世界聊天,由于交互时效问题,重启丢失消息也可以接受。
2.聊天基础
基本流程是:
- 当玩家发送聊天的请求消息,先判断指定的频道能否发送,例如私聊的时候我方是否是对方的黑名单;
- 验证通过后,将消息保存到内存或者数据库,直接返回发送成功的返回值给客户端;
- 服务器收集指定频道的消息接收者列表,异步推送给所有玩家。
- 当玩家登录的时候,需要统一收集所有频道的离线消息,一次性推给客户端。
对一些基础的操作作抽象,如下:
/*** 聊天频道处理器*/
public interface ChatChannelHandler {/*** 发送消息之前的检查,例如等级要求,禁言等* @return 错误码*/int checkCanSend(PlayerEnt player, String target, String content);/*** 保存到内存或者数据库* @param message*/void cacheOrSave(ChatMessage message);/*** 角色登录的消息收集* @param player* @return*/List<ChatMessage> collectMessageOnLogin(PlayerEnt player);/*** 收到消息后,进行广播** @param message*/default void broadcast(ChatMessage message) {// 过滤不在线的玩家Collection<String> receivers = receivers(message).stream().filter(p -> SpringContext.getSessionManager().getSessionBy(p) != null).collect(Collectors.toSet());if (CollectionUtils.isEmpty(receivers)) {return;}PushChatNewMessage pushChatNewMessage = new PushChatNewMessage();pushChatNewMessage.setMessages(Collections.singletonList(message));for (String receiver : receivers) {MessageUtil.pushMessage(receiver, pushChatNewMessage);}}/*** 消息接收者列表, 可以不考虑玩家是否在线,底层会自动过滤** @param message* @return*/Collection<String> receivers(ChatMessage message);/*** 频道类型* @return*/byte channelType();}
3.私聊频道
对于私聊,有一个比较有争议的问题是,聊天记录只存一份,还是发送方/接收方独立存一份拷贝这个问题的本质是聊天软件对消息内容的存储方式,一种是扩散读,也就是消息只存一份;另外一种是扩散写,每个接收者独立存储一份副本。
对于游戏的聊天系统,远没有聊天软件那般复杂。
如果聊天记录不允许手动删除,而是由系统过期自动清除。考虑只存一份即可。类似下面的表定义。
这种方式,数据不会冗余,但有一个问题,如果程序使用了缓存,就稍微有点麻烦。当发送方或者接收方独立上线的时候,需要先查询发送方id或者接收方id是当前玩家的所有聊天记录的id,再通过这个id去查询缓存。
当然,如果聊天记录支持手动删除,为了不影响对方的聊天记录,只能双方各存一份拷贝了。
比较直接的是,玩家身上存一个map,记录该玩家发送的,以及接收的所有消息记录。示例如下:
/*** 私人聊天记录* key: 对方id* value: 消息列表*/private Map<String, List<ChatMessage>> privateChat = new HashMap<>();public void addNewMessage(String targetId, ChatMessage message) {privateChat.putIfAbsent(targetId, new LinkedList<>());List<ChatMessage> messages = privateChat.get(targetId);if (messages.size() >= 50) {messages.remove(0);}messages.add(message);}
这种方式也有一个问题,就是如果发送消息的目标玩家是离线状态,为了把新消息发送给接收方,业务需要把离线用户从数据库捞取上来,有造成离线玩家缓存内存过大的风险。针对这种情况 ,可以把聊天记录作为一个单独的表设计,表结构只需要一个玩家id,以及一个保存聊天记录json数据的字段,以此减少程序内存。
本文为了方便,将聊天记录作为玩家表的一个字段,并非推荐方式。
代码如下:
/*** 私人聊天频道*/
public class PrivateChannelHandler implements ChatChannelHandler {@Overridepublic int checkCanSend(PlayerEnt player, String target, String content) {// 对方是否我的好友return 0;}@Overridepublic void cacheOrSave(ChatMessage message) {// 发送方,接收方,各存储一份拷贝PlayerEnt from = GameContext.playerService.getPlayer(message.getSenderId());from.getExtendBox().addNewMessage(message.getReceiverId(), message);GameContext.playerService.savePlayer(from);// 把消息存储到接收者的私聊列表中,如果离线,也要捞起来PlayerEnt target = GameContext.playerService.getPlayer(message.getReceiverId());// 丢到对方线程跑ThreadSafeUtil.addPlayerTask(target, () -> target.getExtendBox().addNewMessage(message.getSenderId(), message));}@Overridepublic List<ChatMessage> collectMessageOnLogin(PlayerEnt player) {return player.getExtendBox().getPrivateChat().values().stream().flatMap(List::stream).collect(Collectors.toList());}@Overridepublic Collection<String> receivers(ChatMessage message) {List<String> receivers = new LinkedList<>();// 发送方+接收方receivers.add(message.getReceiverId());receivers.add(message.getReceiverId());return receivers;}@Overridepublic byte channelType() {return Channels.PERSON;}
}
有一点需要注意的是,在投放接收者消息的时候,需要考虑线程安全问题。
4.世界频道
世界频道,即为单服的全服聊天。可能有些游戏,会定义为所有游戏服节点的全服聊天,这里只是概念的差异。
/*** 世界聊天频道*/
public class WorldChannelHandler implements ChatChannelHandler {// 世界聊天消息缓存// 最大100条消息,30分钟过期private LazyCacheMap<Long, ChatMessage> worldChatCache = new LazyCacheMap<>(100, 30 * TimeUtil.MILLIS_PER_MINUTE);@Overridepublic int checkCanSend(PlayerEnt player, String target, String content) {return 0;}@Overridepublic void cacheOrSave(ChatMessage message) {//世界聊天消息不需要存储到数据库, 只存储在内存中worldChatCache.put(message.getId(), message);}@Overridepublic List<ChatMessage> collectMessageOnLogin(PlayerEnt player) {return worldChatCache.getAllRecords();}@Overridepublic Collection<String> receivers(ChatMessage message) {return GameContext.playerService.getOnlinePlayers().keySet();}@Overridepublic byte channelType() {return Channels.WORLD;}
}
5.业务代码
处理聊天消息发送
/*** 消息发送* @param player 发送者* @param channel* @param content 聊天内容* @param target 发送目标,若为私聊频道,代表接收者id。若为世界频道,该参数为空。*/
public int sendMessage(PlayerEnt player, byte channel, String content, String target) {ChatChannelHandler handler = handlers.get(channel);AssertPlus.isFalse(handler == null);int errCode = handler.checkCanSend(player, target, content);if (errCode > 0) {return errCode;}// 这里要用异步广播ChatMessage message = new ChatMessage();message.setChannel(channel);message.setSenderId(player.getId());message.setReceiverId(target);message.setContent(content);handler.cacheOrSave(message);handler.broadcast(message);return 0;
}
处理登录下发离线消息
/*** 登录的时候,收集所有频道的离线聊天,统一下发* @param player*/
public void notifyNewMessageOnLogin(PlayerEnt player) {PushChatNewMessage push = new PushChatNewMessage();List<ChatMessage> messages = new LinkedList<>();handlers.values().forEach(handler->{messages.addAll(handler.collectMessageOnLogin(player));});push.setMessages(messages);MessageUtil.pushMessage(player, push);
}