前言

上篇文章我们用STOMP子协议实现了在线群聊和一对一聊天室等功能,本篇我们继续WebSocket这个话题,这次我们换个实现维度:用原生的WebSocket来实现,看看这两者在实现上的差别有多大。

image.png


实战WebSocket的要点

一、WebSocket重要属性

属性

备注

Socket.readyState

只读属性 readyState 表示连接状态,可以是以下值:

0 - 表示连接尚未建立。

1 - 表示连接已建立,可以进行通信。

2 - 表示连接正在进行关闭。

3 - 表示连接已经关闭或者连接不能打开。

Socket.bufferedAmount

只读属性 bufferedAmount 已被 send() 放入正在队列中等待传输,但是还没有发出的 UTF-8 文本字节数。

二、WebSocket核心事件

事件

事件处理程序

备注

open

Socket.onopen

连接建立时触发

message

Socket.onmessage

客户端接收服务端数据时触发

error

Socket.onerror

通信发生错误时触发

close

Socket.onclose

连接关闭时触发

三、WebSocket核心方法

方法

备注

Socket.send()

使用连接发送数据

Socket.close()

连接关闭


代码设计实现

一、服务端部分

/**
*
@author andychen https://blog.51cto.com/14815984
*
@description:WebSocket配置
*/
@Configuration
public class WebSocketConfig {
   /**
    * 注册并开启WebSocket
    *
@return
   
*/
   
@Bean
   
public ServerEndpointExporter serverEndpointExporter(){
       return new ServerEndpointExporter();
   
}
}
/**
*
@author andychen https://blog.51cto.com/14815984
*
@description:WebSocket通信业务类
*/
@ServerEndpoint("/ws/server")
@Component
public class WebSocketController {
   private static final Logger log = LoggerFactory.getLogger(WebSocketController.class);
   
/**
    * 服务端连接计数器
    */
   
private static final AtomicInteger counter = new AtomicInteger(0);
   
/**
    * 定义客户端会话安全容器
    * 缓存客户端会话对象(正式环境,这里可以直接做分布式缓存)
    */
   
private static final CopyOnWriteArraySet<Session> sessionContainer = new CopyOnWriteArraySet<>();
   
/**
    * 定义客户端会话和用户身份映射安全容器
    */
   
private static final Map<String,String> sessionMap = new ConcurrentHashMap<>();
   
/**
    * 消息分隔字符窜
    */
   
private static final String MSG_SPLIT_STR = "@#@";
   
/**
    * 消息角色
    */
   
private static final String[] MSG_ROLES = {"sender","recevier"};

   
/**
    * WebSocket连接打开事件
    *
@param session 客户端连接会话
    */
   
@OnOpen
   
public void open(Session session){
       //缓存会话
       
sessionContainer.add(session);
       
//会话Id
       
String sessionId = session.getId();
       if
(!sessionMap.containsKey(sessionId)){
           String receiver = this.getRecevier(session);
           boolean
isMass = (null == receiver);
           
//消息用户:群聊为发送者,单聊时为发送者和接收者
           
String usrInfo = parseMsgParameter(session, MSG_ROLES[0]);
           if
(isMass){
               sessionMap.put(sessionId, usrInfo);
           
}else{
               usrInfo += MSG_SPLIT_STR+receiver;
               
sessionMap.put(usrInfo, sessionId);
           
}

           //发送新用户加入消息
           
if(isMass){
               sendMass("系统消息"+MSG_SPLIT_STR+"用户["+usrInfo+"]加入群聊");
           
}
           log.info("会话[{}]加入,当前连接数为:{}", sessionId, counter.incrementAndGet());
       
}
   }

   /**
    * 接收客户端消息事件
    *
@param message 文本消息(也支持对象、二进制Buffer)
    *
@param session 客户端连接会话
    */
   
@OnMessage
   
public void accept(String message, Session session){
       String sender = null;
       
String sessionId = session.getId();
       
String sessionId2 = null;
       
String msg =null;
       
String recevier = getRecevier(session);
       if
(null == recevier){
           msg = sessionMap.get(sessionId)+MSG_SPLIT_STR+message;
           
sendMass(msg);
       
}else{
           sender = parseMsgParameter(session, MSG_ROLES[0]);
           
msg = sender+MSG_SPLIT_STR+message;
           
//发送者sessionId
           
sessionId = sender+MSG_SPLIT_STR+recevier;
           
sessionId = sessionMap.get(sessionId);
           
//接收者sessionId
           
sessionId2 = recevier+MSG_SPLIT_STR+sender;
           
sessionId2 = sessionMap.get(sessionId2);
           
sendSingle(sessionId, sessionId2, msg);
       
}
       log.info("已接收客户端[{}]消息:{},请求地址:{}", sessionId, message, session.getRequestURI().toString());
   
}

   /**
    * 连接关闭事件
    *
@param session 客户端连接会话
    */
   
@OnClose
   
public void close(Session session){
       String sessionId = session.getId();
       
sessionContainer.remove(session);
       
String recevier =getRecevier(session);
       if
(null == recevier){
           //群聊发送退群消息
           
String sender = sessionMap.get(sessionId);
           
sessionMap.remove(sessionId);
           
sendMass("系统消息"+MSG_SPLIT_STR+"用户["+sender+"]退出群聊");
       
}else{
           sessionId = parseMsgParameter(session, MSG_ROLES[0])+MSG_SPLIT_STR+recevier;
           
sessionId = sessionMap.get(sessionId);
           
sessionMap.remove(sessionId);
       
}
       log.info("会话[{}]关闭连接,当前连接数为:{}", sessionId, counter.decrementAndGet());
   
}

   /**
    * 连接发生错误事件
    *
@param session 客户端连接会话
    *
@param error 错误对象
    */
   
@OnError
   
public void error(Session session, Throwable error){
       log.error("连接发生错误:{}, \n\n客户端会话ID[{}],请求地址:{}", error.getMessage(),
                                 
session.getId(), session.getRequestURI().toString());
       
error.printStackTrace();
   
}

   /**
    * 是否单聊
    *
@param session 客户会话id
    *
@return
   
*/
   
private String getRecevier(Session session){
       return parseMsgParameter(session, MSG_ROLES[1]);
   
}
   /**
    * 解析消息参数
    *
@param session 客户端会话
    *
@param name 参数名称
    *
@return
   
*/
   
private static String parseMsgParameter(Session session, String name){
       //获取会话中包含的参数信息
       
Map<String, List<String>> params = session.getRequestParameterMap();
       if
(params.containsKey(name)){
           return params.get(name).get(0);
       
}
       return null;
   
}
   /**
    * 发送消息
    *
@param session 客户端会话
    *
@param msg 消息内容
    */
   
private static boolean send(Session session, String msg){
       try {
           //异步转发文本消息(也可发送消息对象,二进制流等)
           
session.getAsyncRemote().sendText(msg);
           return true;
       
} catch (Exception e) {
           log.error("消息发送失败:{}", e.getMessage());
           
e.printStackTrace();
       
}
       return false;
   
}

   /**
    * 群发消息
    *
@param msg 消息内容
    */
   
private static void sendMass(String msg){
       for (Session session : sessionContainer){
           if(session.isOpen()){
               //发送
               
send(session, msg);
           
}
       }
   }

   /**
    * 发送聊消息
    *
@param senderSid 发送者会话id
    *
@param recevSid 接收者会话id
    *
@param msg 消息内容
    */
   
private static void sendSingle(String senderSid, String recevSid, String msg){
       String id = null;
       int
count = 0;
       for
(Session s : sessionContainer) {
           id = s.getId();
           if
(senderSid.equals(id)) {
               count++;
               
send(s, msg);
           
}
           if (recevSid.equals(id)) {
               count++;
               
send(s, msg);
           
}
           if(2 == count){break;}
       }
       if(2 > count){
           log.warn("未找到指定会话[ID: {}或{}]", senderSid, recevSid);
       
}
   }
}

二、客户端部分

<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org">
<head>
   <meta
charset="UTF-8">
   <meta
name="aplus-terminal" content="1">
   <meta
name="apple-mobile-web-app-title" content="">
   <meta
name="apple-mobile-web-app-capable" content="yes">
   <meta
name="apple-mobile-web-app-status-bar-style" content="black-translucent">
   <meta
name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
   <meta
name="format-detection" content="telephone=no, address=no">
   <title>
WebSocket在线聊天室</title>
   <link
rel="stylesheet" th:href="@{/css/chatroom.css}" type="text/css"/>
</head>
<body>
   <div>
       <div
class="window_frame">
           <span><e
style="font-weight: bold;">选择你的网名:</e>
           <select
id="selectSender">
               <option
value="">请选择..</option>
               <option
value="zhangsan">zhangsan</option>
               <option
value="lisi">lisi</option>
               <option
value="wangwu">wangwu</option>
               <option
value="zhaoliu">zhaoliu</option>
               <option
value="chenqi">chenqi</option>
               <option
value="qianba">qianba</option>
           </select>
           <e
style="font-weight: bold;">群聊:</e>
           </span>
           <div
class="chatWindow">
               <section
class="chatRecord">
                   <div
id="mass_div" class="mobile-page"></div>
               </section>
               <section
class="sendWindow">
                   <textarea
name="txtContent" id="txtContent"  class="send_box"></textarea>
                   <input
type="button" id="btnSend" value="发送" class="send_btn"/>
               </section>
           </div>
       </div>
       <div
class="window_frame">
           <span><e
style="font-weight: bold;">选择聊天的对象:</e>
               <select
id="selectRecevier">
                   <option
value="">请选择..</option>
                   <option
value="zhangsan">zhangsan</option>
                   <option
value="lisi">lisi</option>
                   <option
value="wangwu">wangwu</option>
                   <option
value="zhaoliu">zhaoliu</option>
                   <option
value="chenqi">chenqi</option>
                   <option
value="qianba">qianba</option>
               </select>
               <e
style="font-weight: bold;">单聊:</e>
           </span>
           <div
class="chatWindow">
               <section
class="chatRecord">
                   <div
id="single_div" class="mobile-page"></div>
               </section>
               <section
class="sendWindow">
                   <textarea
name="txtContent2" id="txtContent2" class="send_box"></textarea>
                   <input
type="button" id="btnSend2" value="发送" class="send_btn"/>
               </section>
           </div>
       </div>
   </div>
   <script
type="text/javascript" th:src="@{/js/jquery-1.9.1.min.js}"></script>
   <script
type="text/javascript" th:src="@{/js/wschatroom.js}"></script>
</body>
</html>
/**
* WS-WebSocket在线聊天室类
* 负责实现群聊和单聊相关的聊天业务
*/
WsChatRoom = {
   socket: null,
   
sys_msg_tag:'系统消息',
   
msg_split_str:'@#@',//消息分隔
   
isMass: true //是否群发
};
/**
* 选择发送者
*/
WsChatRoom.selectSender = function () {
   let sender = $("#selectSender").val();
   
if("" === sender){
       alert("请选择你的聊天身份!");
       
return;
   
}
   WsChatRoom.switchUser(sender);
};
/**
* 选择接收者
*/
WsChatRoom.selectRecevier = function () {
   let sender = $("#selectSender").val();
   
if("" === sender){
       alert("请选择你的聊天身份!");
       
return;
   
}
   let recevier = $("#selectRecevier").val();
   
if("" === recevier){
       alert("请选择对方的聊天身份!");
       
return;
   
}
   WsChatRoom.switchUser(sender, recevier);
};
/**
* 切换用户
*/
WsChatRoom.switchUser = function (sender, recevier) {
   //先关闭之前连接
   
WsChatRoom.close();
   
//连接服务器端
   
let url = "ws://localhost:8089/ws/server?sender="+sender;
   
if(recevier && null !== recevier && "" !== recevier){
       url += ("&recevier="+recevier);
       
WsChatRoom.isMass = false;
   
}else{
       WsChatRoom.isMass = true;
   
}
   WsChatRoom.socket = new WebSocket(url);
   
//打开连接事件
   
WsChatRoom.socket.onopen = function (data) {
       console.log("Socket连接已建立");
   
}
   //接收消息事件
   
WsChatRoom.socket.onmessage = function (msg) {
       let aData = msg.data.split(WsChatRoom.msg_split_str);
       
let sender = aData[0];
       
let content = aData[1];
       
let container = $("#mass_div");
       
let current = $("#selectSender").val();
       
if(!WsChatRoom.isMass){
           container = $("#single_div");
       
}
       //当前用户发的消息 WsChatRoom.isMass &&
       
if(current === sender && WsChatRoom.sys_msg_tag !== sender){
           container.append("<div class='user-group'>" +
               "          <div class='user-msg'>" +
               "                <span class='user-reply'>"+content+"</span>" +
               "                <i class='triangle-user'></i>" +
               "          </div><span style='padding-top:10px;'>" +sender+
               "     </span></div>");
       
}
       else{
           //系统消息
           
if(WsChatRoom.sys_msg_tag === sender){
               $("#mass_div").append("     <div class='admin-group'><span class='msg_head'>"+
                   sender+
                   "</span><div class='admin-msg'>"+
                   "    <i class='triangle-sys'></i>"+
                   "    <span class='sys-reply'>"+content+"</span>"+
                   "</div>"+
                   "</div>");
           
}else{
               container.append("     <div class='admin-group'><span class='msg_head'>"+
                   sender+
                   "</span><div class='admin-msg'>"+
                   "    <i class='triangle-admin'></i>"+
                   "    <span class='admin-reply'>"+content+"</span>"+
                   "</div>"+
                   "</div>");
           
}

       }
   }
   //关闭连接事件
   
WsChatRoom.socket.onclose = function (data) {
       console.log("Socket连接已关闭");
   
}
   //连接异常事件
   
WsChatRoom.socket.onerror = function (e) {
       console.log("Socket连接出错:"+e);
   
}
};
/**
* 发送消息
*/
WsChatRoom.send = function () {
   let sender = $("#selectSender").val();
   
if("" === sender){
       alert("请选择你的聊天身份!");
       
return;
   
}
   let content = "";
   
if(WsChatRoom.isMass){
       content = $("#txtContent").val().trim();
   
}else{
       content = $("#txtContent2").val().trim();
   
}
   if("" === content){
       alert("发送的消息不能为空!");
       
return;
   
}
   if(!WsChatRoom.isMass && "" === $("#selectRecevier").val()){
       alert("请选择对方的聊天身份!");
       
return;
   
}
   //发送消息
   
WsChatRoom.socket.send(content);
   
if(WsChatRoom.isMass){
       $("#txtContent").val("");
   
}else{
       $("#txtContent2").val("");
   
}
};
/**
* 关闭连接
*/
WsChatRoom.close = function(){
   if(null != WsChatRoom.socket){
       WsChatRoom.socket.close();
       
console.log("连接已关闭");
   
}
}

/**
* 窗口关闭时,关闭连接
*/
window.onload = function(){
   WsChatRoom.close();
}
/**
* 页面加载完毕事件
*/
$(function () {
   //注册事件
   
$("#selectSender").change(function () {
       WsChatRoom.selectSender();
   
});
   
$("#selectRecevier").change(function () {
       WsChatRoom.selectRecevier();
   
});
   
$("#btnSend").click(function () {
       //发送时为单聊,这里需要切换
       
if(!WsChatRoom.isMass){
           WsChatRoom.selectSender();
       
}
       WsChatRoom.send();
   
});
   
$("#btnSend2").click(function () {
       //发送时为群聊,这里需要切换
       
if(WsChatRoom.isMass){
           WsChatRoom.selectRecevier();
       
}
       WsChatRoom.send();
   
});
});

结果验证

一、群聊效果

image.png

image.png

二、单聊效果

image.png


总结

从实现角度原生HTML5的WebSocket在客户端比STOMP协议的实现方式要简洁清晰一些,不要额外依赖第三放的组件或插件;而服务器端的实现比STOMP协议实现上略为复杂一点(需要对客户端Session进行管理)。从功能维度讲,原生WebSocket不只支持文本数据传输,同时也支持对象和二进制流等传输方式,功能更强大;而STOMP只支持文本消息。从通信效率上看,STOMP协议的实现服务器端延迟更少(实现更简单高效)。这两种方式,我们可根据项目的具体业务场景选择使用。后面我们将看看Netty在实现这类通信实时性要求较高场景的表现,请继续关注!