前提
瞭解如何實現客戶端和服務端通訊
上一篇博客——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"
}
questionId
和chatMessageType
爲業務需求,暫時可以忽略
content
爲發送消息的內容
fromUserId
爲發送方的用戶id
toUserId
爲接受方的用戶id
action
爲後端定義的消息動作(2代表的是聊天消息)。
心跳包
很多時候,服務器需要在一定的時間段內知道客戶端是否還在線,所以可以採用客戶端定期給服務器發送心跳數據的方式。
{
"fromUserId": "1",
"action":"3"
}
fromUserId
爲發送方的用戶id
action
爲後端定義的消息動作(3代表的是心跳包消息)。
注意:action
可以定義成常量,與後端對應,防止出錯,也方便維護。
前端實現
具體的前端實現,略。可參考上一篇文章,一般需要根據具體的業務邏輯來寫。
總結
- 現在可以實現點對點聊天,即客戶端與客戶端通信,但是隻是實現了最基礎的聊天功能,並不是很完善。
- 一般都需要將聊天的消息存儲在數據庫當中,保存聊天記錄。但是聊天的業務一般比較頻繁,如果每條消息都存儲在數據庫,會給數據庫造成很大的壓力。所以一般採用的方式都是採用redis緩存消息,等到積累到一定的程度,然後在將消息統一存儲進數據庫。
- 現在客戶端發送消息只能是對方客戶端也連接websocket(即在線狀態下)纔可以實現通訊,如何對方離線,則不行,缺少對離線消息的處理。
下一篇博客將總結如何將客戶端通信的消息緩存進redis,並達到一定的條件下存儲進mysql
SpringBoot+Netty整合websocket(三)——客戶端聊天消息存儲到redis和MySQL,並實現離線消息的處理