SpringBoot 整合 Netty + Websocket 實現NIO通信

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章