一.简介
本文使用主要使用 springboot + stomp实现一个简单的web端的聊天室,至于springboot和stomp相关的知识,各位读者可以查阅相关的资料。案例在实现的过程中,并没有将数据进行持久化,而是直接放入到内存中,而且实现的也比较的简陋,欢迎各位的指正。
二.服务端代码
2.1 案例的结构

服务端代码结构
2.2 maven的依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>springboot-websocket</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-websocket</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
2.3 消息代理配置实现
package com.example.springbootwebsocket.config;
import com.example.springbootwebsocket.inteceptors.MessageStatusInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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 //开启websocket的消息代理
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
/**
* 客户单端在连接服务端的时候:http://localhost:8082/chat
*/
registry.addEndpoint("/chat") // 连接的地址
.setAllowedOrigins("*") //允许跨域
.withSockJS(); //开启SockJS
}
/**
* 配置客户端的输入
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 添加一个针对消息状态变化的拦截器
registration.interceptors(new MessageStatusInterceptor());
}
}
2.4 实体对象的定义
Message.java
public class Message {
private String msgId;
private String roomId;
private String username;
private String content;
private Date sendDate;
//setter and getter
}
UserInfo.java
public class UserInfo {
private String username;
private String roomId;
// setters and getters
}
2.5 数据存储对象定义
/**
* 用于数据的存储
*/
public class Constant {
/**
* key为房间号
* value是房间内的所有的人
*/
public static Map<String, Set<String>> usersInRoom = new ConcurrentHashMap<>();
/**
* 说明: 之所以定义该数据结构,是因为用户的退出的时候,无法在请求的头信息中获取到用户名和房间号。
* key是同道的id
* userInfo中包含了用户的房间号与用户名
*/
public static Map<String, UserInfo> channelId2UserInfo = new ConcurrentHashMap<>();
/**
* key是房间号
* value是房间内的所有的信息
*/
public static Map<String, List<Message>> messagesOfRoom = new ConcurrentHashMap<>();
}
2.6 通道状态拦截器定义
package com.example.springbootwebsocket.inteceptors;
import com.alibaba.fastjson.JSONObject;
import com.example.springbootwebsocket.entity.UserInfo;
import com.example.springbootwebsocket.constants.Constant;
import com.example.springbootwebsocket.handler.ApplicationContextHandler;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import java.util.HashSet;
import java.util.Set;
public class MessageStatusInterceptor implements ChannelInterceptor {
private SimpMessagingTemplate getSimpMessagingTemplate() {
return ApplicationContextHandler.context.getBean(SimpMessagingTemplate.class);
}
// 用户的上线、下线、发送信息都要经过该方法
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 可以通过 StompHeaderAccessor来获取用户的状态 (登录、退出、发送信息)
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// 连接的建立
if(StompCommand.CONNECT == accessor.getCommand()) {
MessageHeaders messageHeaders = message.getHeaders();
// nativeHeaders = {username=[zhangsan], roomId=[123456], accept-version=[1.1,1.0], heart-beat=[10000,10000]}
String values = messageHeaders.get("nativeHeaders").toString();
String[] array = values.split(",");
//截取用户名
String username = array[0].replace("{username=[", "").replace("]", "");
//截取用户的房间号
String roomId = array[1].trim().replace("roomId=[", "").replace("]", "");
Set<String> users = Constant.usersInRoom.get(roomId);
if(null == users) { //如果users为空,表示该用户是第一个进到房间的
users = new HashSet<>();
users.add(username);
Constant.usersInRoom.put(roomId, users);
}else { //房间中有人
users.add(username);
}
String channelId = accessor.getSessionId(); //获取同道的id
UserInfo userInfo = new UserInfo(username, roomId);
Constant.channelId2UserInfo.put(channelId, userInfo);
getSimpMessagingTemplate().convertAndSend("/room/" + roomId, JSONObject.toJSONString(users));
}
//连接的断开
if(StompCommand.DISCONNECT == accessor.getCommand()) {
MessageHeaders messageHeaders = message.getHeaders();
String channelId = accessor.getSessionId(); //获取同道的id
UserInfo userInfo = Constant.channelId2UserInfo.get(channelId);
String roomId = userInfo.getRoomId();
String username = userInfo.getUsername();
/**
* 1.将用户从房间移除掉
* 2.给房间中的所有的人发信息
*/
Set<String> usresInSepcifyRoom = Constant.usersInRoom.get(roomId); //获取房间内所有的用户
usresInSepcifyRoom.remove(username);
getSimpMessagingTemplate().convertAndSend("/room/" + roomId, JSONObject.toJSONString(usresInSepcifyRoom));
}
return message;
}
}
2.7 ApplicationContext的获取
之所以需要获取ApplicationContext,是因为在通道拦截器中需要使用SimpMessagingTemplate 对象,在消息代理配置中又需要拦截器,SimpMessagingTemplate的实例又需要使用到消息代理配置,会产生一个循环依赖的问题。所以我们手动去获取ApplicationContext,在需要用到SimpMessagingTemplate的时候,再去取,而不是在服务器启动的时候去注入。
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* XXXXXAware是spring中非常特殊的接口(XXXXProcessor); 这类接口是spring在启动的过程中
* 如果检测到你的bean实现了这些接口,那么spring在实例化这些bean之后,接着调用对应的方法,
* 传递给你所需要的参数。
*/
@Component
public class ApplicationContextHandler implements ApplicationContextAware {
public static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
ApplicationContextHandler.context = applicationContext;
}
}
2.8 控制器的编写
UsersInRoomController.java
package com.example.springbootwebsocket.controller;
import com.example.springbootwebsocket.constants.Constant;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;
// 用户在连接成功之后,需要去获取房间内的所有的用户,用于前端列表展示
@Controller
public class UsersInRoomController {
// 用户在简历连接之后,要获取到房间内所有的人
@SubscribeMapping("/connection/{roomId}")
public Object getUsers(@DestinationVariable("roomId") String roomId) {
return Constant.usersInRoom.get(roomId);
}
}
MessageController.java
package com.example.springbootwebsocket.controller;
import com.alibaba.fastjson.JSONObject;
import com.example.springbootwebsocket.constants.Constant;
import com.example.springbootwebsocket.entity.Message;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Controller
public class MessageController {
private SimpMessagingTemplate simpMessagingTemplate;
public MessageController(SimpMessagingTemplate simpMessagingTemplate) {
this.simpMessagingTemplate = simpMessagingTemplate;
}
// @SubscribeMapping 是当前谁调用,返回的数据就给谁
// 该接口的作用是, 当用户进入房间后, 获取该房间内的所有聊天记录
@SubscribeMapping("/room/allMessage/{roomId}")
public Object getAllMessages(@DestinationVariable("roomId") String roomId) {
List<Message> messageList = Constant.messagesOfRoom.get(roomId);
if(null == messageList) {
messageList = new ArrayList<>();
}
return messageList;
}
// 用户发送信息,给指定房间内的所有的用户发送信息
@MessageMapping("/room/{roomId}/{username}")
public void sendMsg(@DestinationVariable("roomId") String roomId,
@DestinationVariable("username") String username, String text) {
Message message = new Message();
message.setMsgId(UUID.randomUUID().toString());
message.setContent(text);
message.setRoomId(roomId);
message.setUsername(username);
message.setSendDate(new Date());
// 取出对应房间的所有的聊天记录
List<Message> messageList = Constant.messagesOfRoom.get(roomId);
if(null == messageList) { //房间内的首次发信息
messageList = new ArrayList<>(50000);
messageList.add(message);
Constant.messagesOfRoom.put(roomId, messageList);
}else {
messageList.add(message);
}
// /room/" + roomId + "/message
simpMessagingTemplate.convertAndSend("/room/" + roomId + "/message", JSONObject.toJSONString(message));
}
}
2.9 启动类
package com.example.springbootwebsocket;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class SpringbootWebsocketApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(SpringbootWebsocketApplication.class, args);
// Stream.of(ctx.getBeanDefinitionNames()).forEach(n -> System.out.println(n));
}
}
三.前端页面
各位读者请勿喷,笔者前端的布局能力不是很强,前端看起来会有些乱,但是基本的布局还是有的。
3.1 前端代码结构

前端代码结构
3.2 前端页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天页面</title>
<!-- bootstrap4 -->
<link href="./css/bootstrap.css" rel="stylesheet" type="text/css" />
<!-- 聊天页面的布局 -->
<link href="./css/chat.css" rel="stylesheet" type="text/css" />
<script src="./js/vue.js"></script>
<!-- SockJS 是websocket的备选方案,如果浏览器不支持websocket那么,就会降级为轮训的方式 -->
<script src="./js/sockjs.min.js"></script>
<!-- 是websocket的上传协议,操作更加方便 -->
<script src="./js/stomp.js"></script>
<script src="./js/moment.min.js"></script>
<script src="./js/jquery-3.3.1.min.js"></script>
<script src="./js/bootstrap.min.js"></script>
</head>
<body>
<!-- <router-view></router-view>-->
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-7">
<form class="form-inline" onsubmit="javascript: return false;">
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text">房间号</div>
</div>
<input class="form-control" id="roomId" placeholder="请输入房间号">
</div>
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text">用户名</div>
</div>
<input class="form-control" id="username" placeholder="请输入用户名">
</div>
<button type="submit" onclick="loginIntoRoom()" class="btn btn-primary">连接</button>
</form>
</div>
</div>
</div>
<div id="app" class="row justify-content-center" style="margin-top: 10px;">
<div class="col-2">
<div class="card">
<div class="card-header chat-header">
<h5 class="chat-title">在线用户列表</h5>
</div>
<div class="card-body">
<ul id="userList" class="list-group">
<li class="list-group-item" v-for="u in userList" :key="u">{{u}}</li>
</ul>
</div>
</div>
</div>
<div class="col-5" style="padding-left: 0;">
<div class="card" style="height: 600px;">
<div class="card-header chat-header">
<h5 class="chat-title">聊天区</h5>
</div>
<div class="card-body chat-content">
<!-- block -->
<!-- {message: chatContent, username: username, sendDate: moment().format('YYYY-MM-DD HH:mm:ss')} --->
<!-- 别人的聊天信息 -->
<template v-for="msg in messageList" :key="msg.msgId">
<div v-if="msg.username != username" class="clearfix mb-6 fx">
<img class="avarta" src="http://localhost/5677yyufe.jpg">
<div class="other-chat-info">
<span class="name-st">{{msg.username}} {{msg.sendDate | formatDate}}</span>
<div class="content-other">
<!-- 白色的小三角 -->
<i class="triangle-common left-4 triangle-left"></i>
{{msg.content}}
</div>
</div>
</div>
<div v-else class="clearfix mb-6 self-chat-panel">
<!-- 自己的聊天内容 -->
<div class="self-chat-info">
<p class="self-name">{{msg.username}} {{msg.sendDate | formatDate}}</p>
<div class="content-self">
<!-- 绿色的小三角 -->
<i class="triangle-common right-4 triangle-right"></i>
{{msg.content}}
</div>
</div>
<img class="float-right avarta" src="http://localhost/5677yyufe.jpg">
</div>
</template> <!-- vue、微信小程序(block)、react前端在做逻辑判断、循环的时候采取去 -->
</div>
</div>
<div class="card chat-box">
<!-- 输入聊天内容 -->
<textarea id="chatContent" class="chat-content"></textarea>
<button class="send-btn btn-success" onclick="sendMsg()">Send</button>
</b-card-body>
</div>
</div>
</div>
<script>
// 定义一个过滤器
Vue.filter('formatDate', (value) => {
return moment(value).format('YYYY-MM-DD HH:mm:ss')
})
var vm = new Vue({
el: '#app',
data() {
return {
userList: [],
username: '',
messageList: [] //所有的消息
}
}
})
// 如果使用原生 Websocket, 那么连接的形式:ws://localhost:8082/XXX
// 我们使用SockJS
var wsUrl = "http://localhost:8080/chat";
var client = null;
// 登录到具体的房间
function loginIntoRoom() {
// 创建 SockJS对象
var sockJs = new SockJS(wsUrl);
// 连接对象
client = Stomp.over(sockJs); //
var username = document.getElementById("username").value;
var roomId = document.getElementById("roomId").value;
var headers = { //构建websocket请求头信息
username: username,
roomId: roomId
}
client.connect(headers, () => {
vm.username = username;
// 用户上线获取所有的用户
client.subscribe("/connection/" + roomId, (_data) => {
vm.userList = [];
var userList = JSON.parse(_data.body); //取得用户的列表
for(var i = 0; i < userList.length; i++) {
vm.userList.push(userList[i])
}
})
// 用户上线之后,获取所有的聊天记录
client.subscribe("/room/allMessage/" + roomId, (_data) => {
vm.messageList = [];
var messages = JSON.parse(_data.body); //获取所有的信息
for(var i = 0;i < messages.length; i++) {
vm.messageList.push(messages[i]);
}
})
// 其他人上线了,传递过来的用户列表
client.subscribe("/room/" + roomId, (_data) => {
vm.userList = [];
var userList = JSON.parse(_data.body); //取得用户的列表
for(var i = 0; i < userList.length; i++) {
vm.userList.push(userList[i])
}
})
// 监听的当前房间号的聊天信息
client.subscribe("/room/" + roomId + "/message", (_data) => {
var body = _data.body; //获取聊天信息
vm.messageList.push((JSON.parse(body)));
})
})
}
// 用户点击发送按钮,发送信息
function sendMsg() {
var content = document.getElementById("chatContent").value;
var roomId = document.getElementById("roomId").value;
var username = document.getElementById("username").value;
document.getElementById("chatContent").value = '';
client.send("/room/" + roomId + "/" + username, {}, content);
}
</script>
</body>
</html>
3.3 chat.css内容
.fx{
display: flex;
}
.chat-header {
padding: 0.25rem 1.25rem !important;
}
.connect-btn {
position: relative;
top: 2px;
}
.avarta {
height: 36px;
width: 36px;
}
.chat-title {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
}
.chat-content {
padding-left: 5px !important;
padding-right: 5px !important;
background-color: #e3e3e3;
width: 100%;
}
/** 其他人的聊天信息布局,名字以及聊天内容布局 */
.other-chat-info{
margin-left: 8px;
position: relative;
top: -5px;
max-width: 60%;
}
.name-st{
color: #428BCA;
}
/** 自己聊天信息布局 */
.self-chat-panel {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.self-chat-info {
margin-right: 8px;
position: relative;
top: -5px;
max-width: 60%;
}
.self-name {
color: #428BCA;
text-align: right;
margin: 0;
}
/* 左边偏 -8px */
.left-4 {
left: -4px;
}
/* 右边偏-8px */
.right-4 {
right: -4px;
}
.mb-6 {
margin-bottom: 6px;
}
/** 聊天内容样式通用样式 */
.content-other {
background-color: #ffffff;
border-radius: 4px;
padding: 4px;
font-weight: 500;
position: relative;
width: 100%;
}
/** 左右三角的通用样式 */
.triangle-common {
position: absolute;
border-top: solid 4px transparent;
border-bottom: solid 4px transparent;
display: block;
top: 4px;
}
/* 左三角 */
.triangle-left {
border-right: solid 6px #ffffff;
}
/** 右三角 */
.triangle-right {
border-left: solid 6px #85DD45;
}
/** 本人聊天内容展示效果 */
.content-self {
background: #85DD45;
border-radius: 4px;
display: inline-block;
padding: 4px;
float: right;
font-weight: 500;
position: relative;
width: 100%;
}
.chat-box {
padding: 0 !important;
position: relative;
}
.chat-content {
height: 100%;
width: 100%;
resize: none;
}
/* 发送按钮 */
.send-btn {
position: absolute;
bottom: 5px;
right: 5px;
}
四.效果展示

当前用户-正井猫

当前用户-大佬