websocket性能低?教你使用netty整合websocket(二)——實現點對點聊天(客戶端與客戶端通信)

前提

瞭解如何實現客戶端和服務端通訊
上一篇博客——SpringBoot+Netty整合websocket(一)——客戶端和服務端通訊

實現點對點聊天

後端

1.建立服務端WebSocketNettyServer

@Slf4j
@Configuration
public class WebSocketNettyServer {

    /** netty整合websocket的端口 */
    @Value("${netty.port}")
    private int port;

    public void run() throws InterruptedException {
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();

        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(boss,worker)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG,1024)
                    .childOption(ChannelOption.TCP_NODELAY,true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            //web基於http協議的解碼器
                            ch.pipeline().addLast(new HttpServerCodec());
                            //對大數據流的支持
                            ch.pipeline().addLast(new ChunkedWriteHandler());
                            //對http message進行聚合,聚合成FullHttpRequest或FullHttpResponse
                            ch.pipeline().addLast(new HttpObjectAggregator(1024 * 64));
                            //websocket服務器處理對協議,用於指定給客戶端連接訪問的路徑
                            //該handler會幫你處理一些繁重的複雜的事
                            //會幫你處理握手動作:handshaking(close,ping,pong) ping + pong = 心跳
                            //對於websocket來講,都是以frames進行傳輸的,不同的數據類型對應的frames也不同
                            ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws"));
                            //添加我們的自定義channel處理器
                            ch.pipeline().addLast(new WebSocketHandler());
                        }
                    });
            log.info("服務器啓動中,websocket的端口爲:"+port);
            ChannelFuture future = bootstrap.bind(port).sync();
            future.channel().closeFuture().sync();
        } finally {
            //關閉主從線程池
            worker.shutdownGracefully();
            boss.shutdownGracefully();
        }

    }
}
2.建立聊天類

聊天類主要是消息本身的各種屬性

@Data
public class ChatVO implements Serializable {

    /** 消息id */
    private Integer questionId;
    
    /**聊天信息類型*/
    private String chatMessageType;
    
    /**聊天內容*/
    private String content;
    
    /**發送方ID*/
    private Integer fromUserId;
    
    /**接收方ID*/
    private Integer toUserId;
    
    /**消息時間*/
    @JSONField(format="yyyy-MM-dd HH:mm:ss")
    private Date dateTime;

}

3.封裝聊天消息的VO

繼承聊天類,擁有聊天類的屬性,額外封裝消息的額外屬性(比如:消息類型、是否讀取等)

@EqualsAndHashCode(callSuper = true)
@Data
public class ChatMsgVO extends ChatVO {

    /** 動作類型 */
    private Integer action;

    /** 消息簽收狀態 */
    private MsgSignFlagEnum signed;

}

4.建立枚舉類MsgSignFlagEnum

主要用於判斷消息是否簽收

public enum MsgSignFlagEnum {
    /** 消息是否簽收 */
    unsign(0,"未簽收"),
    signed(1,"已簽收");

    @Getter
    public final int type;
    @Getter
    public final String value;

    private MsgSignFlagEnum(int type,String value) {
        this.type = type;
        this.value = value;
    }
    
}

5.建立枚舉類MsgActionEnum

主要用於確定客戶端發送消息的動作類型

public enum MsgActionEnum {
    /** 第一次(或重連)初始化連接 */
    CONNECT(1,"第一次(或重連)初始化連接"),
    /** 聊天消息 */
    CHAT(2,"聊天消息"),

    /** 客戶端保持心跳 */
    KEEPALIVE(3,"客戶端保持心跳");

    public final Integer type;
    public final String content;

    private MsgActionEnum(Integer type,String content) {
        this.type = type;
        this.content = content;
    }
}

6.在寫WebSocketHandler之前,將用戶Id跟Channel做一個綁定

主要用於確定客戶端信息

@Slf4j
public class UserChannelRel {

    /** 用戶id爲鍵,channel爲值 */
    private static ConcurrentHashMap<Integer, Channel> manager = new ConcurrentHashMap<>();

    /** 添加客戶端與channel綁定 */
    public static void put(Integer senderId,Channel channel) {
        manager.put(senderId,channel);
    }

    /** 根據用戶id查詢 */
    public static Channel get(Integer senderId) {
        return manager.get(senderId);
    }

    /** 根據用戶id,判斷是否存在此客戶端(即客戶端是否在線) */
    public static boolean isContainsKey(Integer userId){
        return manager.containsKey(userId);
    }

   /** 輸出 */
    public static void output() {
        manager.forEach(( key, value ) -> log.info("UserId:" + key + ",ChannelId:" +
                value.id().asLongText()));
    }

}

到這裏只要再建立WebSocketHandler,就可以實現點對點聊天

7.建立WebSocketHandler

@Slf4j
public class WebSocketHandler extends SimpleChannelInboundHandler<Object> {

    /**
     * 客戶端組
     * 用於記錄和管理所有客戶端的channel
     */
    public static ChannelGroup channelGroup;

    static {
        channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    }

    /**
     * 接收客戶端傳來的消息
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    protected void channelRead0 ( ChannelHandlerContext ctx, Object msg ) throws Exception {
        Channel currentChannel = ctx.channel();
        
        //文本消息
        if (msg instanceof TextWebSocketFrame) {
            String message = ((TextWebSocketFrame) msg).text();
            System.out.println("收到客戶端消息:" + message);
            //json消息轉換爲Javabean對象
            ChatMsgVO chatMsgVO = null;
            try {
                chatMsgVO = JSONUtil.toBean(message, ChatMsgVO.class, true);
            } catch (JSONException e) {
                e.printStackTrace();
                System.out.println("json解析異常,發送的消息應該爲json格式");
                return;
            }
            //得到消息的動作類型
            Integer action = chatMsgVO.getAction();
            //客戶端第一次連接websocket或者重連時執行
            if (action.equals(MsgActionEnum.CONNECT.type)) {
                //當websocket第一次open的時候,初始化channel,把用的channel和userId關聯起來
                Integer fromUserId = chatMsgVO.getFromUserId();
                UserChannelRel.put(fromUserId, currentChannel);
                //測試
                channelGroup.forEach(channel -> log.info(channel.id().asLongText()));
                UserChannelRel.output();
            } else if (action.equals(MsgActionEnum.CHAT.type)) {
                //聊天類型的消息,把聊天記錄保存到redis,同時標記消息的簽收狀態[是否簽收]
                Integer toUserId = chatMsgVO.getToUserId();
                //設置發送消息的時間
                chatVO.setDateTime(new DateTime());
                    /* 發送消息給指定用戶 */
                    //判斷消息是否符合定義的類型
                if (ChatTypeVerificationUtil.verifyChatType(chatVO.getChatMessageType())) {
                        //發送消息給指定用戶
                        if (toUserId > 0 && UserChannelRel.isContainsKey(toUserId)) {
                            sendMessage(toUserId, JSONUtil.toJsonStr(chatVO));
                        }
                 } else {
                        //消息不符合定義的類型的處理
                 }
            } else if (action.equals(MsgActionEnum.KEEPALIVE.type)) {
                //心跳類型的消息
                log.info("收到來自channel爲[" + currentChannel + "]的心跳包");
            }

        }
        //二進制消息
        if (msg instanceof BinaryWebSocketFrame) {
            System.out.println("收到二進制消息:" + ((BinaryWebSocketFrame) msg).content().readableBytes());
            BinaryWebSocketFrame binaryWebSocketFrame = new BinaryWebSocketFrame(Unpooled.buffer().writeBytes("hello".getBytes()));
            //給客戶端發送的消息
            ctx.channel().writeAndFlush(binaryWebSocketFrame);
        }
        //ping消息
        if (msg instanceof PongWebSocketFrame) {
            System.out.println("客戶端ping成功");
        }
        //關閉消息
        if (msg instanceof CloseWebSocketFrame) {
            System.out.println("客戶端關閉,通道關閉");
            Channel channel = ctx.channel();
            channel.close();
        }

    }

    /**
     * Handler活躍狀態,表示連接成功
     * 當客戶端連接服務端之後(打開連接)
     * 獲取客戶端的channel,並且放到ChannelGroup中去進行管理
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerAdded ( ChannelHandlerContext ctx ) throws Exception {
        System.out.println("與客戶端連接成功");
        channelGroup.add(ctx.channel());
    }

    /**
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerRemoved ( ChannelHandlerContext ctx ) throws Exception {
        //當觸發handlerRemoved,ChannelGroup會自動移除對應的客戶端的channel
        //所以下面這條語句可不寫
//        clients.remove(ctx.channel());
        log.info("客戶端斷開,channel對應的長id爲:" + ctx.channel().id().asLongText());
        log.info("客戶端斷開,channel對應的短id爲:" + ctx.channel().id().asShortText());
    }

    /**
     * 異常處理
     *
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught ( ChannelHandlerContext ctx, Throwable cause ) throws Exception {
        System.out.println("連接異常:" + cause.getMessage());
        cause.printStackTrace();
        ctx.channel().close();
        channelGroup.remove(ctx.channel());
    }

    @Override
    public void userEventTriggered ( ChannelHandlerContext ctx, Object evt ) throws Exception {
        //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關閉前,用戶數量爲:" + channelGroup.size());
                //關閉無用的channel,以防資源浪費
                ctx.channel().close();
                log.info("channel關閉後,用戶數量爲:" + channelGroup.size());
            }

        }
    }

    /**
     * 給指定用戶發內容
     * 後續可以掉這個方法推送消息給客戶端
     */
    public void sendMessage ( Integer toUserId, String message ) {
        Channel channel = UserChannelRel.get(toUserId);
        channel.writeAndFlush(new TextWebSocketFrame(message));
    }

    /**
     * 羣發消息
     */
    public void sendMessageAll ( String message ) {
        channelGroup.writeAndFlush(new TextWebSocketFrame(message));
    }


}
1.JSON處理

接受客戶端的消息都是json數據類型的,這裏採用的json處理使用的是Hutool工具包(完善並且輕量級的Java工具包)
如何使用?
直接引入依賴即可

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.3.5</version>
</dependency>

詳細使用請參考官網:https://hutool.cn/docs/

2.TextWebSocketFrame: 在netty中,用於爲websocket專門處理文本的對象,frame是消息的載體
3.SimpleChannelInboundHandler<Object>中的Object意味可以接收任意類型的消息。
4.ChatTypeVerificationUtil主要用於驗證消息類型(比如文本、圖片、語音)等
public class ChatTypeVerificationUtil {

    /**
     * 功能描述:枚舉:聊天信息的類型
     * @author RenShiWei
     * Date: 2020/2/6 15:58
     */
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public enum ChatMessageTypeEnum {
        /**文本*/
        TEXT("text"),
        /**圖片*/
        IMAGE("image"),
        /**音頻*/
        VOICE("voice"),
        /**心跳包*/
        HEART("heart"),
        ;
        private String chatType;
    }

    /**
     * 功能描述:
     * @param chatType 預判斷類型
     * @return boolean
     */
    public static boolean verifyChatType(String chatType) {
        //循環枚舉
        for (ChatMessageTypeEnum airlineTypeEnum : ChatMessageTypeEnum.values()) {
            if (StringUtils.isNotBlank(chatType)&&chatType.equals(airlineTypeEnum.getChatType())){
                return true;
            }
        }
        return false;
    }

}

8.在SpringBoot啓動時,啓動Netty整合的websocket服務

啓動類實現CommandLineRunner 接口,重寫run方法,用來在項目啓動時預加載資源

/**
 * 聲明CommandLineRunner接口,實現run方法,就能給啓動項目同時啓動netty服務
 */
@SpringBootApplication
public class WebsocketApplication implements CommandLineRunner {

    /** 注入netty整合websocket的服務  CommandLineRunner */
    @Autowired
    private WebSocketNettyServer webSocketNettyServer;

   public static void main(String[] args) throws InterruptedException {
      SpringApplication.run(WebsocketApplication.class, args);
   }

   /**
     *聲明CommandLineRunner接口,實現run方法,就能給啓動項目同時啓動netty服務
     */
    @Override
    public void run ( String... args ) throws Exception {
        webSocketNettyServer.run();
    }
}

在application.yml配置netty的啓動端口

netty:
  port: 10101

連接netty整合的websocket路徑:ws://127.0.0.1:10101/ws

可通過在線websocket進行測試:http://www.easyswoole.com/wstool.html

在這裏插入圖片描述

前端使用

連接地址

前端連接websocket地址:ws://127.0.0.1:10101/ws
10101爲yml文件自定義的端口(可以自定義,但不能與項目端口重複)

第一次連接或者重連websocket

第一次連接或者重連websocket必鬚髮送指定的json消息類型
例如:

{
  "fromUserId": "1",
  "action":"1"
}

fromUserId爲連接websocket的用戶id
action爲後端定義的消息動作(1代表的是首次連接或者重連)。

客戶端發送的消息類型

{
"questionId": "113",
"chatMessageType": "text",
"content": "01用戶發送消息",
"fromUserId": "1",
"toUserId": "2",
"action":"2"
} 

questionIdchatMessageType爲業務需求,暫時可以忽略
content爲發送消息的內容
fromUserId爲發送方的用戶id
toUserId爲接受方的用戶id
action爲後端定義的消息動作(2代表的是聊天消息)。

心跳包

很多時候,服務器需要在一定的時間段內知道客戶端是否還在線,所以可以採用客戶端定期給服務器發送心跳數據的方式。

{
  "fromUserId": "1",
  "action":"3"
}

fromUserId爲發送方的用戶id
action爲後端定義的消息動作(3代表的是心跳包消息)。

注意:action可以定義成常量,與後端對應,防止出錯,也方便維護。

前端實現

具體的前端實現,略。可參考上一篇文章,一般需要根據具體的業務邏輯來寫。

總結

  1. 現在可以實現點對點聊天,即客戶端與客戶端通信,但是隻是實現了最基礎的聊天功能,並不是很完善。
  2. 一般都需要將聊天的消息存儲在數據庫當中,保存聊天記錄。但是聊天的業務一般比較頻繁,如果每條消息都存儲在數據庫,會給數據庫造成很大的壓力。所以一般採用的方式都是採用redis緩存消息,等到積累到一定的程度,然後在將消息統一存儲進數據庫。
  3. 現在客戶端發送消息只能是對方客戶端也連接websocket(即在線狀態下)纔可以實現通訊,如何對方離線,則不行,缺少對離線消息的處理。

下一篇博客將總結如何將客戶端通信的消息緩存進redis,並達到一定的條件下存儲進mysql
SpringBoot+Netty整合websocket(三)——客戶端聊天消息存儲到redis和MySQL,並實現離線消息的處理

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