SpringBoot + Vue 实现基于 WebSocket 的聊天室
前言
在前一篇文章SpringBoot 集成 STOMP 实现一对一聊天的两种方法中简单介绍了如何利用 STOMP
实现单聊,本文则将以一个比较完整的示例展示实际应用,不过本文并未使用 STOMP
,而是使用了基础的 websocket
进行实现,如果想利用 STOMP
实现,参考上一篇文章稍加修改即可,此外,建议你阅读以下前置知识,如果比较熟悉就不再需要了:
此外为了展示方便,本文的聊天室整体实现还是比较简单,也没有进行一些身份验证,如果想要集成 JWT
,可以参考SpringBoot + Vue 集成 JWT 实现 Token 验证,以后有机会再进行完善,下面就开始正式介绍具体的实现,本文代码同样已上传到GitHub。
效果
按照惯例,先展示一下最终的实现效果:
登录界面如下:
聊天效果如下:
实现思路
本文读写信息使用了 读扩散
的思路:将任意两人 A、B
的发送信息都存在一个 A-B(B-A)
信箱里,这样就可以在两人都在线时直接通过 websocket
发送信息,即使由于其中一人离线了,也可以在在线时从两人的信箱里拉取信息,而本文为了实现的方便则采用了 redis
存储信息,假设两人 id
分别为1,2,则以 "1-2"
字符串为键,两人的消息列表为值存储在 redis
中,这样就可以实现基本的单聊功能。
具体实现
由于本文主要是介绍基于 websocket
的聊天室实现,所以关于 redis
等的配置不做详细介绍,如果有疑惑,可以进行留言。
后端实现
首先是 ServerEndpointExporter
的 Bean
配置:
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } }
然后是跨域和一些资源处理器的配置,本文未使用基于 nginx
的反向代理处理跨域,如果感兴趣可以看我之前的文章:
@Configuration public class WebConfig extends WebMvcConfigurationSupport { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE") .allowedHeaders("*") .maxAge(3600); } @Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**") .addResourceLocations("classpath:/static/"); super.addResourceHandlers(registry); } }
然后是为了使用 wss
协议而进行的 Tomcat
服务器配置,以便可以使用 https
协议:
@Configuration public class TomcatConfiguration { @Bean public ServletWebServerFactory servletContainer() { TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory(); tomcat.addAdditionalTomcatConnectors(createSslConnector()); return tomcat; } private Connector createSslConnector() { Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); connector.setScheme("http"); connector.setPort(8888); connector.setSecure(false); connector.setRedirectPort(443); return connector; } @Bean public TomcatContextCustomizer tomcatContextCustomizer() { return context -> context.addServletContainerInitializer(new WsSci(), null); } }
此外完整的应用配置文件如下:
spring: main: banner-mode: off datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/blog?serverTimezone=GMT%2B8&charset=utf8mb4&useSSL=false username: root password: root jpa: show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQL5InnoDBDialect open-in-view: false # 这里使用的是本地 windows 的 redis 连接 # 想要配置个人服务器上的 redis, 可以参考前言中第三篇文章 redis: database: 0 host: localhost port: 6379 lettuce: pool: max-active: 8 max-wait: -1 max-idle: 10 min-idle: 5 shutdown-timeout: 100ms server: port: 443 ssl.key-store: classpath:static/keystore.jks ssl.key-store-password: 123456 ssl.key-password: 123456 ssl.key-alias: tomcat
然后是 RedisTemplate
的配置:
@Configuration public class RedisConfig { @Bean @Primary public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 配置键的字符串序列化解析器 template.setKeySerializer(new StringRedisSerializer()); // 配置值的对象序列化解析器 template.setValueSerializer(valueSerializer()); template.afterPropertiesSet(); return template; } private RedisSerializer<Object> valueSerializer() { // 对值的对象解析器的一些具体配置 Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(objectMapper); return serializer; } }
以及对应的工具类,这里只包含两个本文使用的 get
、set
操作:
@Component public class RedisUtil { private final RedisTemplate<String, Object> redisTemplate; @Autowired public RedisUtil(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } public List<Object> get(String key) { // 获取信箱中所有的信息 return redisTemplate.opsForList().range(key, 0, -1); } public void set(String key, Object value) { // 向正在发送信息的任意两人的信箱中中添加信息 redisTemplate.opsForList().rightPush(key, value); } }
然后是自定义的 Spring
上下文处理的配置,这里是为了防止 WebSocket
启用时无法正确的加载上下文:
@Configuration @ConditionalOnWebApplication public class AppConfig { @Bean public Gson gson() { return new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create(); } @Bean public CustomSpringConfigurator customSpringConfigurator() { return new CustomSpringConfigurator(); } }
public class CustomSpringConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware { private static volatile BeanFactory context; @Override public <T> T getEndpointInstance(Class<T> clazz) { return context.getBean(clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { CustomSpringConfigurator.context = applicationContext; } }
简单展示了以上一些基本的配置后,再来介绍对数据的存储和处理部分,为了简便数据库的操作,本文使用了 Spring JPA
。
首先展示用户类:
@Data @Entity @NoArgsConstructor @AllArgsConstructor @Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(length = 32) private String username; @Column(length = 64) private String password; }
然后是为了方便登录时简单验证的 dao
:
@Repository public interface UserDao extends JpaRepository<User, Long> { User findByUsernameAndPassword(String userName, String password); }
以及对应的 service
:
@Service public class UserService { private final UserDao dao; @Autowired public UserService(UserDao dao) { this.dao = dao; } public User findById(Long uid) { return dao.findById(uid).orElse(null); } public User findByUsernameAndPassword(String username, String password) { return dao.findByUsernameAndPassword(username, password); } public List<User> getFriends(Long uid) { // 这里为了简化整个程序,就在这里模拟用户获取好友列表的操作 // 就不通过数据库来存储好友关系了 return LongStream.of(1L, 2L, 3L, 4L) .filter(item -> item != uid) .mapToObj(this::findById) .collect(Collectors.toList()); } }
对应的登录控制器如下:
@RestController public class LoginInController { private final UserService userService; @Autowired public LoginInController(UserService userService) { this.userService = userService; } @PostMapping("/login") public User login(@RequestBody LoginEntity loginEntity) { return userService.findByUsernameAndPassword(loginEntity.getUsername(), loginEntity.getPassword()); } }
LoginEntity
是对登录信息进行的简单封装,方便处理,代码如下:
@Data @NoArgsConstructor @AllArgsConstructor public class LoginEntity { private String username; private String password; }
另外再提前展示一下消息实体的封装:
@Data @NoArgsConstructor @AllArgsConstructor public class MessageEntity { // 发送者的 id private Long from; // 接受者的 id private Long to; // 具体信息 private String message; // 发送时间 private Date time; }
以及关于该消息实体的编码和解码器:
@Component public class MessageEntityDecode implements Decoder.Text<MessageEntity> { @Override public MessageEntity decode(String s) { // 利用 gson 处理消息实体,并格式化日期格式 return new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create() .fromJson(s, MessageEntity.class); } @Override public boolean willDecode(String s) { return true; } @Override public void init(EndpointConfig endpointConfig) {} @Override public void destroy() {} }
public class MessageEntityEncode implements Encoder.Text<MessageEntity> { @Override public String encode(MessageEntity messageEntity) { return new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create() .toJson(messageEntity); } @Override public void init(EndpointConfig endpointConfig) {} @Override public void destroy() {} }
然后就是最主要的 websocket
服务器的配置了:
@Component // 配置 websocket 的路径 @ServerEndpoint( value = "/websocket/{id}", decoders = { MessageEntityDecode.class }, encoders = { MessageEntityEncode.class }, configurator = CustomSpringConfigurator.class ) public class WebSocketServer { private Session session; private final Gson gson; private final RedisUtil redis; // 存储所有的用户连接 private static final Map<Long, Session> WEBSOCKET_MAP = new ConcurrentHashMap<>(); @Autowired public WebSocketServer(Gson gson, RedisUtil redis) { this.gson = gson; this.redis = redis; } @OnOpen public void onOpen(@PathParam("id") Long id, Session session) { this.session = session; // 根据 /websocket/{id} 中传入的用户 id 作为键,存储每个用户的 session WEBSOCKET_MAP.put(id, session); } @OnMessage public void onMessage(MessageEntity message) throws IOException { // 根据消息实体中的消息发送者和接受者的 id 组成信箱存储的键 // 按两人id升序并以 - 字符分隔为键 String key = LongStream.of(message.getFrom(), message.getTo()) .sorted() .mapToObj(String::valueOf) .collect(Collectors.joining("-")); // 将信息存储到 redis 中 redis.set(key, message); // 如果用户在线就将信息发送给指定用户 if (WEBSOCKET_MAP.get(message.getTo()) != null) { WEBSOCKET_MAP.get(message.getTo()).getBasicRemote().sendText(gson.toJson(message)); } } @OnClose public void onClose() { // 用户退出时,从 map 中删除信息 for (Map.Entry<Long, Session> entry : WEBSOCKET_MAP.entrySet()) { if (this.session.getId().equals(entry.getValue().getId())) { WEBSOCKET_MAP.remove(entry.getKey()); return; } } } @OnError public void onError(Throwable error) { error.printStackTrace(); } }
最后是两个控制器:
获取好友列表的控制器:
@RestController public class GetFriendsController { private final UserService userService; @Autowired public GetFriendsController(UserService userService) { this.userService = userService; } @PostMapping("/getFriends") public List<User> getFriends(@RequestParam("id") Long uid) { return userService.getFriends(uid); } }
用户获取好友之间信息的控制器:
@RestController public class PullMessageController { private final RedisUtil redis; @Autowired public PullMessageController(RedisUtil redis) { this.redis = redis; } @PostMapping("/pullMsg") public List<Object> pullMsg(@RequestParam("from") Long from, @RequestParam("to") Long to) { // 根据两人的 id 生成键,并到 redis 中获取 String key = LongStream.of(from, to) .sorted() .mapToObj(String::valueOf) .collect(Collectors.joining("-")); return redis.get(key); } }
以上便是所有的后端配置代码,下面再介绍前端的实现。
前端实现
首先是网络请求的封装,我使用的是 axios
:
export default 'https://localhost' // const.js 内容
import axios from 'axios' import api from './const' export function request(config) { const req = axios.create({ baseURL: api, timeout: 5000 }) return req(config) }
然后是路由的配置:
import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) const Login = () => import('@/views/Login') const Chat = () => import('@/views/Chat') const routes = [ { path: '/', redirect: '/chat' }, { path:'/login', name:'Login', component: Login }, { path:'/chat', name:'Chat', component: Chat } ] const router = new VueRouter({ mode: 'history', routes }) // 添加全局的前置导航守卫 // 如果没有在本地 localStorage 中得到用户信息 // 说明用户未登录, 直接跳转到登录界面 router.beforeEach(((to, from, next) => { let tmp = localStorage.getItem('user') const user = tmp && JSON.parse(tmp) if (to.path !== '/login' && !user) { next('/login') } next() })) export default router
这里先说一下,为了简化整个程序,并没有采用
Vuex
或者是 store模式去存储一些用户信息和之后的联系人信息,而是直接全部使用本地localStorage
进行存储了。
然后是登录界面,这里为了简洁省略了样式代码:
<template> <el-row type="flex" class="login"> <el-col :span="6"> <h1 class="title">聊天室</h1> <el-form :model="loginForm" :rules="rules" status-icon ref="ruleForm" class="demo-ruleForm"> <el-form-item prop="username"> <el-input v-model="loginForm.username" autocomplete="off" placeholder="用户名" prefix-icon="el-icon-user-solid" ></el-input> </el-form-item> <el-form-item prop="password"> <el-input type="password" v-model="loginForm.password" autocomplete="off" placeholder="请输入密码" prefix-icon="el-icon-lock" ></el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="submitForm" class="login-btn">登录</el-button> </el-form-item> </el-form> </el-col> </el-row> </template> <script> import { Row, Col, Form, Input, Button, Loading, Message, FormItem } from 'element-ui' import {request} from '@/network' export default { name: 'Login', components: { 'el-row': Row, 'el-col': Col, 'el-form': Form, 'el-input': Input, 'el-button': Button, 'el-form-item': FormItem }, data() { return { loginForm: { username: '', password: '' }, rules: { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] } } }, methods: { submitForm() { const loading = Loading.service({ fullscreen: true }) request({ method: 'post', url: '/login', data: { 'username': this.loginForm.username, 'password': this.loginForm.password } }).then(res => { loading.close() let user = res.data.data delete user.password if (!user) { Message('用户名或密码错误') return } // 登录成功将用户的信息存在 localStorage, 并跳转到聊天界面 localStorage.setItem('user', JSON.stringify(user)) this.$router.push('/chat') Message('登录成功') }).catch(err => { console.log(err) }) } } } </script>
聊天界面如下:
<template> <div id="app"> <div class="main"> <Contact @set-contact="set"/> <Dialog :contact="contact" :msgList="msgList"/> </div> </div> </template> <script> import {request} from '@/network' import Contact from '@/components/Contact' import Dialog from '@/components/Dialog' export default { name: "Chat", components: { Dialog, Contact }, data() { return { contact: null, msgList: [] } }, methods: { // 点击指定用户后,就获取两人之间的所有信息 // 并将当前联系人保存在 localStorage set(user) { this.contact = user request({ method: 'post', url: '/pullMsg', params: { from: JSON.parse(localStorage.getItem('user')).id, to: this.contact.id } }).then(res => { this.msgList = res.data.data }).catch(err => { console.log(err) }) } } } </script>
然后是聊天界面使用的两个组件,首先是左边的好友列表栏:
<template> <div class="contact"> <div class="top"> <div class="left"> <img class="avatar" :src="`${api}/static/img/${user.id}.jpg`" alt=""/> </div> <div class="right"> {{ user.username }} </div> </div> <div v-if="friendList.length" class="bottom"> <div v-for="(friend, i) in friendList" class="friend" :class="{activeColor: isActive(i)}" @click="setContact(i)"> <div class="left"> <img class="avatar" :src="`${api}/static/img/${friend.id}.jpg`" alt=""/> </div> <div class="right"> {{ friend.username }} </div> </div> </div> <div v-else class="info"> <div class="msg"> 还没有好友~~~ </div> </div> </div> </template> <script> import api from '@/network/const' import {request} from '@/network' export default { name: 'Contact', data() { return { api: api, active: -1, friendList: [] } }, mounted() { // 界面渲染时获取用户的好友列表并展示 request({ method: 'post', url: '/getFriends', params: { id: this.user.id } }).then(res => { this.friendList = res.data.data }).catch(err => { console.log(err) }) }, computed: { user() { return JSON.parse(localStorage.getItem('user')) } }, methods: { setContact(index) { this.active = index delete this.friendList[index].password this.$emit('set-contact', this.friendList[index]) }, isActive(index) { return this.active === index } } } </script>
以及聊天框的组件:
<template> <div v-if="contact" class="dialog"> <div class="top"> <div class="name"> {{ contact.username }} </div> </div> <div class="middle" @mouseover="over" @mouseout="out"> <div v-if="msgList.length"> <div v-for="msg in msgList"> <div class="msg" :style="msg.from === contact.id ? 'flex-direction: row;' : 'flex-direction: row-reverse;'"> <div class="avatar"> <img alt="" :src="`${api}/static/img/${msg.from}.jpg`"/> </div> <div v-if="msg.from === contact.id" style="flex: 13;"> <div class="bubble-msg-left" style="margin-right: 75px;"> {{ msg.message }} </div> </div> <div v-else style="flex: 13;"> <div class="bubble-msg-right" style="margin-left: 75px;"> {{ msg.message }} </div> </div> </div> </div> </div> </div> <div class="line"></div> <div class="bottom"> <label> <textarea class="messageText" maxlength="256" v-model="msg" :placeholder="hint" @keydown.enter="sendMsg($event)" ></textarea> </label> <button class="send" :class="{emptyText: isEmptyText}" title="按下 ENTER 发送" @click="sendMsg()">发送</button> </div> </div> <div v-else class="info"> <div class="msg"> 找个好友聊天吧~~~ </div> </div> </template> <script> import api from '@/network/const' import {request} from '@/network' export default { name: "Dialog", props: { contact: { type: Object }, msgList: { type: Array } }, mounted() { // 渲染界面时, 根据用户的 id 获取 websocket 连接 this.socket = new WebSocket(`wss://localhost/websocket/${JSON.parse(localStorage.getItem('user')).id}`) this.socket.onmessage = event => { this.msgList.push(JSON.parse(event.data)) } // 为防止网络和其他一些原因,每隔一段时间自动从信箱中获取信息 this.interval = setInterval(() => { if (this.contact && this.contact.id) { request({ method: 'post', url: '/pullMsg', params: { from: JSON.parse(localStorage.getItem('user')).id, to: this.contact.id } }).then(res => { this.msgList = res.data.data }).catch(err => { console.log(err) }) } }, 15000) }, beforeDestroy() { // 清楚定时器的设置 !this.interval &&clearInterval(this.interval) }, data() { return { msg: '', hint: '', api: api, socket: null, bubbleMsg: '', interval: null, isEmptyText: true } }, watch: { msgList() { // 保证滚动条(如果存在), 始终在最下方 const mid = document.querySelector('.middle') this.$nextTick(() => { mid && (mid.scrollTop = mid.scrollHeight) document.querySelector('.messageText').focus() }) }, msg() { this.isEmptyText = !this.msg } }, methods: { over() { this.setColor('#c9c7c7') }, out() { this.setColor('#0000') }, setColor(color) { document.documentElement.style.setProperty('--scroll-color', `${color}`) }, sendMsg(e) { if (e) { e.preventDefault() } if (!this.msg) { this.hint = '信息不可为空!' return } let entity = { from: JSON.parse(localStorage.getItem('user')).id, to: this.contact.id, message: this.msg, time: new Date() } this.socket.send(JSON.stringify(entity)) this.msgList.push(entity) this.msg = '' this.hint = '' } } } </script>
大功告成!
总结
由于个人的水平尚浅,本文的一些实现思路也只是作为练习使用,希望能到帮助到你,如果你有一些更好的思想思路,也欢迎留言交流。
本文作者:庄周de蝴蝶
本文链接:https://www.cnblogs.com/butterfly-fish/p/17762293.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步