一、基本思路:
在上一篇中分享了短连接下IM设计,这篇来分享基于长连接设计的IM系统。
长连接由于不用每次请求后就释放连接,所以其效率性能比短连接的高上不少,但是长连接的实现比短连接要复杂不少,需要通过心跳去维护连接,避免连接因为长时间没有发消息而被关闭,而且由于移动端网络极其不稳定,网络抖动厉害,而心跳检测有一定的延时性,因此极其容易丢消息,因此需要一个机制来保证消息的可靠性,在消息丢失后能进行消息重试。
在IM-Service部署了多个实例后还会面临新的问题,首先是路由问题,客户端只能连接到其中一个实例上,因此客户端需要先拉取IM-Service实例列表,通过一个路由算法路由到其中一个实例并连接,得到一个Channel,以后发消息都通过这个Channel进行。另一个问题是消息的发送方和接收方连接的不是同一个服务实例的问题,如下图所示:
上图所示,客户端C1连接上服务实例S1,客户端C2连接上服务实例S2,现在C1要给C2发消息,C1首先将消息发到S1上,但是S1上没有C2连接的Channel,因此S1无法将消息推给C2,解决办法就是S1将消息转给S2,然后S2发现当前服务实例上有C2的连接Channel,因此S2可以将消息推给C2。
这里只是举例两个服务实例的情况,实际生产环境中可能有多个服务实例,S1要如何知道C2连接的服务实例是哪个了,一般有两种解决办法:
1、增加一个Register服务实例登记所有的客户端连接的服务实例的映射关系,每个服务实例有客户端连接上时就登记到Register服务,客户端下线时就从Register服务删除,C1给C2发消息时发现C2不在S1服务实例上,于是S1从Register服务中查询到C2所在的服务实例,然后S1将消息转给这个服务实例,由这个服务实例将消息推送给客户端C2。这种方式下其他与这条消息无关的服务实例不用做无用的操作,但是会增加调用链路的长度,切同时如果Register服务出现故障就会导致整个系统无法工作,因此Register服务要部署多个服务实例来保证高可用。
2、每个服务实例在启动时都订阅MQ上一个指定的Topic,当S1发现当前服务实例上没有C2的连接Channel,就向MQ指定的Topic发布一条消息,然后所有的服务实例都能收到这条消息,然后都在自己的连接Channel池中查找是否有接收客户端的Channel,有的话则通过这个Channel推送消息到客户端。这种方式实现简单,可靠性简单,缺点是与这条消息无关的服务实例都要做一次无用的查找。
在本IM系统设计中采用了方案二,网络通讯框架采用了Netty,MQ可以采用RabbitMQ, RocketMQ等等,本实例中我们采用了Redis的发布订阅,下面将介绍Redis的发布订阅机制。
二、redis的发布订阅机制:
1、基本概念:
Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。
Redis 客户端可以订阅任意数量的频道。
下图展示了频道 channel1 , 以及订阅这个频道的三个客户端 ------ client2 、 client5 和 client1 之间的关系:
当有新消息通过 PUBLISH 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端:
2、发布订阅命令:
例如下面创建了订阅频道名为 MyChannel
ruby
127.0.0.1:6379> subscribe MyChannel
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "MyChannel"
3) (integer) 1
然后在另一个客户端给这个频道发布消息:
ruby
127.0.0.1:6379> publish MyChannel aaaa
(integer) 1
3、SpringBoot中使用Redis的发布订阅:
首先要引入Redis的依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<scope>provided</scope>
</dependency>
接下来发布消息到频道:
ini
String message = node.toString();
redisTemplate.convertAndSend("SocketCloseTopic", new DistributedMessage(SendType.ALL, channelType, message));
最后是订阅消息,在框架中定义了一个MessageListener接口,我们只需要实现这个接口即可收到指定频道的消息:
less
@Component
public class WebSocketChannelCloseListener implements MessageListener {
private RedisSerializer<String> stringSerializer = new StringRedisSerializer();
@Override
public void onMessage(@NonNull Message message, @Nullable byte[] bytes) {
byte[] channel = message.getChannel();
byte[] body = message.getBody();
String msgChannel = stringSerializer.deserialize(channel);
String msgBody = stringSerializer.deserialize(body);
logger.trace("监听到WS-ChannelClose消息,channel:{},body:{}", msgChannel, msgBody);
DistributedMessage distributedMessage = JSON.parseObject(msgBody, DistributedMessage.class);
//........省略逻辑处理代码...........
}
}
那么我们定义的这个MessageListener是监听的哪个频道的消息了,在定义RedisMessageListenerContainer的bean时指定的:
java
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(StringRedisTemplate stringRedisTemplate, WebSocketChannelCloseListener webSocketChannelCloseListener) {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(stringRedisTemplate.getRequiredConnectionFactory());
redisMessageListenerContainer.addMessageListener(webSocketChannelCloseListener, new ChannelTopic("SocketCloseTopic"));
return redisMessageListenerContainer;
}
三、系统设计:
1、整体架构:
1)、客户端封装了一个SDK,在 SDK中首先从注册中心Eureka中拿到所有的IM-Service的实例列表缓存,然后用一个负载均衡算法将当前客户端路由到其中一个服务实例上,最后通过Socket / WebSocket连接上指定的服务实例;
2)、服务端的IM-Service整个基于Netty框架,客户端的请求首先达到Netty框架,IM-Service实现接收到消息的处理逻辑;
3)、IM-Service启动时会注册到注册中心,关闭时会从注册中心删除,同时IM-Service会订阅Redis指定的Topic;
4)、IM-Service收到消息后,如果接收方在当前实例上则通过Netty直接推送给客户端,如果不在当前服务实例上则向Redis发布一个消息,其他IM-Service实例收到消息,查找消息接收方是否连接的当前服务实例,如果是的话则推送给客户端;
5)、将消息推送给客户端的同时,启动异步任务将消息保存到MySQL数据库。
2、Netty初始化:
scss
public void start() {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(nettyChannelInitializer)
.childOption(ChannelOption.SO_KEEPALIVE, true);
//绑定端口,开始接收进来的连接
ChannelFuture f = serverBootstrap.bind(new InetSocketAddress(port)).sync();
//等待服务器socket关闭
f.channel().closeFuture().sync();
}
//NettyChannelInitializer.initChannel
protected void initChannel(Channel ch) {
ChannelPipeline pipeline = ch.pipeline();
//解码
pipeline.addLast(new LengthFieldBasedFrameDecoder(100 * 1024 * 1024, 0, 4, 0, 4));
pipeline.addLast(nettyReqDecoder);
//编码
pipeline.addLast(new LengthFieldPrepender(4));
pipeline.addLast(nettyReqEncoder);
pipeline.addLast(nettyServerHandler);
}
public class NettyServerHandler extends SimpleChannelInboundHandler<NettyReqWrapper> {
@Autowired
private NettySessionManager nettySessionManager;
@Autowired
private NettyHandlerManager nettyHandlerManager;
protected void channelRead0(ChannelHandlerContext ctx, NettyReqWrapper nettyReqWrapper) {
Channel incoming = ctx.channel();
Attribute<SessionUser> attribute = incoming.attr(NettyServer.REDIS_ATTR_SOCKET);
SessionUser sessionUser = attribute.get();
if (null != sessionUser && sessionUser.isAuthorized()) {
//回执消息直接返回,不做任何处理
if (nettyReqWrapper.getCmd() < 0) {
return;
}
//handler处理
nettyHandlerManager.process(incoming, sessionUser, nettyReqWrapper);
} else {
checkAndLogin(nettyReqWrapper, incoming, attribute);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
Channel incoming = ctx.channel();
SessionUser sessionUser = incoming.attr(NettyServer.REDIS_ATTR_SOCKET).get();
nettySessionManager.removeSession(sessionUser.getUserId(), sessionUser.getChannelType());
ctx.close();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
Channel incoming = ctx.channel();
SessionUser sessionUser = incoming.attr(NettyServer.REDIS_ATTR_SOCKET).get();
nettySessionManager.removeSession(sessionUser.getUserId(), sessionUser.getChannelType());
ctx.close();
}
}
start以及NettyChannelInitializer.initChannel方法中的代码很好理解,就是启动Netty服务并初始化Netty的流水线,对Netty稍微有点了解的都很好理解。
初始化时指定了业务处理Handler类NettyServerHandler,channelRead0接收到客户端的消息,读取到SessionUser,然后交给nettyHandlerManager进行处理,如果读取不到User则调用登录处理,这块后面再介绍。channelInactive、exceptionCaught部分就是关闭和删除Session及连接。
3、Session管理:
实现Session管理的类是NettySessionManager,从这个类中不仅可以读取到当前连接的客户端信息,而且还将所有连接这台服务实例的客户端的Channel保存到一个Map中。代码如下:
typescript
public class SessionUser implements Serializable {
private boolean authorized;
private Long loginTime;
private String userName;
private Integer gender;
private String userHead;
private String city;
//...........
}
public class NettySessionManager extends AbstractSessionManager {
@Override
public AttributeKey<SessionUser> getAttributeKey() {
return WebSocketServer.REDIS_ATTR_WEBSOCKET;
}
}
public abstract class AbstractSessionManager {
// 用户会话缓存
protected static final ConcurrentHashMap<Long, ConcurrentHashMap<ChannelType, Channel>> sessionCache = new ConcurrentHashMap<>();
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private MachineApi machineApi;
public void addSession(Long userId, ChannelType channelType, Channel channel) {
ConcurrentHashMap<ChannelType, Channel> userMap = sessionCache.get(userId);
if (null == userMap) {
userMap = new ConcurrentHashMap<>();
userMap.put(channelType, channel);
sessionCache.put(userId, userMap);
return;
}
Channel oldSession = userMap.get(channelType);
// 若用户重复登录,覆盖信息,并断开本地前一次连接
if (oldSession != null) {
oldSession.close();
}
//断开分布式服务器中的同渠道连接
ChannelClose channelClose = new ChannelClose(userId, machineApi.getMachineId());
redisTemplate.convertAndSend(getSocketCloseTopic(), new DistributedMessage(SendType.ALL, channelType, JSON.toJSONString(channelClose)));
//本地缓存替换为新的连接
userMap.put(channelType, channel);
}
public Channel getSession(Long userId, ChannelType channelType) {
ConcurrentHashMap<ChannelType, Channel> userMap = sessionCache.get(userId);
if (null == userMap) {
return null;
}
return userMap.get(channelType);
}
//...............
}
1)、通过NettySessionManager.getAttributeKey可以获取到连接的客户端信息,信息内容见SessionUser;
2)、AbstractSessionManager保存了所有连接到这个服务实例的客户端的Channel列表,这个Map分两层,第一层的Key为UserId,第二层的Key为ChannelType,即客户端类型,有App、web等;
3)、addSession首先查找是否已经存在相同UserId和ChannelType的Channel,如果不存在则直接添加到Map中,如果存在则将原Channel关闭,并将新的Channel添加到Map。
4)、getSession实现了按照userId和ChannelType的查找;
4、消息发送:
java
public <T extends IReq> boolean send(Long receiveId, ChannelType channelType, @NonNull T data) {
JsonNode node = objectMapper.convertValue(data, JsonNode.class);
String message = node.toString();
Channel channel = nettySessionManager.getSession(receiveId, channelType);
if (null != channel && channel.isActive()) {
return localSend(receiveId, channelType, message);
} else {
redisTemplate.convertAndSend(NettyServer.REDIS_TOPIC_SOCKET, new DistributedMessage(receiveId, channelType, message));
return true;
}
}
public boolean localSend(Long receiveId, ChannelType channelType, String message) {
Channel channel = nettySessionManager.getSession(receiveId, channelType);
if (null != channel && channel.isActive()) {
NettyReqWrapper nettyReqWrapper = new NettyReqWrapper(cmd, message);
channel.writeAndFlush(nettyReqWrapper);
return true;
}
return false;
}
send方法首先在当前服务实例缓存的Channel的map中查找Channel,如果查找到了则调用localSend发送,localSend其实就是通过Channel直接将消息推送给客户端;如果没查找到则给redis发布消息,其他服务实例收到这个消息则在本实例查找Channel,查找到则直接推送消息:
less
public class NettySendMessageListener implements MessageListener {
private RedisSerializer<String> stringSerializer = new StringRedisSerializer();
@Autowired
private NettySendManager nettySendManager;
@Override
public void onMessage(@NonNull Message message, @Nullable byte[] bytes) {
byte[] channel = message.getChannel();
byte[] body = message.getBody();
String msgChannel = stringSerializer.deserialize(channel);
String msgBody = stringSerializer.deserialize(body);
DistributedMessage distributedMessage = JSON.parseObject(msgBody, DistributedMessage.class);
nettySendManager.localSend(distributedMessage.getReceiveId(), distributedMessage.getChannelType(), distributedMessage.getMessage());
}
}
这个类实现了MessageListener接口,因此就能在onMessage中接收到上一步给redis发布的消息。onMessage中也是先解析出消息中的内容,调用localSend查找Channel,查找到则通过Channel推送;
5、编码解码器:
在Netty初始化时即在流水线中指定了编码器NettyReqEncoder和解码器NettyReqDecoder:
scala
public class NettyReqWrapper implements Serializable {
private int cmd;
private String message;
//.........
}
public class NettyReqEncoder extends MessageToMessageEncoder<NettyReqWrapper> {
@Override
protected void encode(ChannelHandlerContext ctx, NettyReqWrapper nettyReqWrapper, List<Object> out) {
byte[] byteMsg = new byte[0];
if (StringUtils.hasText(nettyReqWrapper.getMessage())) {
byteMsg = nettyReqWrapper.getMessage().getBytes(charset);
}
ByteBuf byteBuf = ctx.alloc().buffer(byteMsg.length + 4);
byteBuf.writeInt(nettyReqWrapper.getCmd());
if (byteMsg.length > 0) {
byteBuf.writeBytes(byteMsg);
}
out.add(byteBuf);
}
}
public class NettyReqDecoder extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
NettyReqWrapper nettyReqWrapper = new NettyReqWrapper();
int len = in.readableBytes();
if (len < 4) {
in.skipBytes(len);
throw new BusinessException("NettyReqDecoder ByteBuf readableBytes is less than 4");
}
int cmd = in.getInt(0);
nettyReqWrapper.setCmd(cmd);
in.skipBytes(4);
nettyReqWrapper.setMessage(in.toString(charset));
out.add(nettyReqWrapper);
}
}
可以看到在编码器NettyReqEncoder中,只是在消息内容前面加了一个四个字节的CmdId,解码器NettyReqDecoder中首先解析出CmdId,然后再将消息内容进入下一步流程处理。
其实CmdId就是定义了一系列的常量,如下:
arduino
//登录
public static final int CMD_LOGIN = 1000;
//心跳
public static final int CMD_HEARTBEAT = 1001;
//送礼
public static final int CMD_SEND_GIFT = 3000;
//直播间爆灯
public static final int CMD_SEND_LIGHT = 4000;
//------begin服务端请求---------
//登录鉴权结果
public static final int CMD_LOGIN_RESULT = 2000;
//响应的转换因子
private static final int CMD_RSP = -1;
6、客户端消息处理器:
客户端消息处理器实现在NettyHandlerManager中:
scala
public class NettyHandlerManager extends AbstractHandlerManager {
@Autowired
private HeartbeatHandler heartbeatHandler;
public void process(Channel channel, SessionUser sessionUser, NettyReqWrapper nettyReqWrapper) {
Cmd cmd = inputMap.get(nettyReqWrapper.getCmd());
//回复收到消息的回执
Req receipt = new Req(CmdConstants.getReceiptCmd(cmd.getUniqueCode()));
channel.writeAndFlush(new NettyReqWrapper(receipt.getCmd(), JSON.toJSONString(receipt)));
//记录心跳数据
heartbeatHandler.heartbeat(sessionUser);
//............
}
}
process中首先根据消息中的CmdId查找到对应的Cmd实例,然后给客户端发送消息回执,相当于告诉客户端你发来的这条消息我已经收到了。最后记录心跳数据到redis缓存,其实这里不光认为只有心跳包是心跳数据,收到的任何消息都可以认为是心跳数据。这里的记录心跳数据其实就是把UserId和在线状态更新到redis,这样方便客户端查询在线用户信息。
inputMap根据CmdId查找Cmd实例,inputMap的初始化代码如下:
csharp
public Set<InputCmd> initInput() {
Set<InputCmd> cmdSet = new HashSet<>();
cmdSet.add(new NettyInputCmd<>(CmdConstants.CMD_LOGIN, "系统", "登录", LoginReq.class, null));
cmdSet.add(new NettyInputCmd<>(CmdConstants.CMD_HEARTBEAT, "系统", "心跳", Req.class, null));
//................
return cmdSet;
}
Cmd的定义如下:
csharp
public interface Cmd<M extends IReq> {
Protocol getProtocol();
FromType getFromType();
int getUniqueCode();
String getModule();
String getRemark();
Class<M> getMClass();
BiConsumer<SessionUser, M> getConsumer();
}
public abstract class InputCmd<M extends IReq> implements Cmd<M> {
private Protocol protocol;
private FromType fromType = FromType.CLIENT;
private int uniqueCode;
private String module;
private String remark;
private Class<M> mClass;
private BiConsumer<SessionUser, M> consumer;
//............
}
鉴权如何处理了,在NettyServerHandler.channelRead0中读取到消息后判断如果如果没有鉴权信息则执行鉴权操作:
ini
private void checkAndLogin(NettyReqWrapper nettyReqWrapper, Channel incoming, Attribute<SessionUser> attribute) {
LoginReq loginReq = JSON.parseObject(nettyReqWrapper.getMessage(), LoginReq.class);
String userId = loginReq.getUserId();
String token = loginReq.getToken();
String appVersion = loginReq.getAppVersion();
String devType = loginReq.getDevType();
ChannelType channelType = ChannelType.of(loginReq.getDevType());
if (enableAuth) {
//鉴权
LoginResultReq loginResultReq = authManager.checkUserToken(tenantId, userId, token, DevType.getEnumByType(loginReq.getDevType()));
if (!ResponseConstant.SUCCESS.getCode().equals(loginResultReq.getCode())) {
// 鉴权失败消息
incoming.writeAndFlush(new NettyReqWrapper(loginResultReq.getCmd(), JSON.toJSONString(loginResultReq)));
incoming.close();
return;
}
}
//保存会话信息
SessionUser sessionUser = new SessionUser();
sessionUser.setAuthorized(true);
//..............
attribute.set(sessionUser);
nettySessionManager.addSession(Long.valueOf(userId), channelType, incoming);
//登录成功
LoginResultReq loginResultReq = new LoginResultReq(CmdConstants.CMD_LOGIN_RESULT, ResponseConstant.SUCCESS.getCode(), ResponseConstant.SUCCESS.getMsg());
incoming.writeAndFlush(new NettyReqWrapper(loginResultReq.getCmd(), JSON.toJSONString(loginResultReq)));
}
首先解析出鉴权的必要信息,然后调用authManager.checkUserToken进行鉴权,接着构造SessionUser信息到attribute并添加Session,最后给客户端推送鉴权成功的消息。
四、常见问题:
1、丢消息问题:
客户端将消息发送到服务端有可能会丢消息,服务端将消息推送到客户端也有可能会丢消息。
解决丢消息问题的最好方法就是Ack回执机制,即将消息发送出去后,同时将消息丢到MQ的延时队列,对方收到消息后就发送回执Ack消息,收到回执后可以将回执信息保存到redis缓存中;当MQ中延时队列的消息超时后就会再次被消费,消费时首先检查有没有回执消息,有的话说明消息对方确认收到了,则丢弃返回,如果没有的话则说明消息可能丢失了,需要再重试发送出去,同时再次丢到MQ的延时队列。这里可以设置重试的最大次数,比如设置为3次,如果连续三次都没能成功推送出去,说明客户端可能不在线,则不再继续推送,而是将消息保存到数据库中作为离线消息。当客户端下次登录上来时调用接口查询离线消息。
2、消息乱序的问题:
由于消息的发送是异步的,因此有可能出现先发的消息后到达,后发的消息先到达,就会出现消息乱序的问题。
第一反应是在消息里面加一个时间戳,但是由于客户端的时间有可能就是错的,这样会导致消息的顺序错的更离谱。如果在消息达到服务端后,由服务端添加时间戳了,虽然能解决大部分情况下的消息顺序问题,但是如果服务实例由多个,服务器本地时间有误差,会导致时间依然不是很准确。
一个比较好的方法是通过一个发号器来生成一个有序的唯一ID,所有消息在达到服务端后,都会调用发号器来生成一个有序唯一的ID,不管服务实例有多少个,都会调用这个发号器,消息按照调用发号器的顺序来排序,这样就不会出现乱序的问题。
3、消息重复问题:
消息发送之后都会等待对方发送回执消息过来,如果回执消息丢失了,那么发送方就认为对方没收到消息而再次重试发送,这样会导致对方一条消息重复收到多次的问题。
解决方法也很简单,由发送方client-A生成一个消息去重的msgid,保存在"等待ack队列"里,同一条消息使用相同的msgid来重传,供client-B去重。