通过本文你将学习如何使用Spring Boot和WebSocket API开发一个简单的群聊天应用。
WebSocket是HTML5开始提供的一种在单个TCP连接上进行全双工通讯的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务器主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
很多网站为实现推送技术,所用技术都是Ajax轮询。轮询指的是在特定的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后服务器返回最新的数据给浏览器。这种传统的模式有很明显的缺点,即浏览器需要不断的向服务器发出请求,而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,这样就会浪费很多资源。HTML5定义的WebSocket协议能更好的节省服务器带宽等资源,并能够实时地进行你通讯。
详情请看HTML5 WebSocket。
新建项目
打开IDEA,选择Spring Initializer
填写好相关信息:
依赖选择Spring Web
和WebSocket
之后选择Finish
即可。创建完毕后,项目目录结构如下:
WebSocket配置
首先我们配置一下WebSocket端点和消息代理。在com.andy.chat
包下创建一个名为config
的包,然后在config
包新增一个带有以下内容的类WebSocketConfig
:
package com.andy.chat.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); registry.enableSimpleBroker("/topic"); } }
@EnableWebSocketMessageBroker
注解用于启用WebSocket服务。 我们实现了WebSocketMessageBrokerConfigurer
接口,并覆盖了用于配置WebSocket连接的一些方法。
在第一个方法中,我们注册了一个WebSocket端点,客户端将使用该端点连接到我们的WebSocket服务器。端点配置中使用withSockJS()
方法,用于为不支持WebSocket
的浏览器启用备用选项。
你可能注意到方法名中带有STOMP。STOMP(Simple Text Orientated Messaging Protocol,简单文本定向消息协议)允许STOMP客户端与任意STOMP消息代理进行交互。
为什么需要STOMP?因为WebSocket只是一种通信协议。 它没有定义如何仅向订阅了特定主题的用户发送消息,或者如何向特定用户发送消息,所以需要STOMP来实现这些功能。举个例子,在课堂上举手就好比WebSocket代表的通信协议,因为它定义了在讲堂上如何和老师建立通信(举手)。而具体你想问老师的内容:“李老师,为什么这个函数不是连续的呢?”就相当于STOMP协议,因为它定义如何向特定用户发送消息(李老师)。
在第二个方法中,我们配置了一个消息代理,用于将消息从一个客户端路由到另一个客户端。第一行定义了以/app
开头为目标的消息应应路由到消息处理方法。第二行定义了以/topic
开头为目标的消息应路由到消息代理。消息代理广播消息到所有订阅了特定主题的所有连接的客户端。
上例中,我们使用了一个简单的内存消息代理。 也可以使用任何其他功能齐全的消息代理,例如RabbitMQ或ActiveMQ。
Message模型
Message
模型用来表示客户端与服务器之间的消息。在com.andy.chat
新建一个包model
,在model
包中新建Message
类:
package com.andy.chat.model; public class Message { private MessageType messageType; private String content; public MessageType getMessageType() { return messageType; } public void setMessageType(MessageType messageType) { this.messageType = messageType; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getSender() { return sender; } public void setSender(String sender) { this.sender = sender; } private String sender; public enum MessageType { JOIN,CHAT, LEAVE } }
收发消息的控制器
我们将在控制器中定义消息处理方法。在com.andy.chat
包下新建一个包controller
并在其中创建类ChatController
:
package com.andy.chat.controller; import com.andy.chat.model.Message; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Controller; @Controller public class ChatController { @MessageMapping("/sendMessage") @SendTo("/topic/public") public Message sendMessage(@Payload Message message) { return message; } @MessageMapping("addUser") @SendTo("/topic/public") public Message addUser(@Payload Message message, SimpMessageHeaderAccessor headerAccessor) { headerAccessor.getSessionAttributes().put("username", message.getSender()); return message; } }
正如在WebSocket配置一节所配置的,从客户端发送的所有以/app
开头的消息将被路由到这些消息处理方法,这些方法使用@MessageMapping
注解。
例如,以/app/sendMessage
为目标的消息被路由到sendMessage()
方法,以/app/addUser
为目标的消息被路由到addUser
方法。
添加WebSocket事件监听器
我们将使用事件监听器来监听Socket连接和断开事件,以便记录这些事件,并在用户加入或离开群聊时广播它们。
package com.andy.chat.controller; import com.andy.chat.model.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; @Component public class WebSocketEventListener { private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class); @Autowired private SimpMessageSendingOperations messageSendingOperations; @EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { logger.info("新连接"); } @EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) headerAccessor.getSessionAttributes().get("username"); if (username != null) { logger.info("用户{}取消连接", username); Message message = new Message(); message.setMessageType(Message.MessageType.LEAVE); message.setSender(username); messageSendingOperations.convertAndSend("/topic/public", message); } } }
在SessionDisconnect
事件中,从WebSocket
会话中提取用户名,并将用户离开事件广播到所有连接的客户端。
前端开发
在项目的static
下创建如下目录结构:
HTML文件用来显示用户群聊信息。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>群聊</title> <link rel="stylesheet" href="css/main.css"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> </head> <body> <div id="username-page"> <div class="username-page-container"> <h1 class="title">请输入您的用户名</h1> <form id="username-form"> <div class="form-group"> <input type="text" class="form-control" id="name" placeholder="用户名" autocomplete="off"> </div> <div class="form-group"> <button type="submit" class="username-submit accent">开始聊天</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"> 连接中..... </div> <ul id="message-area"> </ul> <form id="message-form"> <div class="form-group"> <div class="input-group clearfix"> <input type="text" class="form-control" id="message" placeholder="输入消息" autocomplete="off"> <button type="submit" class="primary">发送</button> </div> </div> </form> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script> <script src="js/main.js"></script> </body> </html>
我们在其中引入了两个javascript
库——sockjs
和stomp
。
SockJS
是一个WebSocket
客户端,它尝试使用WebSocket
,并为不支持WebSocket
的旧版浏览器提供备用选项。 stomp
是javascript
的STOMP客户端。
现在,我们来编写用于连接WebSocket
端点和收发信息的javascript
代码。将下列代码添加到main.js
文件中:
'use strict'; let usernamePage = document.querySelector("#username-page"); let chatPage = document.querySelector("#chat-page"); let usernameForm = document.querySelector("#username-form"); let messageForm = document.querySelector("#message-form"); let messageInput = document.querySelector("#message"); let messageArea = document.querySelector("#message-area"); let connectingElement = document.querySelector(".connecting"); let username = null; let stompClient = null; let 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('/ws'); stompClient = Stomp.over(socket); stompClient.connect({}, onConnected, onError); } event.preventDefault(); } function onConnected() { // 订阅Public主题 stompClient.subscribe("/topic/public", onMessageReceived); stompClient.send("/app/addUser", {}, JSON.stringify({ sender: username, messageType: 'JOIN' })); connectingElement.classList.add("hidden"); } function onError(error) { connectingElement.textContent = "连接不上WebSocket服务器,请刷新页面!"; connectingElement.style.color = "red"; } function sendMessage(event) { let messageContent = messageInput.value.trim(); if (messageContent && stompClient) { let message = { sender: username, content: messageContent, messageType: "CHAT" }; stompClient.send("/app/sendMessage", {}, JSON.stringify(message)); messageInput.value = ""; } event.preventDefault(); } function onMessageReceived(payload) { var message = JSON.parse(payload.body); var messageElement = document.createElement('li'); if(message.messageType === 'JOIN') { messageElement.classList.add('event-message'); message.content = message.sender + ' 加入!'; } else if (message.messageType === 'LEAVE') { messageElement.classList.add('event-message'); message.content = message.sender + ' 离开!'; } else { messageElement.classList.add('chat-message'); var avatarElement = document.createElement('i'); var avatarText = document.createTextNode(message.sender[0]); avatarElement.appendChild(avatarText); avatarElement.style['background-color'] = getAvatarColor(message.sender); messageElement.appendChild(avatarElement); var usernameElement = document.createElement('span'); var usernameText = document.createTextNode(message.sender); usernameElement.appendChild(usernameText); messageElement.appendChild(usernameElement); } var textElement = document.createElement('p'); var messageText = document.createTextNode(message.content); textElement.appendChild(messageText); messageElement.appendChild(textElement); messageArea.appendChild(messageElement); messageArea.scrollTop = messageArea.scrollHeight; } function getAvatarColor(messageSender) { var hash = 0; for (var i = 0; i < messageSender.length; i++) { hash = 31 * hash + messageSender.charCodeAt(i); } var index = Math.abs(hash % colors.length); return colors[index]; } usernameForm.addEventListener('submit', connect, true) messageForm.addEventListener('submit', sendMessage, true)
connect
方法用SockJS
和stomp
客户端连接到Spring Boot配置的/ws
端点。
添加如下代码到main.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 #message-form { 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; } #message-form .input-group input { float: left; width: calc(100% - 85px); } #message-form .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); } #message-form .input-group button { width: 65px; } #message-form .input-group input { width: calc(100% - 70px); } .chat-header { padding: 10px; } .connecting { top: 60px; } .chat-header h2 { font-size: 1.1em; } }
启动应用
若想启动该应用,在命令行执行以下命令(或者在IDEA中启动):
mvn spring-boot:run
应用将会在默认的8080端口启动,在浏览器访问http://localhost:8080即可。
使用Rabbit MQ
如果要使用RabbitMQ之类的功能全面的消息代理而不是简单的内存消息代理,我们需要先安装RabbitMQ,具体步骤可以参考下文:
然后配置STOMP
插件:
rabbitmq-plugins enable rabbitmq_stomp
在pom.xml文件中添加以下依赖项:
<!-- RabbitMQ Starter Dependency --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <!-- Following additional dependency is required for Full Featured STOMP Broker Relay --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-reactor-netty</artifactId> </dependency>
添加好以上依赖后,可以在WebSocketConfig.java
文件中启用RabbitMQ消息代理:
public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); // Use this for enabling a Full featured broker like RabbitMQ registry.enableStompBrokerRelay("/topic") .setRelayHost("localhost") .setRelayPort(61613) .setClientLogin("guest") .setClientPasscode("guest"); }
最后,正常启动应用即可。
参考
- https://www.callicoder.com/spring-boot-websocket-chat-example/
来源:https://www.cnblogs.com/viljw/p/12397221.html