最近項目中需要用到長連接服務,特地整合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