最近项目中需要用到长连接服务,特地整合Netty+Websocket。我们系统需要给用户主动推送订单消息,还有强制用户下线的功能也需要长连接来推送消息
一、准备工作
Netty的介绍就看这里:https://www.jianshu.com/p/b9f3f6a16911
必须要理解到一些基础概念,什么是BIO,NIO,AIO,什么是多路复用,什么是Channel(相当于一个连接),什么是管道等等概念。
环境:
- JDK8
- SpringBoot - 2.1.5.RELEASE
依赖:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.25.Final</version>
</dependency>
这里有个简易版的Demo: https://github.com/MistraR/netty-websocket.git
二、上代码
*会包含部分业务代码
项目结构:
WebSocketServer
@Component
@Slf4j
public class WebSocketServer {
/**
* 主线程组 负责接收请求
*/
private EventLoopGroup mainGroup;
/**
* 从线程组 负责处理请求 这里的主从线程组就是典型的多路复用思想
*/
private EventLoopGroup subGroup;
/**
* 启动器
*/
private ServerBootstrap server;
/**
* 某个操作完成时(无论是否成功)future将得到通知。
*/
private ChannelFuture future;
/**
* 单例WbSocketServer
*/
private static class SingletonWsServer {
static final WebSocketServer instance = new WebSocketServer();
}
public static WebSocketServer getInstance() {
return SingletonWsServer.instance;
}
public WebSocketServer() {
mainGroup = new NioEventLoopGroup();
subGroup = new NioEventLoopGroup();
server = new ServerBootstrap();
server.group(mainGroup, subGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WwbSocketServerInitialize());//自定义的初始化类,注册管道内的处理器
}
public void start() {
this.future = server.bind(8088);
log.info("| Netty WebSocket Server 启动完毕,监听端口:8088 | ------------------------------------------------------ |");
}
}
WwbSocketServerInitialize
每一个请求到服务的连接都会被这些注册了的处理类(Handler)处理一次,类似于拦截器,相当于一个商品要经过一次流水线,要被流水线上的工人加工一道工序。
public class WwbSocketServerInitialize extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//定义管道------------------------------------------------
ChannelPipeline pipeline = socketChannel.pipeline();
//定义管道中的众多处理器
//HTTP的编解码处理器 HttpRequestDecoder, HttpResponseEncoder
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
// 对httpMessage进行聚合,聚合成FullHttpRequest或FullHttpResponse
pipeline.addLast(new HttpObjectAggregator(1024 * 64));
// 增加心跳支持
// 针对客户端,如果在1分钟时没有向服务端发送读写心跳(ALL),则主动断开
pipeline.addLast(new IdleStateHandler(60, 60, 60));
pipeline.addLast(new HeartBeatHandler());//自定义的心跳处理器
// ====================== 以下是支持httpWebsocket ======================
/**
* websocket 服务器处理的协议,用于指定给客户端连接访问的路由 : /ws
* 对于websocket来讲,都是以frames进行传输的,不同的数据类型对应的frames也不同
*/
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
// 自定义的业务处理handler
pipeline.addLast(new NoMaybeHandler());
}
}
HeartBeatHandler
心跳支持,如果服务端一段时间没收到客户端的心跳,主动断开连接,避免资源浪费。
@Slf4j
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 判断evt是否是IdleStateEvent(用于触发用户事件,包含 读空闲/写空闲/读写空闲 )
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
log.info("进入读空闲...");
} else if (event.state() == IdleState.WRITER_IDLE) {
log.info("进入写空闲...");
} else if (event.state() == IdleState.ALL_IDLE) {
log.info("关闭无用的Channel,以防资源浪费。Channel Id:{}", ctx.channel().id());
Channel channel = ctx.channel();
channel.close();
UserChannelRelation.remove(channel);
log.info("Channel关闭后,client的数量为:{}", NoMaybeHandler.clients.size());
}
}
}
}
最关键的业务处理Handler - NoMaybeHandler
结合自己的业务需求,对请求到服务器的消息进行业务处理。
@Slf4j
public class NoMaybeHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
/**
* 管理所有客户端的channel通道
*/
public static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
//获取客户端传输过来的消息
String content = textWebSocketFrame.text();
Channel currentChannel = channelHandlerContext.channel();
try {
//将消息转换成pojo
WsDataContent wsDataContent = JacksonUtils.stringToObject(content, WsDataContent.class);
if (wsDataContent == null) {
throw new RuntimeException("连接请求参数错误!");
}
Integer action = wsDataContent.getAction();
String msgId = wsDataContent.getMsgId();
//判断消息类型,根据不同的类型来处理不同的业务
if (action.equals(MsgActionEnum.CONNECT.type)) {
//当Websocket第一次建立的时候,初始化Channel,把Channel和userId关联起来
UserWebsocketSalt userWebsocketSalt = wsDataContent.getSalt();
if (userWebsocketSalt == null || userWebsocketSalt.getUserId() == null) {
//主动断开连接
writeAndFlushResponse(MsgActionEnum.BREAK_OFF.type, msgId, createKickMsgBody(), currentChannel);
//currentChannel.close();
return;
}
String userId = userWebsocketSalt.getUserId();
//我们用loginLabel 标签结合长连接消息来做单点登录,踢设备下线,可以忽略中间的业务代码,这里主要是处理将userId于Channel绑定,存在Map中 -》UserChannelRelation.put(userId, currentChannel)
String loginLabel = userWebsocketSalt.getLoginLabel();
Channel existChannel = UserChannelRelation.get(userId);
if (existChannel != null) {
//存在当前用户的连接,验证登录标签
LinkUserService linkUserService = (LinkUserService) SpringUtil.getBean("linkUserServiceImpl");
if (linkUserService.checkUserLoginLabel(userId, loginLabel)) {
//是同一次登录标签,加入新连接,关闭旧的连接
UserChannelRelation.put(userId, currentChannel);
writeAndFlushResponse(MsgActionEnum.BREAK_OFF.type, null, createKickMsgBody(), existChannel);
writeAndFlushResponse(MsgActionEnum.MESSAGE_SIGN.type, msgId, null, currentChannel);
//existChannel.close();
} else {
//不是同一次登录标签,拒绝连接
writeAndFlushResponse(MsgActionEnum.BREAK_OFF.type, null, createKickMsgBody(), currentChannel);
//currentChannel.close();
}
} else {
UserChannelRelation.put(userId, currentChannel);
writeAndFlushResponse(MsgActionEnum.MESSAGE_SIGN.type, msgId, null, currentChannel);
}
} else if (action.equals(MsgActionEnum.KEEPALIVE.type)) {
//心跳类型的消息
log.info("收到来自Channel为{}的心跳包......", currentChannel);
writeAndFlushResponse(MsgActionEnum.MESSAGE_SIGN.type, msgId, null, currentChannel);
} else {
throw new RuntimeException("连接请求参数错误!");
}
} catch (Exception e) {
log.debug("当前连接出错!关闭当前Channel!");
closeAndRemoveChannel(currentChannel);
}
}
/**
* 响应客户端
*/
public static void writeAndFlushResponse(Integer action, String msgId, Object data, Channel channel) {
WsDataContent wsDataContent = new WsDataContent();
wsDataContent.setAction(action);
wsDataContent.setMsgId(msgId);
wsDataContent.setData(data);
channel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(wsDataContent)));
}
/**
* 构建强制下线消息体
*
* @return
*/
public static PushMessageData createKickMsgBody() {
PushMessageData pushMessageData = new PushMessageData();
pushMessageData.setMsgType(MessageEnums.MsgTp.ClientMsgTp.getId());
pushMessageData.setMsgVariety(MessageEnums.ClientMsgTp.FORCED_OFFLINE.getCode());
pushMessageData.setTime(LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli());
pushMessageData.setMsgBody(null);
return pushMessageData;
}
/**
* 构建派单消息体
*
* @return
*/
public static PushMessageData createDistributeOrderMsgBody(String orderId) {
PushMessageData pushMessageData = new PushMessageData();
pushMessageData.setMsgType(MessageEnums.MsgTp.OrderMsgTp.getId());
pushMessageData.setMsgVariety(MessageEnums.OrderMsgTp.PUSH_CODE_ORDER_ROB.getCode());
pushMessageData.setTime(LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli());
MsgBodyVO msgBodyVO = new MsgBodyVO(orderId);
pushMessageData.setMsgBody(msgBodyVO);
return pushMessageData;
}
/**
* 当客户端连接服务端之后(打开连接)
* 获取客户端的channel,并且放到ChannelGroup中去进行管理
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
log.info("客户端建立连接,Channel Id为:{}", ctx.channel().id().asShortText());
clients.add(ctx.channel());
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
//当触发handlerRemoved,ChannelGroup会自动移除对应客户端的channel
Channel channel = ctx.channel();
clients.remove(channel);
UserChannelRelation.remove(channel);
log.info("客户端断开连接,Channel Id为:{}", channel.id().asShortText());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//发生异常之后关闭连接(关闭channel),随后从ChannelGroup中移除
Channel channel = ctx.channel();
cause.printStackTrace();
channel.close();
clients.remove(channel);
UserChannelRelation.remove(channel);
log.info("连接发生异常,Channel Id为:{}", channel.id().asShortText());
}
/**
* 关闭Channel
*
* @param channel
*/
public static void closeAndRemoveChannel(Channel channel) {
channel.close();
clients.remove(channel);
}
}
UserChannelRelation
Map存储userId于Channel的对应关系
public class UserChannelRelation {
private static Logger logger = LoggerFactory.getLogger(UserChannelRelation.class);
private static HashMap<String, Channel> manager = new HashMap<>();
public static void put(String userId, Channel channel) {
manager.put(userId, channel);
}
public static Channel get(String userId) {
return manager.get(userId);
}
public static void remove(String userId) {
manager.remove(userId);
}
public static void output() {
for (HashMap.Entry<String, Channel> entry : manager.entrySet()) {
logger.info("UserId:{},ChannelId{}", entry.getKey(), entry.getValue().id().asLongText());
}
}
/**
* 移除Channel
*
* @param channel
*/
public static void remove(Channel channel) {
for (Map.Entry<String, Channel> entry : manager.entrySet()) {
if (entry.getValue().equals(channel)) {
manager.remove(entry.getKey());
}
}
}
}
消息类型枚举MsgActionEnum
public enum MsgActionEnum {
/**
* Websocket消息类型,WsDataContent.action
*/
CONNECT(1, "客户端初始化建立连接"),
KEEPALIVE(2, "客户端保持心跳"),
MESSAGE_SIGN(3, "客户端连接请求-服务端响应-消息签收"),
BREAK_OFF(4, "服务端主动断开连接"),
BUSINESS(5, "服务端主动推送业务消息"),
SEND_TO_SOMEONE(9, "发送消息给某人(用于通信测试)");
public final Integer type;
public final String content;
MsgActionEnum(Integer type, String content) {
this.type = type;
this.content = content;
}
public Integer getType() {
return type;
}
}
消息体WsDataContent
@Data
public class WsDataContent implements Serializable {
private static final long serialVersionUID = 5128306466491454779L;
/**
* 消息类型
*/
private Integer action;
/**
* msgId
*/
private String msgId;
/**
* 发起连接需要的参数
*/
private UserWebsocketSalt salt;
/**
* data
*/
private Object data;
}
UserWebsocketSalt
客户端简历连接是需要提供的参数,userId
@Data
public class UserWebsocketSalt {
/**
* userId
*/
private String userId;
/**
* loginLabel 当前登录标签
*/
private String loginLabel;
}
每一次请求都会经过channelRead0方法的处理,将前端传回来的消息—我们这里是约定好的Json字符串,转换为对应的实体类,然后进行业务操作。
- 客户端的每次连接请求或者消息通信,服务端必须响应,所以在WsDataContent 定义了一个msgId,收到消息,必须响应消息签收,返回统一的msgId,或者响应主动断开连接。
- 客户端发起连接,我们把连接的Channel跟userId对应起来,存在Map中(自定义的UserChannelRelation类),如果要给某个用户发送消息,只需要根据userId拿到对应的Channel,然后通过channel.writeAndFlush(new TextWebSocketFrame(“消息-Json字符串”));方法,就可以给该用户发送消息了。
- 客户端的心跳连接,接受到客户端的心跳请求,不做任何操作,只是响应它,服务端收到了心跳,类似于握手。服务端也要主动检测心跳,超过指定时间就主动关闭Channel。就是在WwbSocketServerInitialize中配置的pipeline.addLast(new IdleStateHandler(60, 60, 60));心跳时间。
- 客户端的业务类型消息,结合业务场景处理。
最后让Websocket服务随应用启动NettyNIOServer
@Component
public class NettyNIOServer implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
try {
WebSocketServer.getInstance().start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
有部分业务代码没有贴上来,不影响。
这里有个简易版的Demo: https://github.com/MistraR/netty-websocket.git