一、介绍
WebSocket是一种在单个TCP连接上进行全双工通信的协议。在发布/订阅模式中,轮询方式需要不断地发送Http请求,其中每一个数据包都会携带头部信息,这样会浪费很多的带宽。相比之下,WebSocket不仅能够实时进行通讯,还极大的节省了服务器资源的带宽。
WebSocket连接以HTTP请求开始,使用HTTP Upgrade
标头进行升级。当需要低延迟、高频率的大量信息交互时可以考虑使用WebSocket,否则可以简单地使用轮询方式处理。
二、简单消息交互
导入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.31</version> </dependency>
消息实体类:
@Data @AllArgsConstructor @NoArgsConstructor public class Message { //消息id private Long id; //消息发送方 private String from; //消息接收方 private String to; //消息内容 private String content; //消息发送时间 private LocalDateTime time; }
创建WebSocket处理器有两种方式:
1.代码方式
WebSocket配置类:
@Configuration @EnableWebSocket //开启WebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatWebSocketHandler; public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler, MyHandshakeInterceptor myHandshakeInterceptor) { this.chatWebSocketHandler = chatWebSocketHandler; } /** * 注册 WebSocket 处理器 */ @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, "/chat") //对于"/chat"路径 配置WebSocket处理器 .setAllowedOriginPatterns("*"); //允许跨域 } }
/** * 自定义WebSocket处理器 */ @Slf4j @Component public class ChatWebSocketHandler implements WebSocketHandler { //存储所有在线用户Session key是用户名 private static final Map<String, WebSocketSession> SESSION_MAP = new ConcurrentHashMap<>(); /** * 建立连接后执行 */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { log.info("建立连接成功..."); Object username = session.getAttributes().get("username"); if (ObjectUtil.isNotNull(username)) { SESSION_MAP.put(username.toString(), session); } } /** * 处理客户端发送的消息 */ @Override public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { int length = message.getPayloadLength(); if (length == 0) return; sendToUser(message); } /** * 处理异常 */ @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("连接出现异常:{}", exception.getMessage()); } /** * 关闭连接或者出现异常后执行 */ @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { log.info("断开连接成功..."); Object username = session.getAttributes().get("username"); if (ObjectUtil.isNotNull(username)) { SESSION_MAP.remove(username.toString()); } } /** * 是否支持切分消息 */ @Override public boolean supportsPartialMessages() { return false; } /** * 发送消息给特定用户 * * @param message 消息 * @throws IOException 异常 */ public void sendToUser(WebSocketMessage<?> message) throws IOException { Object payload = message.getPayload(); Message m = JSON.parseObject(payload.toString(), Message.class); WebSocketSession webSocketSession = SESSION_MAP.get(m.getFrom()); if (ObjectUtil.isNotNull(webSocketSession) && webSocketSession.isOpen()) { webSocketSession.sendMessage(message); } } /** * 发送消息给所有用户 * @param message 消息 */ public void sendToAll(WebSocketMessage<?> message) { try { for (WebSocketSession session : SESSION_MAP.values()) { if (ObjectUtil.isNotNull(session) && session.isOpen()) { session.sendMessage(message); log.info("发送消息成功!"); } } } catch (IOException e) { log.error("发送消息失败, 出现异常:{}", e.getMessage()); } } }
输入连接地址ws://127.0.0.1:8080/chat
,进行测试:WebSocket在线测试_在线模拟websocket请求工具
源码分析:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented //将DelegatingWebSocketConfiguration类型的Bean加入容器 @Import(DelegatingWebSocketConfiguration.class) public @interface EnableWebSocket { }
@Configuration(proxyBeanMethods = false) public class DelegatingWebSocketConfiguration extends WebSocketConfigurationSupport { private final List<WebSocketConfigurer> configurers = new ArrayList<>(); //自动装配 WebSocketConfigurer类型的Bean 加入configurers集合中 @Autowired(required = false) public void setConfigurers(List<WebSocketConfigurer> configurers) { if (!CollectionUtils.isEmpty(configurers)) { this.configurers.addAll(configurers); } } //注册每一个WebSocket处理器 @Override protected void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { for (WebSocketConfigurer configurer : this.configurers) { configurer.registerWebSocketHandlers(registry); } } }
2.注解方式
相关注解
注解 | 解释 |
---|---|
@ServerEndpoint | 标记该类为一个WebSocket处理器 |
@OnOpen | 标记方法在WebSocket连接打开时触发 |
@OnClose | 标记方法在WebSocket连接关闭时触发 |
@OnMessage | 标记方法在接受到WebSocket消息时触发 |
@OnError | 标记方法在WebSocket连接出现错误时触发 |
@PathParam | 获取@ServerEndpoint注解请求路径中的模版变量 |
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface ServerEndpoint { //WebSocket请求路径 String value(); //WebSocket 自协议 一般用于传递请求头用于鉴权 String[] subprotocols() default {}; //消息加密器 Class<? extends Decoder>[] decoders() default {}; //消息解密器 Class<? extends Encoder>[] encoders() default {}; //WebSocket 服务器配置 public Class<? extends ServerEndpointConfig.Configurator> configurator() default ServerEndpointConfig.Configurator.class; }
WebSocket配置类:
@Configuration public class WebSocketConfig { /** * ServerEndpointExporter 会自动注册标记了@ServerEndpoint注解的WebSocket Server */ @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } }
@Slf4j @Component @ServerEndpoint(value = "/ws/{username}") public class WebSocketEndpoint { //存储所有在线用户Session key是用户名 private static final Map<String, Session> SESSION_MAP = new ConcurrentHashMap<>(); /** * 建立连接后执行 */ @OnOpen public void onOpen(Session session, @PathParam("username") String username) { SESSION_MAP.put(username, session); log.info("sessionId:{}, 建立连接成功...", session.getId()); } /** * 关闭连接或者出现异常后执行 */ @OnClose public void onClose(Session session, @PathParam("username") String username) { SESSION_MAP.remove(username); log.info("sessionId:{}, 断开连接成功...", session.getId()); } /** * 处理客户端发送的消息 */ @OnMessage public void onMessage(Session session, String message) throws IOException { log.info("客服端发送消息:{}", message); if (StrUtil.isBlank(message)) return; sendToUser(message); } /** * 处理异常 */ @OnError public void onError(Session session, Throwable exception) throws IOException { log.error("连接出现异常:{}", exception.getMessage()); } /** * 发送消息给特定用户 * * @param message 消息内容 */ public void sendToUser(String message) { try { Message m = JSON.parseObject(message, Message.class); String to = m.getTo(); Session session = SESSION_MAP.get(to); if (ObjectUtil.isNotNull(session) && session.isOpen()) { session.getBasicRemote().sendText(message); log.info("发送消息成功!"); } else { log.warn("@用户{}不在线!!!", to); } } catch (IOException e) { log.error("发送消息失败, 出现异常:{}", e.getMessage()); } } /** * 发送消息给所有用户 * * @param message 消息内容 */ public void sendToAll(String message) { log.info("服务端发送消息:{}", message); try { for (Session session : SESSION_MAP.values()) { if (ObjectUtil.isNotNull(session) && session.isOpen()) { session.getBasicRemote().sendText(message); log.info("发送消息成功!"); } } } catch (IOException e) { log.error("发送消息失败, 出现异常:{}", e.getMessage()); } } }
输入连接地址ws://127.0.0.1:8080/ws/1
,进行测试:WebSocket在线测试_在线模拟websocket请求工具
源码分析:
ServerEndpointExporter实现了Spring中Bean生命周期接口InitializingBean
、SmartInitializingSingleton
,在该类对象初始化后执行相应的操作
/** * ServerEndpointExporter类 */ @Override public void afterSingletonsInstantiated() { registerEndpoints(); } //注册WebSocket Endpoint protected void registerEndpoints() { Set<Class<?>> endpointClasses = new LinkedHashSet<>(); if (this.annotatedEndpointClasses != null) { endpointClasses.addAll(this.annotatedEndpointClasses); } ApplicationContext context = getApplicationContext(); if (context != null) { //通过容器获取标注了@ServerEndpoint注解的Bean名称(所以标注了@ServerEndpoint的类需要放入容器中) String[] endpointBeanNames = context.getBeanNamesForAnnotation(ServerEndpoint.class); for (String beanName : endpointBeanNames) { endpointClasses.add(context.getType(beanName)); } } for (Class<?> endpointClass : endpointClasses) { registerEndpoint(endpointClass); } if (context != null) { //通过容器获取ServerEndpointConfig类型的Bean Map<String, ServerEndpointConfig> endpointConfigMap = context.getBeansOfType(ServerEndpointConfig.class); for (ServerEndpointConfig endpointConfig : endpointConfigMap.values()) { registerEndpoint(endpointConfig); } } } //注册标注了@ServerEndpoint注解的WebSocket端点 private void registerEndpoint(Class<?> endpointClass) { ServerContainer serverContainer = getServerContainer(); Assert.state(serverContainer != null, "No ServerContainer set. Most likely the server's own WebSocket ServletContainerInitializer " + "has not run yet. Was the Spring ApplicationContext refreshed through a " + "org.springframework.web.context.ContextLoaderListener, " + "i.e. after the ServletContext has been fully initialized?"); try { if (logger.isDebugEnabled()) { logger.debug("Registering @ServerEndpoint class: " + endpointClass); } serverContainer.addEndpoint(endpointClass); } catch (DeploymentException ex) { throw new IllegalStateException("Failed to register @ServerEndpoint class: " + endpointClass, ex); } } //注册ServerEndpointConfig类型的WebSocket端点 private void registerEndpoint(ServerEndpointConfig endpointConfig) { ServerContainer serverContainer = getServerContainer(); Assert.state(serverContainer != null, "No ServerContainer set"); try { if (logger.isDebugEnabled()) { logger.debug("Registering ServerEndpointConfig: " + endpointConfig); } serverContainer.addEndpoint(endpointConfig); } catch (DeploymentException ex) { throw new IllegalStateException("Failed to register ServerEndpointConfig: " + endpointConfig, ex); } }
3.身份校验
添加依赖:
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.0.0</version> </dependency>
用户实体类:
@Data @AllArgsConstructor @NoArgsConstructor public class User { private String username; private String password; }
/** * JWT工具类 */ public class JwtUtil { private static final String signature = "qingsongxyz"; /** * 生成token header.payload.signature */ public static String getToken(Map<String, String> map) { Calendar instance = Calendar.getInstance(); instance.add(Calendar.DATE, 7); //设置过期时间7天 //创建jwt builder JWTCreator.Builder builder = JWT.create(); //payload map.forEach((k, v) -> { builder.withClaim(k, v); }); String token = builder.withExpiresAt(instance.getTime())//指定令牌的过期时间 .sign(Algorithm.HMAC256(signature));//签发算法 return token; } /** * 验证token */ public static void verify(String token) { JWT.require(Algorithm.HMAC256(signature)).build().verify(token); } /** * 获取token信息 */ public static String getUsername(String token) { DecodedJWT decode = JWT.decode(token); Claim uClaim = decode.getClaims().get("username"); return uClaim.asString(); } }
用户登录接口:
@Slf4j @RestController @RequestMapping("/user") public class UserController { //简单返回Jwt token @PostMapping("/login") public String login(@RequestBody User user){ HashMap<String, String> map = new HashMap<>(); map.put("username", user.getUsername()); return JwtUtil.getToken(map); } }
代码方式
/** * WebSocket配置类 */ @Configuration @EnableWebSocket //开启WebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatWebSocketHandler; private final MyHandshakeInterceptor myHandshakeInterceptor; public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler, MyHandshakeInterceptor myHandshakeInterceptor) { this.chatWebSocketHandler = chatWebSocketHandler; this.myHandshakeInterceptor = myHandshakeInterceptor; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, "/chat") .addInterceptors(myHandshakeInterceptor) //添加握手拦截器 .setAllowedOriginPatterns("*"); } }
/** * 握手拦截器 */ @Slf4j @Component public class MyHandshakeInterceptor implements HandshakeInterceptor { /** * 控制是否连接成功 */ @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { log.info("准备握手..."); //请求头Sec-WebSocket-Protocol中为前端传递的token信息 List<String> headers = request.getHeaders().get("Sec-WebSocket-Protocol"); if (ObjectUtil.isEmpty(headers)) { log.error("用户未认证, 无法连接!!!"); return false; } try { String token = headers.get(0); String username = JwtUtil.getUsername(token); log.info("用户名:{}", username); //将用户名存入WebSocket Session中方便后面使用 attributes.put("username", username); } catch (Exception e) { log.error("用户token有误, 无法连接!!!"); return false; } log.info("开始握手..."); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { log.info("结束握手..."); } }
注解方式
@Slf4j @Component //Endpoint配置 configurator @ServerEndpoint(value = "/ws/{username}", configurator = MyAuthConfigurator.class) public class WebSocketEndpoint { ... }
@Slf4j public class MyAuthConfigurator extends ServerEndpointConfig.Configurator { /** * 控制是否能够连接成功(身份校验、跨域等) */ @Override public boolean checkOrigin(String originHeaderValue) { ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (ObjectUtil.isNull(servletRequestAttributes)) { return false; } HttpServletRequest request = servletRequestAttributes.getRequest(); //请求头Sec-WebSocket-Protocol中为前端传递的token信息 String token = request.getHeader("Sec-WebSocket-Protocol"); if (StrUtil.isBlank(token)) { log.error("用户未认证, 无法连接!!!"); return false; } try { JwtUtil.verify(token); } catch (Exception e) { log.error("用户token有误, 无法连接!!!"); return false; } return true; } /** * 当前端传递非空的Sec-WebSocket-Protocol请求头时, 需要将其返回给前端 */ @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { //List<String> headers = request.getHeaders().get("Sec-WebSocket-Protocol"); //response.getHeaders().put("Sec-WebSocket-Protocol", headers); super.modifyHandshake(sec, request, response); } }
前端代码
登录页:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>登录</title> <style> #box { margin: 200px 500px; text-align: center; } #box div { margin-bottom: 20px; } div.username input { margin-left: 5px; } div.password input { margin-left: 20px; } a { text-decoration: none; } button { width: 220px; } </style> </head> <body> <div id="box"> <div class="username"> 用户名:<input id="username" type="text" name="username"> </div> <div class="password"> 密码:<input id="password" type="text" name="password"> </div> <button type="submit" οnclick="login()">登录</button> </div> </body> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.3.6/axios.min.js"></script> <script type="text/javascript"> function login() { var username = document.getElementById("username").value; var password = document.getElementById("password").value; const json = `{"username": "${username}", "password":"${password}"}` axios.post("/user/login", json, { headers: { "Content-Type": "application/json;charset=utf-8" } } ).then( res => { const token = res.data; console.log(token) localStorage.setItem("username", username); localStorage.setItem("token", token); alert("欢迎回来!"); window.location.href = "/chat.html"; }, err => { console.log(err); } ) } </script> </html>
聊天页:
<html> <head> <meta charset="UTF-8"> <title>用户间点对点发消息</title> <script src="https://code.jquery.com/jquery-3.2.0.min.js" integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script> <style> #image { visibility: hidden; } .iconfont { font-size: 30px; color: rgb(104, 182, 255); } .iconfont:hover { outline: red; transform: translateX(50px); border: 2px solid rgb(240, 240, 240); border-radius: 5%; box-shadow: 3px 3px 3px rgba(85, 85, 85, .3); } .iconfont:active { color: rgb(125, 166, 255); } img { width: 100px; height: 100px; } </style> </head> <body οnlοad="disconnect()"> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div> <div> <labal>连接用户</labal> <span id="username"></span> <button id="connect" οnclick="connect();">Connect</button> </div> <div> <labal>取消连接</labal> <button class="disconnect" disabled="disabled" οnclick="disconnect();">Disconnect</button> </div> <div id="conversationDiv"> <labal>发送消息</labal> <div> <labal>内容</labal> <input type="text" id="content"/> </div> <div> <labal>发给谁</labal> <input type="text" id="to"/> </div> <button id="sendMsg" class="disconnect" disabled="disabled" οnclick="sendMsg();">Send</button> </div> <div> <labal>接收到的消息:</labal> <p id="responseData"></p> </div> </div> <script type="text/javascript"> var websocket = null; var username = localStorage.getItem("username"); var token = localStorage.getItem("token"); const span = document.getElementById("username"); span.innerHTML = username; window.onbeforeunload = function () { if (websocket !== null) websocket.close(); } function setConnected(connected) { document.getElementById("connect").disabled = connected; var elements = document.getElementsByClassName("disconnect"); for (let i = 0; i < elements.length; i++) { elements[i].disabled = !connected; } $("#response").html(); } function connect() { //1.WebSocket代码方式连接url // websocket = new WebSocket(`ws://localhost:8080/chat`, token); //2.WebSocket注解方式连接url websocket = new WebSocket(`ws://localhost:8080/ws/${username}`, token); websocket.onopen = function () { setConnected(true); console.log("连接成功...") } websocket.onmessage = function (e) { const message = e.data; const m = JSON.parse(message); const responseData = document.getElementById('responseData'); console.log(`接受到 ${m.from} 的消息\"${m.content}\"成功...`); const p = document.createElement('p'); p.style.wordWrap = 'break-word'; p.appendChild(document.createTextNode(m.content)); responseData.appendChild(p); } websocket.onclose = function () { setConnected(false); console.log("连接关闭...") } websocket.onerror = function (err) { setConnected(false); websocket.close(); console.log("连接异常...", err) } } function disconnect() { if (websocket !== null) websocket.close(); } function sendMsg() { const to = document.getElementById("to").value; const content = document.getElementById("content").value; const img = document.getElementsByClassName('show')[0].src; if (to.trim() === "" || content.trim() === "") { alert("请输入消息信息和接收用户!!!"); return; } const json = `{"from": "${username}", "to": "${to.trim()}", "content": "${content.trim()}", "messageType": "Text"}` websocket.send(json) console.log(`${username} 发送消息\"${content}\"给 ${to} 成功...`) } </script> </body> </html>
4.图片消息处理
修改消息实体类:
@Data @AllArgsConstructor @NoArgsConstructor public class Message { //消息id private Long id; //消息发送方 private String from; //消息接收方 private String to; //消息内容 private String content; //消息类型 private MessageType messageType; //消息发送时间 private LocalDateTime time; //枚举 public enum MessageType { Text, //文本消息 Binary //二进制消息(图片、音视频) } }
修改聊天页面:
<html> <head> <meta charset="UTF-8"> <title>用户间点对点发消息</title> <script src="https://code.jquery.com/jquery-3.2.0.min.js" integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script> <style> #image { visibility: hidden; } .iconfont { font-size: 30px; color: rgb(104, 182, 255); } .iconfont:hover { outline: red; transform: translateX(50px); border: 2px solid rgb(240, 240, 240); border-radius: 5%; box-shadow: 3px 3px 3px rgba(85, 85, 85, .3); } .iconfont:active { color: rgb(125, 166, 255); } img { width: 100px; height: 100px; } </style> </head> <body οnlοad="disconnect()"> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div> <div> <labal>连接用户</labal> <span id="username"></span> <button id="connect" οnclick="connect();">Connect</button> </div> <div> <labal>取消连接</labal> <button class="disconnect" disabled="disabled" οnclick="disconnect();">Disconnect</button> </div> <div id="conversationDiv"> <labal>发送消息</labal> <div> <labal>内容</labal> <input type="text" id="content"/> </div> <div> <label class="initial" for="image"><i class="iconfont icon-tianjiatupian_huaban"></i></label> <label class="cover" for="image"><img class="show"></label> <input type="file" οnchange="changeImg(this)" id="image" class="image_input" accept="image/png, image/jpeg, image/gif, image/jpg"/> </div> <div> <labal>发给谁</labal> <input type="text" id="to"/> </div> <button id="sendMsg" class="disconnect" disabled="disabled" οnclick="sendMsg();">Send</button> </div> <div> <labal>接收到的消息:</labal> <p id="responseData"></p> </div> </div> <script type="text/javascript"> var websocket = null; var username = localStorage.getItem("username"); var token = localStorage.getItem("token"); const span = document.getElementById("username"); span.innerHTML = username; window.onbeforeunload = function () { if (websocket !== null) websocket.close(); } function changeImg(object) { const reads = new FileReader(); const div = object.parentNode; const input = div.getElementsByClassName('image_input')[0]; const label_initial = div.getElementsByClassName("initial")[0]; label_initial.style.display = "none"; let f = input.files[0]; reads.readAsDataURL(f); reads.onload = function (e) { var img = div.getElementsByClassName('show')[0]; img.src = this.result; } } function setConnected(connected) { document.getElementById("connect").disabled = connected; var elements = document.getElementsByClassName("disconnect"); for (let i = 0; i < elements.length; i++) { elements[i].disabled = !connected; } $("#response").html(); } function connect() { // websocket = new WebSocket(`ws://localhost:8080/chat`, token); websocket = new WebSocket(`ws://localhost:8080/ws/${username}`, token); websocket.onopen = function () { setConnected(true); console.log("连接成功...") } websocket.onmessage = function (e) { const message = e.data; const m = JSON.parse(message); const responseData = document.getElementById('responseData'); if ("Text" === m.messageType) { console.log(`接受到 ${m.from} 的文本消息\"${m.content}\"成功...`); const p = document.createElement('p'); p.style.wordWrap = 'break-word'; p.appendChild(document.createTextNode(m.content)); responseData.appendChild(p); } if ("Binary" === m.messageType) { console.log(`接受到 ${m.from} 的二进制消息图片成功...`); const img = document.createElement('img'); img.style.wordWrap = 'break-word'; img.src = m.content; responseData.appendChild(img); } } websocket.onclose = function () { setConnected(false); console.log("连接关闭...") } websocket.onerror = function (err) { setConnected(false); websocket.close(); console.log("连接异常...", err) } } function disconnect() { if (websocket !== null) websocket.close(); } function sendMsg() { const to = document.getElementById("to").value; const content = document.getElementById("content").value; const img = document.getElementsByClassName('show')[0].src; if (to.trim() === "" || (content.trim() === "" && img === "")) { alert("请输入消息信息和接收用户!!!"); return; } if (content.trim() !== "") { const json = `{"from": "${username}", "to": "${to.trim()}", "content": "${content.trim()}", "messageType": "Text"}` websocket.send(json) console.log(`${username} 发送文本消息\"${content}\"给 ${to} 成功...`) } if (img !== "") { const json = `{"from": "${username}", "to": "${to.trim()}", "content": "${img}", "messageType": "Binary"}` websocket.send(json) console.log(`${username} 发送二进制消息图片给 ${to} 成功...`) } } </script> </body> </html>
注意:
前端将图片转换为Base64编码放入消息中进行传递,会使得消息更大,需要修改后端接受的消息大小
//注入自定义ServletServerContainerFactoryBean @Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(500 * 1024); // 设置文本消息最大bufferSize container.setMaxBinaryMessageBufferSize(500 * 1024); // 设置二进制消息最大bufferSize container.setMaxSessionIdleTimeout(TimeUnit.DAYS.toMillis(7)); // 设置WebSocket Session有效期 return container; }
测试:
三、SockJS
SockJS是一种为浏览器设计的WebSocket替代方案,无需更改应用后端代码。主要是因为IE 8和9版本浏览器不支持WebSocket。SockJS要求服务器发送心跳消息,保证连接正常。如果该连接上没有发送其他消息,默认情况下,心跳会间隔25秒发送一次。
修改WebSocket配置类,启用SockJS(前端必须使用SockJS
)
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatWebSocketHandler; private final MyHandshakeInterceptor myHandshakeInterceptor; public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler, MyHandshakeInterceptor myHandshakeInterceptor) { this.chatWebSocketHandler = chatWebSocketHandler; this.myHandshakeInterceptor = myHandshakeInterceptor; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, "/chat") .addInterceptors(myHandshakeInterceptor) .setAllowedOriginPatterns("*") .withSockJS(); //启用SocketJS } }
前端只需要修改创建SockJS对象的代码即可,其他API和WebSocket一致
//引入sockjs库 <script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script> //url携带token参数进行身份校验 websocket = new SockJS(`/chat?token=${token}`, null, { transports: ['websocket', 'xhr-streaming', 'xhr-polling'], timeout: 5000, debug: true });
new SockJS()
函数在创建一个 SockJS 对象时,可以传递一些参数进行配置,常用的参数如下:
-
url
:WebSocket 连接的 URL,可以是相对或绝对路径,默认为当前页面 URL 的同域路径加上配置路径。 -
protocols
:客户端期望使用的协议数组,默认为["websocket", "xhr-streaming", "iframe-eventsource", "iframe-htmlfile", "xhr-polling", "jsonp-polling"]
。如果服务端不支持任何一个协议,将会抛出错误。 -
timeout
:超时时间(毫秒),默认为 1 万毫秒(10 秒)。 -
debug
:是否启用调试模式,开启后会显示更多日志信息,默认为false
。
创建 SockJS 对象时指定 transports
参数,设为只使用 WebSocket、XHR Streaming 和 XHR Polling 三种方式,避免 SockJS 自动切换到 IFRAME 方式,从而避免连接路径中出现 iframe.html
的情况。
修改握手拦截器:
@Slf4j @Component public class MyHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { log.info("准备握手..."); String token = ""; if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; //通过request域获取携带的token参数 token = servletRequest.getServletRequest().getParameter("token"); } if (StrUtil.isEmpty(token)) { log.error("用户未认证, 无法连接!!!"); return false; } try { String username = JwtUtil.getUsername(token); log.info("用户名:{}", username); //将用户名存入WebSocket Session中方便后面使用 attributes.put("username", username); } catch (Exception e) { log.error("用户token有误, 无法连接!!!"); return false; } log.info("开始握手..."); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { log.info("结束握手..."); } }
测试:
四、STOMP
STOMP(Streaming Text Orientated Message Protocol)是流文本定向消息协议
,允许STOMP客户端与任意STOMP消息代理(Broker)
进行交互,可以在TCP和WebSocket协议上使用。尽管是一个面向文本的协议,但消息有效载荷可以是文本或二进制。
1.消息流程
简单内部代理:
-
message
:消息分为标头和负载 -
clientInboundChannel
:用于传递从 WebSocket 客户端接收的消息 -
clientOutboundChannel
:用于将服务器消息发送到 WebSocket 客户端 -
brokerChannel
:用于从内部向消息代理发送消息
外部代理(RabbitMQ):
配置外部代理后,通过 TCP 将消息向上传递到外部 STOMP 代理,并将消息从代理向下传递到订阅的客户端
总结
-
当从WebSocket 客户端接收到消息时,会先将消息解码为STOMP帧,发送到InboundChannel中
-
判断消息的destation标头是否包含注解方法的前缀(SimpAnnotationMethod),如果包含则将该消息的destation标头去掉前缀匹配注解方法,将返回一个以方法返回值为负载的消息,通过brokerChannel发送给内置的SimpleBroker或者外部代理Broker(RabbitMQ);如果不包含,则直接将消息发送给SimpleBroker或代理Broker
-
最后返回消息通过OutboundChannel发送给WebSocket 客户端
2.简单内部代理
启用Stomp
/** * WebSocket Stomp 端点配置类 */ @Configuration @EnableWebSocketMessageBroker //开启 WebSocket Broker public class WebSocketMessageBroker implements WebSocketMessageBrokerConfigurer { private final MyHandshakeInterceptor myHandshakeInterceptor; public WebSocketMessageBroker(MyHandshakeInterceptor myHandshakeInterceptor) { this.myHandshakeInterceptor = myHandshakeInterceptor; } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app") //配置匹配注解方法的前缀 //配置针对将消息发送给单个用户的路径前缀 默认为/user/ .setUserDestinationPrefix("/user/") //开启简单内部代理 并配置两个路径 /topic 广播路径 /queue 一对一单发消息路径 .enableSimpleBroker("/topic", "/queue"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") //配置Stomp 端点连接地址 .setAllowedOriginPatterns("*") //允许跨域 .addInterceptors(myHandshakeInterceptor) //配置握手拦截器 .setHandshakeHandler(myHandshakeHandler) //配置握手处理器 .withSockJS() //开启SockJS .setStreamBytesLimit(1 * 1024 * 1024) // 设置流传输最大字节数为1M .setHttpMessageCacheSize(1000) // http缓存时间为1s .setDisconnectDelay(30 * 1000); // 断开连接后的延长时间 } }
相关注解
注解 | 解释 |
---|---|
@DestinationVariable | 解析@MessageMapping路径中的模版变量 |
@Header | 获取消息的某个标头 |
@Headers | 获取消息的所有表头,类型为Map<String, Object> |
@MessageMapping | 配置注解方法对应的目的地地址,可以包含模版变量 |
@SubscribMapping | 配置仅限订阅消息的注解方法目的地地址 |
@MessageExceptionHandler | 用于处理@MessageMapping标注的方法产生的异常 |
@SendTo | 配置注释方法返回消息的目的地 |
@SendToUser | 可以配置模版变量,从传入消息的标头中获取 |
@Payload | 获取消息中的有效负载,参数默认添加 |
@SubscribMapping和@MessageMapping的区别:
@MessageMapping标注的注解方法的返回消息会发送到代理,然后广播给所有的订阅者(将返回消息目的地地址修改为以/topic
为前缀),@SubscribMapping标注的注解方法的返回消息不会发送给代理而是直接发送给连接用户
@SubscribMapping、@MessageMapping标注方法支持的参数表
参数 | 解释 |
---|---|
Message | 消息对象 |
MessageHeaders | 消息头对象 |
MessageHeaderAccessor、SimpMessageHeaderAccessor或StompHeaderAccessor | 消息头访问器,用于获取消息头 |
@Payload | 标注的参数为消息的负载(内容),参数默认标注 |
@Header | 标注的参数为指定的消息头信息(String) |
@Headers | 标注的参数为所有消息头信息(Map<String, Object>) |
@DestinationVariable | 标注的参数为@SubscribMapping、@MessageMapping中的模版变量 |
java.security.Principal | 消息发送者的用户身份对象 |
聊天控制器:
@Slf4j @RestController public class ChatController { private final ChatServiceImpl chatServiceImpl; public ChatController(ChatServiceImpl chatServiceImpl) { this.chatServiceImpl = chatServiceImpl; } //返回消息的订阅地址为 /app/greeting 消息内容为方法返回值 @SubscribeMapping("/greeting") public com.qingsongxyz.pojo.Message greeting() { com.qingsongxyz.pojo.Message message = new com.qingsongxyz.pojo.Message(); message.setId(1L); message.setFrom("NoBody"); message.setTo("I"); message.setContent("hello..."); message.setMessageType(com.qingsongxyz.pojo.Message.MessageType.Text); return message; } //返回消息默认订阅地址为 /topic/greeting1 @MessageMapping("/greeting1") public String greeting1(Message message, MessageHeaders messageHeaders, SimpMessageHeaderAccessor accessor, @Headers Map<String, Object> headers, @Payload String greeting) { log.info("message:{}", message); log.info("messageHeaders:{}", messageHeaders); log.info("accessor:{}", accessor); log.info("headers:{}", headers); log.info("greeting:{}", greeting); return greeting; } @MessageMapping("/greeting2") //@SendTo("/a/greeting2") // 返回消息订阅地址必须以代理配置的前缀 /topic、/queue 开头 @SendTo("/queue/greeting2") //返回消息订阅地址即@SendTo注解的value值 //@SendTo("/topic/greeting2") public String greeting2(String greeting) { log.info("greeting:{}", greeting); return greeting; } @MessageMapping("/greeting3") //@SendToUser 对应的订阅地址需要加上默认前缀/user 即/user/topic/greeting3 @SendToUser(value = "/topic/greeting3", broadcast = false) public String greeting3(String greeting) { log.info("greeting:{}", greeting); return greeting; } @MessageMapping("/greeting4") public void greeting4(String greeting) { log.info("greeting:{}", greeting); chatServiceImpl.greeting4(greeting); } @MessageMapping("/greeting5") public void greeting5(String greeting) { log.info("greeting:{}", greeting); chatServiceImpl.greeting5(greeting); } @MessageMapping("/greeting6") public void greeting6(SimpMessageHeaderAccessor accessor, String greeting) { com.qingsongxyz.pojo.Message message = JSON.parseObject(greeting, com.qingsongxyz.pojo.Message.class); String username = message.getTo(); log.info("username:{}", username); log.info("greeting:{}", greeting); chatServiceImpl.greeting6(username, greeting); } @MessageMapping("/chat") public void chat(Principal principal, String message){ com.qingsongxyz.pojo.Message m = JSON.parseObject(message, com.qingsongxyz.pojo.Message.class); String username = m.getTo(); log.info("username:{}", username); log.info("principal:{}", principal); log.info("greeting:{}", message); chatServiceImpl.chat(username, message); } }
聊天服务类:
@Slf4j @Service public class ChatServiceImpl { private final SimpMessagingTemplate simpMessagingTemplate; public ChatServiceImpl(SimpMessagingTemplate simpMessagingTemplate) { this.simpMessagingTemplate = simpMessagingTemplate; } //等价于@SendTo("/topic/greeting4") 返回消息订阅地址为/topic/greeting4 public void greeting4(String greeting) { simpMessagingTemplate.convertAndSend("/topic/greeting4", greeting); } //等价于@SendTo("/queue/greeting5") 返回消息订阅地址为/queue/greeting5 public void greeting5(String greeting) { simpMessagingTemplate.convertAndSend("/queue/greeting5", greeting); } //等价于@SendToUser("/queue/greeting6") 返回消息订阅地址为/user/queue/greeting6 public void greeting6(String username, String greeting) { simpMessagingTemplate.convertAndSendToUser( username, "/queue/greeting6", greeting); } //发送聊天消息(文本/图片) public void chat(String username, String message) { simpMessagingTemplate.convertAndSendToUser( username, "/queue/chat", message); } }
/** * 握手处理器 */ @Slf4j @Component public class MyHandshakeHandler extends DefaultHandshakeHandler { /** * 重写determineUser()方法 将用户名(发送者的身份信息) 和 WebSocket Session绑定 */ @Override protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) { Object username = attributes.get("username"); return new UserPrincipal(username.toString()); } }
前端页面修改为Stomp.js
<html> <head> <meta charset="UTF-8"> <title>用户间点对点发消息-Stomp</title> <script src="https://code.jquery.com/jquery-3.2.0.min.js" integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script> <script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script> <style> #image { visibility: hidden; } .iconfont { font-size: 30px; color: rgb(104, 182, 255); } .iconfont:hover { outline: red; transform: translateX(50px); border: 2px solid rgb(240, 240, 240); border-radius: 5%; box-shadow: 3px 3px 3px rgba(85, 85, 85, .3); } .iconfont:active { color: rgb(125, 166, 255); } img { width: 100px; height: 100px; } </style> </head> <body οnlοad="disconnect()"> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div> <div> <labal>连接用户</labal> <span id="username"></span> <button id="connect" οnclick="connect();">Connect</button> </div> <div> <labal>取消连接</labal> <button class="disconnect" disabled="disabled" οnclick="disconnect();">Disconnect</button> </div> <div id="conversationDiv"> <labal>发送消息</labal> <div> <labal>内容</labal> <input type="text" id="content"/> </div> <div> <label class="initial" for="image"><i class="iconfont icon-tianjiatupian_huaban"></i></label> <label class="cover" for="image"><img class="show"></label> <input type="file" οnchange="changeImg(this)" id="image" class="image_input" accept="image/png, image/jpeg, image/gif, image/jpg"/> </div> <div> <labal>发给谁</labal> <input type="text" id="to"/> </div> <button id="sendMsg" class="disconnect" disabled="disabled" οnclick="sendMsg();">Send</button> </div> <div> <labal>接收到的消息:</labal> <p id="responseData"></p> </div> </div> <script type="text/javascript"> var stomp = null; var username = localStorage.getItem("username"); var token = localStorage.getItem("token"); const span = document.getElementById("username"); span.innerHTML = username; window.onbeforeunload = function () { disconnect() } function changeImg(object) { const reads = new FileReader(); const div = object.parentNode; const input = div.getElementsByClassName('image_input')[0]; const label_initial = div.getElementsByClassName("initial")[0]; label_initial.style.display = "none"; let f = input.files[0]; reads.readAsDataURL(f); reads.onload = function (e) { var img = div.getElementsByClassName('show')[0]; img.src = this.result; } } function setConnected(connected) { document.getElementById("connect").disabled = connected; var elements = document.getElementsByClassName("disconnect"); for (let i = 0; i < elements.length; i++) { elements[i].disabled = !connected; } $("#response").html(); } function connect() { const websocket = new SockJS(`http://localhost:8080/ws?token=${token}`, null, { transports: ['websocket', 'xhr-streaming', 'xhr-polling'], timeout: 5000, debug: true }); stomp = Stomp.over(websocket); stomp.heartbeat.outgoing = 20000; //每20s发送一次心跳包 stomp.heartbeat.incoming = 0; //不接受心跳包 stomp.connect({ 'accept-version': 1.1, login: username, passcode: username }, () => { setConnected(true); console.log("连接成功...") //订阅 stomp.subscribe("/user/queue/chat", (e) => { const message = e.body; const m = JSON.parse(message); const responseData = document.getElementById('responseData'); if ("Text" === m.messageType) { console.log(`接受到 ${m.from} 的文本消息\"${m.content}\"成功...`); const p = document.createElement('p'); p.style.wordWrap = 'break-word'; p.appendChild(document.createTextNode(m.content)); responseData.appendChild(p); } if ("Binary" === m.messageType) { console.log(`接受到 ${m.from} 的二进制消息图片成功...`); const img = document.createElement('img'); img.style.wordWrap = 'break-word'; img.src = m.content; responseData.appendChild(img); } }, {}) }, err => { setConnected(false); console.log("连接异常...", err) }) } function disconnect() { if (stomp !== null) stomp.disconnect(() => { setConnected(false); console.log("连接关闭...") }); } function sendMsg() { const to = document.getElementById("to").value; const content = document.getElementById("content").value; const img = document.getElementsByClassName('show')[0].src; if (to.trim() === "" || (content.trim() === "" && img === "")) { alert("请输入消息信息和接收用户!!!"); return; } if (content.trim() !== "") { const json = `{"from": "${username}", "to": "${to.trim()}", "content": "${content.trim()}", "messageType": "Text"}` stomp.send("/app/chat", {}, json) console.log(`${username} 发送文本消息\"${content}\"给 ${to} 成功...`) } if (img !== "") { const json = `{"from": "${username}", "to": "${to.trim()}", "content": "${img}", "messageType": "Binary"}` stomp.send("/app/chat", {}, json) console.log(`${username} 发送二进制消息图片给 ${to} 成功...`) } } </script> </body> </html>
测试:
源码分析:
/** * SimpMessagingTemplate类 */ //默认用户路径前缀 private String destinationPrefix = "/user/"; @Override public void convertAndSendToUser(String user, String destination, Object payload) throws MessagingException { convertAndSendToUser(user, destination, payload, (MessagePostProcessor) null); } @Override public void convertAndSendToUser(String user, String destination, Object payload, @Nullable MessagePostProcessor postProcessor) throws MessagingException { convertAndSendToUser(user, destination, payload, null, postProcessor); } @Override public void convertAndSendToUser(String user, String destination, Object payload, @Nullable Map<String, Object> headers, @Nullable MessagePostProcessor postProcessor) throws MessagingException { //用户凭证不能为空 也不能包含 %2F Assert.notNull(user, "User must not be null"); Assert.isTrue(!user.contains("%2F"), "Invalid sequence \"%2F\" in user name: " + user); //将用户凭证中的 / 替换为 %2F user = StringUtils.replace(user, "/", "%2F"); //对于destination补充前 / destination = destination.startsWith("/") ? destination : "/" + destination; //实际调用父类AbstractMessageSendingTemplate的convertAndSend()方法 super.convertAndSend(this.destinationPrefix + user + destination, payload, headers, postProcessor); }
/** * AbstractMessageSendingTemplate类 */ @Override public void convertAndSend(D destination, Object payload, @Nullable Map<String, Object> headers, @Nullable MessagePostProcessor postProcessor) throws MessagingException { Message<?> message = doConvert(payload, headers, postProcessor); send(destination, message); } @Override public void send(D destination, Message<?> message) { doSend(destination, message); } //实际调用子类SimpMessagingTemplate重写的doSend()方法 protected abstract void doSend(D destination, Message<?> message);
/** * SimpMessagingTemplate类 */ //超时时间(默认没有 值为-1) private volatile long sendTimeout = -1; @Override protected void doSend(String destination, Message<?> message) { Assert.notNull(destination, "Destination must not be null"); SimpMessageHeaderAccessor simpAccessor = MessageHeaderAccessor.getAccessor(message, SimpMessageHeaderAccessor.class); if (simpAccessor != null) { if (simpAccessor.isMutable()) { simpAccessor.setDestination(destination); simpAccessor.setMessageTypeIfNotSet(SimpMessageType.MESSAGE); simpAccessor.setImmutable(); //发送消息 sendInternal(message); return; } else { simpAccessor = (SimpMessageHeaderAccessor) MessageHeaderAccessor.getMutableAccessor(message); initHeaders(simpAccessor); } } else { simpAccessor = SimpMessageHeaderAccessor.wrap(message); initHeaders(simpAccessor); } simpAccessor.setDestination(destination); simpAccessor.setMessageTypeIfNotSet(SimpMessageType.MESSAGE); message = MessageBuilder.createMessage(message.getPayload(), simpAccessor.getMessageHeaders()); sendInternal(message); } private void sendInternal(Message<?> message) { String destination = SimpMessageHeaderAccessor.getDestination(message.getHeaders()); Assert.notNull(destination, "Destination header required"); //通过messageChannel发送消息, 如果设置超时时间则要进行相应的判断 long timeout = this.sendTimeout; boolean sent = (timeout >= 0 ? this.messageChannel.send(message, timeout) : this.messageChannel.send(message)); if (!sent) { throw new MessageDeliveryException(message, "Failed to send message to destination '" + destination + "' within timeout: " + timeout); } }
/** * AbstractMessageChannel类 */ //存储MessageHandler private final Set<MessageHandler> handlers = new CopyOnWriteArraySet<>(); //获取MessageHandler集合 public Set<MessageHandler> getSubscribers() { return Collections.<MessageHandler>unmodifiableSet(this.handlers); } @Override public final boolean send(Message<?> message, long timeout) { Assert.notNull(message, "Message must not be null"); Message<?> messageToUse = message; ChannelInterceptorChain chain = new ChannelInterceptorChain(); boolean sent = false; try { messageToUse = chain.applyPreSend(messageToUse, this); if (messageToUse == null) { return false; } //发送消息 sent = sendInternal(messageToUse, timeout); chain.applyPostSend(messageToUse, this, sent); chain.triggerAfterSendCompletion(messageToUse, this, sent, null); return sent; } catch (Exception ex) { chain.triggerAfterSendCompletion(messageToUse, this, sent, ex); if (ex instanceof MessagingException) { throw (MessagingException) ex; } throw new MessageDeliveryException(messageToUse,"Failed to send message to " + this, ex); } catch (Throwable err) { MessageDeliveryException ex2 = new MessageDeliveryException(messageToUse, "Failed to send message to " + this, err); chain.triggerAfterSendCompletion(messageToUse, this, sent, ex2); throw ex2; } } //实际调用子类ExecutorSubscribableChannel重写的sendInternal()方法 protected abstract boolean sendInternal(Message<?> message, long timeout);
/** * ExecutorSubscribableChannel类 */ @Override public boolean sendInternal(Message<?> message, long timeout) { //遍历每一个消息处理器 for (MessageHandler handler : getSubscribers()) { SendTask sendTask = new SendTask(message, handler); if (this.executor == null) { sendTask.run(); } else { this.executor.execute(sendTask); } } return true; } //ExecutorSubscribableChannel类中的成员内部类SendTask 实际上就是一个Runnable线程调用消息处理器处理消息 private class SendTask implements MessageHandlingRunnable { private final Message<?> inputMessage; private final MessageHandler messageHandler; public SendTask(Message<?> message, MessageHandler messageHandler) { this.inputMessage = message; this.messageHandler = messageHandler; } @Override public void run() { Message<?> message = this.inputMessage; try { message = applyBeforeHandle(message); if (message == null) { return; } //调用messageHandler的handleMessage()方法处理消息 this.messageHandler.handleMessage(message); triggerAfterMessageHandled(message, null); } catch (Exception ex) { triggerAfterMessageHandled(message, ex); if (ex instanceof MessagingException) { throw (MessagingException) ex; } String description = "Failed to handle " + message + " to " + this + " in " + this.messageHandler; throw new MessageDeliveryException(message, description, ex); } catch (Throwable err) { String description = "Failed to handle " + message + " to " + this + " in " + this.messageHandler; MessageDeliveryException ex2 = new MessageDeliveryException(message, description, err); triggerAfterMessageHandled(message, ex2); throw ex2; } } }
MessageHandler:
类名 | 解释 |
---|---|
SimpleBrokerMessageHandler | 简单内部代理使用,处理订阅、发送消息 |
StompBrokerRelayMessageHandler | 外部代理使用,和代理建立TCP连接,交互消息 |
AbstractMethodMessageHandler | 通过消息目的地匹配对应的注解方法、对于注解方法消息的默认处理逻辑和对于其中产生异常的处理 |
SimpAnnotationMethodMessageHandler | 处理@MessageMapping、@SubscribeMapping |
WebSocketAnnotationMethodMessageHandler | 处理@MessageExceptionHandler,支持@ControllerAdvice |
SubProtocolWebSocketHandler | 将WebSocket Client发送的消息通过InboundChannel代理到其他MessageHandler处理;将其他MessageHandler的返回消息通过OutboundChannel发送到WebSocket Client |
UserDestinationMessageHandler | 监听以/user/ 为前缀路径的消息,将其路径转换为基于用户sessionId的真实路径并发送到broker channel |
/** * UserDestinationMessageHandler类 处理 /user/ 为前缀的消息 */ @Override public void handleMessage(Message<?> message) throws MessagingException { Message<?> messageToUse = message; if (this.broadcastHandler != null) { messageToUse = this.broadcastHandler.preHandle(message); if (messageToUse == null) { return; } } /* 通过UserDestinationResolver处理, 将消息目的地和用户session关联起来组成真正地址, 向用 户目的地发送消息时, 目的地必须包含用户名, 以便提取该用户名并用于查找用户会话.订阅用户目的 地时, 则不必包含用户名 */ UserDestinationResult result = this.destinationResolver.resolveDestination(messageToUse); if (result == null) { return; } if (result.getTargetDestinations().isEmpty()) { if (logger.isTraceEnabled()) { logger.trace("No active sessions for user destination: " + result.getSourceDestination()); } if (this.broadcastHandler != null) { this.broadcastHandler.handleUnresolved(messageToUse); } return; } SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(messageToUse); initHeaders(accessor); //将消息的原本地址储存在标头simpOrigDestination中, 方便后续将消息发给WebSoket client accessor.setNativeHeader(SimpMessageHeaderAccessor.ORIGINAL_DESTINATION, result.getSubscribeDestination()); accessor.setLeaveMutable(true); messageToUse = MessageBuilder.createMessage(messageToUse.getPayload(), accessor.getMessageHeaders()); if (logger.isTraceEnabled()) { logger.trace("Translated " + result.getSourceDestination() + " -> " + result.getTargetDestinations()); } for (String target : result.getTargetDestinations()) { //通过SimpMessagingTemplate发送消息 this.messagingTemplate.send(target, messageToUse); } }
/** * DefaultUserDestinationResolver类 将消息目的地和用户session关联起来组成真正地址 */ @Override @Nullable public UserDestinationResult resolveDestination(Message<?> message) { //调用parse()解析消息 ParseResult parseResult = parse(message); if (parseResult == null) { return null; } String user = parseResult.getUser(); String sourceDestination = parseResult.getSourceDestination(); Set<String> targetSet = new HashSet<>(); //如果解析结果中sessionIds集合为空集合, 则没有targetDestination消息也发送不了了 for (String sessionId : parseResult.getSessionIds()) { String actualDestination = parseResult.getActualDestination(); //调用getTargetDestination()组成真正地址 类似/{username}/xxx-user{sessionId} String targetDestination = getTargetDestination( sourceDestination, actualDestination, sessionId, user); if (targetDestination != null) { targetSet.add(targetDestination); } } String subscribeDestination = parseResult.getSubscribeDestination(); //将原本地址、真正地址集合、订阅地址和用户信息封装成UserDestinationResult对象返回 return new UserDestinationResult(sourceDestination, targetSet, subscribeDestination, user); } @Nullable protected String getTargetDestination(String sourceDestination, String actualDestination,String sessionId, @Nullable String user) { //真实地址为 去掉/user/前缀后的地址加上-user和sessionId return actualDestination + "-user" + sessionId; } @Nullable private ParseResult parse(Message<?> message) { MessageHeaders headers = message.getHeaders(); String sourceDestination = SimpMessageHeaderAccessor.getDestination(headers); //判断消息目的地是否以/user/开头 if (sourceDestination == null || !checkDestination(sourceDestination, this.prefix)) { return null; } //获取消息的类型 SimpMessageType messageType = SimpMessageHeaderAccessor.getMessageType(headers); if (messageType != null) { switch (messageType) { case SUBSCRIBE: case UNSUBSCRIBE: //调用parseSubscriptionMessage()解析订阅、取消订阅消息 return parseSubscriptionMessage(message, sourceDestination); case MESSAGE: //调用parseMessage()解析发送消息 return parseMessage(headers, sourceDestination); } } return null; } @Nullable private ParseResult parseSubscriptionMessage(Message<?> message, String sourceDestination) { MessageHeaders headers = message.getHeaders(); String sessionId = SimpMessageHeaderAccessor.getSessionId(headers); if (sessionId == null) { logger.error("No session id. Ignoring " + message); return null; } int prefixEnd = this.prefix.length() - 1; String actualDestination = sourceDestination.substring(prefixEnd); if (isRemoveLeadingSlash()) { actualDestination = actualDestination.substring(1); } Principal principal = SimpMessageHeaderAccessor.getUser(headers); String user = (principal != null ? principal.getName() : null); Assert.isTrue(user == null || !user.contains("%2F"), "Invalid sequence \"%2F\" in user name: " + user); Set<String> sessionIds = Collections.singleton(sessionId); //将消息原本地址、去掉/user/前缀的地址、订阅地址、sessionId集合和用户信息组装成ParseResult解析结果返回 return new ParseResult(sourceDestination, actualDestination, sourceDestination, sessionIds, user); } private ParseResult parseMessage(MessageHeaders headers, String sourceDest) { int prefixEnd = this.prefix.length(); int userEnd = sourceDest.indexOf('/', prefixEnd); Assert.isTrue(userEnd > 0, "Expected destination pattern \"/user/{userId}/**\""); String actualDest = sourceDest.substring(userEnd); String subscribeDest = this.prefix.substring(0, prefixEnd - 1) + actualDest; String userName = sourceDest.substring(prefixEnd, userEnd); userName = StringUtils.replace(userName, "%2F", "/"); String sessionId = SimpMessageHeaderAccessor.getSessionId(headers); Set<String> sessionIds; if (userName.equals(sessionId)) { userName = null; sessionIds = Collections.singleton(sessionId); } else { //通过传递的用户名查找该对应的sessionId sessionIds = getSessionIdsByUser(userName, sessionId); } if (isRemoveLeadingSlash()) { actualDest = actualDest.substring(1); } //将消息原本地址、去掉/user/前缀的地址、订阅地址、sessionId集合和用户信息组装成ParseResult解析结果返回 return new ParseResult(sourceDest, actualDest, subscribeDest, sessionIds, userName); } private final SimpUserRegistry userRegistry; private Set<String> getSessionIdsByUser(String userName, @Nullable String sessionId) { Set<String> sessionIds; //调用SimpUserRegistry的getUser()方法通过用户名查找SimpUser对象 SimpUser user = this.userRegistry.getUser(userName); //找到了会将sessionId存入集合中, 没找到sessionIds为空集合 if (user != null) { if (sessionId != null && user.getSession(sessionId) != null) { sessionIds = Collections.singleton(sessionId); } else { Set<SimpSession> sessions = user.getSessions(); sessionIds = new HashSet<>(sessions.size()); for (SimpSession session : sessions) { sessionIds.add(session.getId()); } } } else { sessionIds = Collections.emptySet(); } return sessionIds; }
DefaultSimpUserRegistry(SimpUserRegistry实现类)
:
存储当前连接的用户session集合和订阅关系(内存中)
//实现SmartApplicationListener生命周期接口, 监听session相关事件动态维护session集合和订阅关系 public class DefaultSimpUserRegistry implements SimpUserRegistry, SmartApplicationListener { //存储用户session关系 key是用户名 value是LocalSimpUser对象 存储用户session信息 private final Map<String, LocalSimpUser> users = new ConcurrentHashMap<>(); //存储session订阅关系 key是sessionId value是LocalSimpSession对象 存储订阅关系(session集合) private final Map<String, LocalSimpSession> sessions = new ConcurrentHashMap<>(); ... @Override public void onApplicationEvent(ApplicationEvent event) { AbstractSubProtocolEvent subProtocolEvent = (AbstractSubProtocolEvent) event; Message<?> message = subProtocolEvent.getMessage(); MessageHeaders headers = message.getHeaders(); //获取消息的sessionId String sessionId = SimpMessageHeaderAccessor.getSessionId(headers); Assert.state(sessionId != null, "No session id"); //订阅事件 if (event instanceof SessionSubscribeEvent) { LocalSimpSession session = this.sessions.get(sessionId); if (session != null) { String id = SimpMessageHeaderAccessor.getSubscriptionId(headers); String destination = SimpMessageHeaderAccessor.getDestination(headers); if (id != null && destination != null) { //添加订阅关系 session.addSubscription(id, destination); } } } //连接事件 else if (event instanceof SessionConnectedEvent) { //获取用户信息 Principal user = subProtocolEvent.getUser(); if (user == null) { return; } String name = user.getName(); if (user instanceof DestinationUserNameProvider) { name = ((DestinationUserNameProvider) user).getDestinationUserName(); } //添加连接用户的session synchronized (this.sessionLock) { LocalSimpUser simpUser = this.users.get(name); if (simpUser == null) { simpUser = new LocalSimpUser(name, user); this.users.put(name, simpUser); } LocalSimpSession session = new LocalSimpSession(sessionId, simpUser); simpUser.addSession(session); this.sessions.put(sessionId, session); } } //断开连接事件 else if (event instanceof SessionDisconnectEvent) { //移除用户session synchronized (this.sessionLock) { LocalSimpSession session = this.sessions.remove(sessionId); if (session != null) { LocalSimpUser user = session.getUser(); user.removeSession(sessionId); if (!user.hasSessions()) { this.users.remove(user.getName()); } } } } //取消订阅事件 else if (event instanceof SessionUnsubscribeEvent) { LocalSimpSession session = this.sessions.get(sessionId); if (session != null) { String subscriptionId = SimpMessageHeaderAccessor.getSubscriptionId(headers); if (subscriptionId != null) { //移除订阅关系 session.removeSubscription(subscriptionId); } } } } }
3.外部代理(RabbitMQ)
简单内部代理不支持消息确认和回执等功能,用户session和订阅关系存储在各自的内存中,不适合集群环境,此时需要使用外部代理,例如ActiveMQ、RabbitMQ。下面以RabbitMQ为例:
RabbitMQ官网(Stomp plugin):STOMP Plugin — RabbitMQ
开启RabbitMQ Stomp插件:
rabbitmq-plugins enable rabbitmq_stomp rabbitmq-plugins list # 查看所有插件
RabbitMQ支持的目的地前缀:
-
/exchange
:可以指定路由键和交换机,但不能指定已存在的队列,而是自动创建auto-delete队列 -
/queue
:可以指定队列名称(作为路由键),使用默认交换机 -
/amp/queue
:只能指定已存在的队列(名称作为路由键),使用默认交换机 -
/topic
:可以指定路由键,自动创建auto-delete队列,使用amq.topic交换机 -
/temp-queue
:暂时队列(仅在reply-to请求头中使用)
1.Exchange
对于订阅消息,每次都会创建一个独占、自动删除
的队列,通过指定的路由键绑定到指定名称的交换机上,将订阅关系注册到当前的Stomp session中。对于发送消息,将消息通过路由键发送给指定名称的交换机。不支持指定已经存在的队列
,消费其中的消息
发送、订阅地址:/exchange/{exchange_name}[/{routing_key}]
2.Queue
对于订阅消息,会创建一个共享的指定名称的队列,将订阅关系注册到当前的Stomp session中。对于发送消息,只有在第一次发送时会创建一个共享的指定名称的队列,将指定的队列名称作为路由键
,将消息发送给默认交换机
,如果没有订阅者,消息会存储在队列中直到订阅者出现进行消费
。如果未指定队列的参数,则默认为durable, non-exclusive, non-autodeleted。
发送、订阅地址:/queue/{queue_name}
3.AMQ
订阅和发送消息都不会创建队列,而是使用已经存在的队列
。对于订阅消息,如果指定名称的队列不存在则会报错,将订阅关系注册到当前的Stomp session中。对于发送消息,将指定的队列名称作为路由键
,通过默认交换机
直接发送给指定名称的队列。如果未指定队列的参数,则默认为durable, non-exclusive, non-autodeleted
发送、订阅地址:/amp/queue/{queue_name}
4.Topic
对于订阅消息,会创建一个自动删除
的队列,通过指定路由键绑定到amq.topic
交换机。对于发送消息,通过指定路由键发送到amq.topic交换机,默认没有订阅者消息会直接丢弃
发送、订阅地址:/topic/{routing_key}
创建持久化订阅,解决消息丢弃
在订阅时,设置标头durable或persistent为true,auto-delete为false,设置一个唯一id
SUBSCRIBE destination:/topic/my-durable id:1234 durable:true auto-delete:false
如果需要删除持久化订阅,在取消订阅时,设置同样的标头即可
UNSUBSCRIBE id:1234 durable:true auto-delete:false
5.Temp Queue
发送和订阅消息的目的地地址都不能包含/temp-queue
字符串。对于发送消息,需要设置以/temp/queue
为前缀的标头reply-to
,会创建一个独占、自动删除
的临时队列,队列名称不透明由broker生成,为每个会话私有,并自动进行对队列的订阅,订阅使用自动确认模式(无法修改)
SEND destination:/queue/reply-test reply-to:/temp-queue/foo
相关配置:
在订阅Exchange和Topic前缀目的地时,队列名称默认自动生成,可以通过标头x-queue-name设置队列名称
SUBSCRIBE destination:/topic/alarms x-queue-name:my-alarms-queue # 队列名称为my-alarms-queue
在发送消息时,设置标头persistent为true,将消息持久化,这种消息只有在收到Broker的确认时才会发送出去
SEND destination:/topic/a persistent:true
消息的ACK和NACK
默认情况下消息会在发送到客户端之前由服务器自动确认,可以设置ack标头修改。当消息被拒绝NACK后,消息默认会重新入队列
-
auto(默认)
:自动确认模式 -
client
:手动确认,一次确认多条消息 -
client-individual
:手动确认,一次只确认单条消息
添加RabbitMQ依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-reactor-netty</artifactId> </dependency>
application.yml配置文件
spring: rabbitmq: host: IP port: 5672 virtual-host: vhost username: 用户名 password: 密码 publisher-confirm-type: CORRELATED # 发布消息成功到交换机后会触发回调 publisher-returns: true # 表示交换机消息发送不出去会将消息回退给生产者 listener: simple: acknowledge-mode: manual # 设置为手动确认模式
/** * WebSocket端点配置类 */ @Configuration @EnableWebSocketMessageBroker public class WebSocketMessageBroker implements WebSocketMessageBrokerConfigurer { @Value("${spring.rabbitmq.host}") private String host; @Value("${spring.rabbitmq.username}") private String username; @Value("${spring.rabbitmq.virtual-host}") private String virtualHost; @Value("${spring.rabbitmq.password}") private String password; private final MyChannelInterceptor myChannelInterceptor; public WebSocketMessageBroker(MyChannelInterceptor myChannelInterceptor) { this.myChannelInterceptor = myChannelInterceptor; } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 使用RabbitMQ做为消息代理, 替换默认的Simple Broker registry.setApplicationDestinationPrefixes("/app") .enableStompBrokerRelay("/exchange", "/queue", "/amq/queue", "/topic", "/temp-queue/") .setRelayHost(host) .setVirtualHost(virtualHost) .setClientLogin(username) //客户端连接时用户名 .setClientPasscode(password) //客户端连接时密码 .setSystemLogin(username) //服务器连接时用户名 .setSystemPasscode(password); //服务器连接时密码 } @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(myChannelInterceptor); //添加拦截器 } @Override public void configureClientOutboundChannel(ChannelRegistration registration) { registration.interceptors(myChannelInterceptor); //添加拦截器 } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") //注册端点映射路径 .setAllowedOriginPatterns("*") //配置跨域 .withSockJS() //开启SockJS回退 .setStreamBytesLimit(1 * 1024 * 1024) // 设置流传输最大字节数为1M .setHttpMessageCacheSize(1000) // http缓存时间为1s .setDisconnectDelay(30 * 1000); // 断开连接后的延长时间 } }
/** * RabbitMQ 消息确认、回退监听配置类 */ @Slf4j @Component public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback { private final RabbitTemplate rabbitTemplate; public MyCallBack(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; } @PostConstruct private void init() { rabbitTemplate.setConfirmCallback(this); rabbitTemplate.setReturnsCallback(this); } /** * 交换机对生产者的消息产生应答 * * @param correlationData 回调消息的id和信息,需要生产者发送 * @param b 交换机是否收到消息 true * @param s 失败原因 */ @Override public void confirm(CorrelationData correlationData, boolean b, String s) { String id = correlationData != null ? correlationData.getId() : ""; if (b) { log.info("交换机收到id为{}的消息", id); } else { log.error("交换机未收到id为{}的消息,原因:{}", id, s); } } /** * 队列未收到交换机的消息时将消息回退给生产者 * * @param message 回退消息 */ @Override public void returnedMessage(ReturnedMessage message) { Message msg = message.getMessage(); String exchange = message.getExchange(); String routingKey = message.getRoutingKey(); int replyCode = message.getReplyCode(); String replyText = message.getReplyText(); log.error("消息{}被交换机{}退回,路由:{},响应码:{},原因是:{}", msg, exchange, routingKey, replyCode, replyText); } }
/** * 通道拦截器 监听ACK、NACK消息 */ @Slf4j @Component public class MyChannelInterceptor implements ChannelInterceptor { @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); StompCommand command = headerAccessor.getCommand(); if (command == null) { return message; } String msg = headerAccessor.getMessage(); switch (command) { case ACK: log.info("消息-{}-被ack确认...", msg); break; case NACK: log.info("消息-{}-被nack否认...", msg); break; } return message; } }
1.使用Topic前缀
进行单发
<html> <head> <meta charset="UTF-8"> <title>用户间点对点发消息</title> <script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script> <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script> <script src="https://code.jquery.com/jquery-3.2.0.min.js" integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script> <style> #image { visibility: hidden; } .iconfont { font-size: 30px; color: rgb(104, 182, 255); } .iconfont:hover { outline: red; transform: translateX(50px); border: 2px solid rgb(240, 240, 240); border-radius: 5%; box-shadow: 3px 3px 3px rgba(85, 85, 85, .3); } .iconfont:active { color: rgb(125, 166, 255); } img { width: 100px; height: 100px; } </style> </head> <body> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div> <div> <labal>连接用户</labal> <input type="text" id="user"/> <button id="connect" οnclick="connect();">Connect</button> </div> <div> <labal>取消连接</labal> <button class="disconnect" id="disconnect" disabled="disabled" οnclick="disconnect();">Disconnect</button> </div> <div id="conversationDiv"> <labal>发送消息</labal> <div> <labal>内容</labal> <input type="text" id="content"/> </div> <div> <label class="initial" for="image"><i class="iconfont icon-tianjiatupian_huaban"></i></label> <label class="cover" for="image"><img class="show"></label> <input type="file" οnchange="changeImg(this)" id="image" class="image_input" accept="image/png, image/jpeg, image/gif, image/jpg"/> </div> <div> <labal>发给谁</labal> <input type="text" id="to"/> </div> <button id="sendMsg" class="disconnect" οnclick="sendMsg();">Send</button> </div> <div> <labal>接收到的消息:</labal> <p id="responseData"></p> </div> </div> <script type="text/javascript"> var stompClient = null; var subscription; var username; window.onbeforeunload = function () { disconnect() } function changeImg(object) { const reads = new FileReader(); const div = object.parentNode; const input = div.getElementsByClassName('image_input')[0]; const label_initial = div.getElementsByClassName("initial")[0]; label_initial.style.display = "none"; let f = input.files[0]; reads.readAsDataURL(f); reads.onload = function (e) { var img = div.getElementsByClassName('show')[0]; img.src = this.result; } } function setConnected(connected) { document.getElementById("connect").disabled = connected; const elements = document.getElementsByClassName("disconnect"); for (let i = 0; i < elements.length; i++) { elements[i].disabled = !connected; } $("#response").html(); } function connect() { username = document.getElementById('user').value.trim(); if (username === "") { alert("请输入用户名!!!") return; } const socket = new SockJS(`http://localhost:8080/ws?username=${username}`, null, { transports: ['websocket', 'xhr-streaming', 'xhr-polling'], timeout: 5000, debug: true }); stompClient = Stomp.over(socket); stompClient.heartbeat.outgoing = 20000; stompClient.heartbeat.incoming = 0; stompClient.connect({}, function (frame) { setConnected(true); console.log('Connected: ' + frame); subscription = stompClient.subscribe(`/topic/${username}`, function (response) { let body = response.body; let m = JSON.parse(body); let responseData = document.getElementById('responseData'); if ("Text" === m.messageType) { console.log(`接受到 ${m.from} 的文本消息\"${m.content}\"成功...`); const p = document.createElement('p'); p.style.wordWrap = 'break-word'; p.appendChild(document.createTextNode(m.content)); responseData.appendChild(p); } if ("Binary" === m.messageType) { console.log(`接受到 ${m.from} 的二进制消息图片成功...`); const img = document.createElement('img'); img.style.wordWrap = 'break-word'; img.src = m.content; responseData.appendChild(img); } }, { id: '1', durable: true, 'auto-delete': false, 'x-queue-name': username }); }, err => { setConnected(false); console.log("连接异常...", err) }); } function disconnect() { if (subscription != null) { subscription.unsubscribe({ id: subscription.id, durable: true, 'auto-delete': false, 'x-queue-name': username }); } if (stompClient != null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } function sendMsg() { const content = document.getElementById('content').value; const to = document.getElementById('to').value; const from = document.getElementById('user').value; const img = document.getElementsByClassName('show')[0].src; if (to.trim() === "" || (content.trim() === "" && img === "")) { alert("请输入消息信息和接收用户!!!"); return; } if (content.trim() !== "") { stompClient.send(`/topic/${to}`, {}, JSON.stringify({ 'from': from, 'to': to, 'content': content, "messageType": "Text" })); console.log(`${username} 发送文本消息\"${content}\"给 ${to} 成功...`) } if (img !== "") { stompClient.send(`/topic/${to}`, {}, JSON.stringify({ 'from': from, 'to': to, 'content': img, "messageType": "Binary" })); console.log(`${username} 发送二进制消息图片给 ${to} 成功...`) } } </script> </body> </html>
测试:
2.使用exchange前缀
进行广播
<!DOCTYPE html> <html> <head> <meta http-equiv="content-type" content="text/html; charset=UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0"> <title>聊天室</title> <style type="text/css"> * { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } html, body { height: 100%; overflow: hidden; } body { margin: 0; padding: 0; font-weight: 400; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1rem; line-height: 1.58; color: #333; background-color: #f4f4f4; height: 100%; } body:before { height: 50%; width: 100%; position: absolute; top: 0; left: 0; background: #128ff2; content: ""; z-index: 0; } .clearfix:after { display: block; content: ""; clear: both; } .hidden { display: none; } .form-control { width: 100%; min-height: 38px; font-size: 15px; border: 1px solid #c8c8c8; } .form-group { margin-bottom: 15px; } input { padding-left: 10px; outline: none; } h1, h2, h3, h4, h5, h6 { margin-top: 20px; margin-bottom: 20px; } h1 { font-size: 1.7em; } a { color: #128ff2; } button { box-shadow: none; border: 1px solid transparent; font-size: 14px; outline: none; line-height: 100%; white-space: nowrap; vertical-align: middle; padding: 0.6rem 1rem; border-radius: 2px; transition: all 0.2s ease-in-out; cursor: pointer; min-height: 38px; } button.default { background-color: #e8e8e8; color: #333; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); } button.primary { background-color: #128ff2; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff; } button.accent { background-color: #ff4743; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff; } #username-page { text-align: center; } .username-page-container { background: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); border-radius: 2px; width: 100%; max-width: 500px; display: inline-block; margin-top: 42px; vertical-align: middle; position: relative; padding: 35px 55px 35px; min-height: 250px; position: absolute; top: 50%; left: 0; right: 0; margin: 0 auto; margin-top: -160px; } .username-page-container .username-submit { margin-top: 10px; } #chat-page { position: relative; height: 100%; } .chat-container { max-width: 700px; margin-left: auto; margin-right: auto; background-color: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); margin-top: 30px; height: calc(100% - 60px); max-height: 600px; position: relative; } #chat-page ul { list-style-type: none; background-color: #FFF; margin: 0; overflow: auto; overflow-y: scroll; padding: 0 20px 0px 20px; height: calc(100% - 150px); } #chat-page #messageForm { padding: 20px; } #chat-page ul li { line-height: 1.5rem; padding: 10px 20px; margin: 0; border-bottom: 1px solid #f4f4f4; } #chat-page ul li p { margin: 0; } #chat-page .event-message { width: 100%; text-align: center; clear: both; } #chat-page .event-message p { color: #777; font-size: 14px; word-wrap: break-word; } #chat-page .chat-message { padding-left: 68px; position: relative; } #chat-page .chat-message i { position: absolute; width: 42px; height: 42px; overflow: hidden; left: 10px; display: inline-block; vertical-align: middle; font-size: 18px; line-height: 42px; color: #fff; text-align: center; border-radius: 50%; font-style: normal; text-transform: uppercase; } #chat-page .chat-message span { color: #333; font-weight: 600; } #chat-page .chat-message p { color: #43464b; } #messageForm .input-group input { float: left; width: calc(100% - 85px); } #messageForm .input-group button { float: left; width: 80px; height: 38px; margin-left: 5px; } .chat-header { text-align: center; padding: 15px; border-bottom: 1px solid #ececec; } .chat-header h2 { margin: 0; font-weight: 500; } .connecting { padding-top: 5px; text-align: center; color: #777; position: absolute; top: 65px; width: 100%; } @media screen and (max-width: 730px) { .chat-container { margin-left: 10px; margin-right: 10px; margin-top: 10px; } } @media screen and (max-width: 480px) { .chat-container { height: calc(100% - 30px); } .username-page-container { width: auto; margin-left: 15px; margin-right: 15px; padding: 25px; } #chat-page ul { height: calc(100% - 120px); } #messageForm .input-group button { width: 65px; } #messageForm .input-group input { width: calc(100% - 70px); } .chat-header { padding: 10px; } .connecting { top: 60px; } .chat-header h2 { font-size: 1.1em; } } </style> </head> <body> <noscript> <h2>Sorry! Your browser doesn't support Javascript</h2> </noscript> <div id="username-page"> <div class="username-page-container"> <h1 class="title">接收消息测试</h1> <form id="usernameForm" name="usernameForm"> <div class="form-group"> <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control"/> </div> <div class="form-group"> <button type="submit" class="accent username-submit">进入消息接收室</button> </div> </form> </div> </div> <div id="chat-page" class="hidden"> <div class="chat-container"> <div class="chat-header"> <h2>消息测试</h2> </div> <div class="connecting"> Connecting... </div> <ul id="messageArea"> </ul> <form id="messageForm" name="messageForm" nameForm="messageForm"> <div class="form-group"> <div class="input-group clearfix"> <input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/> <button type="submit" class="primary">Send</button> </div> </div> </form> </div> </div> <script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script> <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script> <script src="https://code.jquery.com/jquery-3.2.0.min.js" integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script> <script type="text/javascript"> var usernamePage = document.querySelector('#username-page'); var chatPage = document.querySelector('#chat-page'); var usernameForm = document.querySelector('#usernameForm'); var messageForm = document.querySelector('#messageForm'); var messageInput = document.querySelector('#message'); var messageArea = document.querySelector('#messageArea'); var connectingElement = document.querySelector('.connecting'); var stompClient = null; var username = null; var colors = [ '#2196F3', '#32c787', '#00BCD4', '#ff5652', '#ffc107', '#ff85af', '#FF9800', '#39bbb0' ]; function connect(event) { username = document.querySelector('#name').value.trim(); if (username) { usernamePage.classList.add('hidden'); chatPage.classList.remove('hidden'); let socket = new SockJS("http://localhost:8080/ws", null, { transports: ['websocket', 'xhr-streaming', 'xhr-polling'], timeout: 5000, debug: true }); stompClient = Stomp.over(socket); stompClient.heartbeat.outgoing = 20000; //每20s发送一次心跳包 stompClient.heartbeat.incoming = 0; //不接受心跳包 stompClient.connect({}, onConnected, onError); } event.preventDefault(); } function onConnected() { username = document.querySelector('#name').value.trim(); stompClient.subscribe("/exchange/amq.fanout", onMessageReceived, {ack: 'client-individual'}); connectingElement.classList.add('hidden'); } function onError(error) { console.log(error) connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!'; connectingElement.style.color = 'red'; } function sendMessage(event) { let messageContent = messageInput.value.trim(); if (messageContent && stompClient) { let chatMessage = { from: username, to: "", content: messageInput.value }; stompClient.send("/exchange/amq.fanout", {}, JSON.stringify(chatMessage)); messageInput.value = ''; } event.preventDefault(); } function onMessageReceived(response) { try { let body = response.body; let m = JSON.parse(body); let li = document.createElement('li'); li.style.wordWrap = 'break-word'; let avatarColor = getAvatarColor(m.from); let avatar = document.createElement('span'); avatar.innerHTML = m.from avatar.style.padding = "10px" avatar.style.marginRight = "20px" avatar.style.backgroundColor = avatarColor avatar.style.borderRadius = "50%" li.append(avatar, document.createTextNode(m.content)) messageArea.appendChild(li); response.ack(); } catch (e) { console.log(e) response.nack(); } } function getAvatarColor(messageSender) { let hash = 0; for (let i = 0; i < messageSender.length; i++) { hash = 31 * hash + messageSender.charCodeAt(i); } let index = Math.abs(hash % colors.length); return colors[index]; } usernameForm.addEventListener('submit', connect, true) messageForm.addEventListener('submit', sendMessage, true) </script> </body> </html>
测试: