前言

在前一篇文章SpringBoot 集成 STOMP 实现一对一聊天的两种方法中简单介绍了如何利用 STOMP 实现单聊,本文则将以一个比较完整的示例展示实际应用,不过本文并未使用 STOMP,而是使用了基础的 websocket 进行实现,如果想利用 STOMP 实现,参考上一篇文章稍加修改即可,此外,建议你阅读以下前置知识,如果比较熟悉就不再需要了:

  • 模拟 Tim 实现自定义的且可动态显示的滚动条
  • SpringBoot 集成 WebSocket 简单模拟群发通知
  • 云服务器安装redis及与SpringBoot的集成测试

此外为了展示方便,本文的聊天室整体实现还是比较简单,也没有进行一些身份验证,如果想要集成 JWT,可以参考SpringBoot + Vue 集成 JWT 实现 Token 验证,以后有机会再进行完善,下面就开始正式介绍具体的实现,本文代码同样已上传到GitHub。

效果

按照惯例,先展示一下最终的实现效果:

登录界面如下:

聊天效果如下:

实现思路

本文读写信息使用了 读扩散 的思路:将任意两人 A、B 的发送信息都存在一个 A-B(B-A) 信箱里,这样就可以在两人都在线时直接通过 websocket 发送信息,即使由于其中一人离线了,也可以在在线时从两人的信箱里拉取信息,而本文为了实现的方便则采用了 redis 存储信息,假设两人 id 分别为1,2,则以 "1-2" 字符串为键,两人的消息列表为值存储在 redis 中,这样就可以实现基本的单聊功能。

具体实现

由于本文主要是介绍基于 websocket 的聊天室实现,所以关于 redis 等的配置不做详细介绍,如果有疑惑,可以进行留言。

后端实现

首先是 ServerEndpointExporterBean 配置:

@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter(){return new ServerEndpointExporter();}}

然后是跨域和一些资源处理器的配置,本文未使用基于 nginx 的反向代理处理跨域,如果感兴趣可以看我之前的文章:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOrigins("*").allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE").allowedHeaders("*").maxAge(3600);}@Overrideprotected void addResourceHandlers(ResourceHandlerRegistry registry) {registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");super.addResourceHandlers(registry);}}

然后是为了使用 wss 协议而进行的 Tomcat 服务器配置,以便可以使用 https 协议:

@Configuration
public class TomcatConfiguration {@Beanpublic 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;}@Beanpublic TomcatContextCustomizer tomcatContextCustomizer() {return context -> context.addServletContainerInitializer(new WsSci(), null);}}

此外完整的应用配置文件如下:

spring:main:banner-mode: offdatasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/blog?serverTimezone=GMT%2B8&charset=utf8mb4&useSSL=falseusername: rootpassword: rootjpa:show-sql: trueproperties:hibernate:dialect: org.hibernate.dialect.MySQL5InnoDBDialectopen-in-view: false# 这里使用的是本地 windows 的 redis 连接# 想要配置个人服务器上的 redis, 可以参考前言中第三篇文章redis:database: 0host: localhostport: 6379lettuce:pool:max-active: 8max-wait: -1max-idle: 10min-idle: 5shutdown-timeout: 100msserver:port: 443ssl.key-store: classpath:static/keystore.jksssl.key-store-password: 123456ssl.key-password: 123456ssl.key-alias: tomcat

然后是 RedisTemplate 的配置:

@Configuration
public class RedisConfig {@Bean@Primarypublic 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;}}

以及对应的工具类,这里只包含两个本文使用的 getset 操作:

@Component
public class RedisUtil {private final RedisTemplate<String, Object> redisTemplate;@Autowiredpublic 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 {@Beanpublic Gson gson() {return new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();}@Beanpublic CustomSpringConfigurator customSpringConfigurator() {return new CustomSpringConfigurator();}}
public class CustomSpringConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware {private static volatile BeanFactory context;@Overridepublic <T> T getEndpointInstance(Class<T> clazz) {return context.getBean(clazz);}@Overridepublic 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;@Autowiredpublic 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;@Autowiredpublic 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 {// 发送者的 idprivate Long from;// 接受者的 idprivate Long to;// 具体信息private String message;// 发送时间private Date time;}

以及关于该消息实体的编码和解码器:

@Component
public class MessageEntityDecode implements Decoder.Text<MessageEntity> {@Overridepublic MessageEntity decode(String s) {// 利用 gson 处理消息实体,并格式化日期格式return new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create().fromJson(s, MessageEntity.class);}@Overridepublic boolean willDecode(String s) {return true;}@Overridepublic void init(EndpointConfig endpointConfig) {}@Overridepublic void destroy() {}}
public class MessageEntityEncode implements Encoder.Text<MessageEntity> {@Overridepublic String encode(MessageEntity messageEntity) {return new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create().toJson(messageEntity);}@Overridepublic void init(EndpointConfig endpointConfig) {}@Overridepublic 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<>();@Autowiredpublic WebSocketServer(Gson gson, RedisUtil redis) {this.gson = gson;this.redis = redis;}@OnOpenpublic void onOpen(@PathParam("id") Long id, Session session) {this.session = session;// 根据 /websocket/{id} 中传入的用户 id 作为键,存储每个用户的 sessionWEBSOCKET_MAP.put(id, session);}@OnMessagepublic 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));}}@OnClosepublic 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;}}}@OnErrorpublic void onError(Throwable error) {error.printStackTrace();}}

最后是两个控制器:

获取好友列表的控制器:

@RestController
public class GetFriendsController {private final UserService userService;@Autowiredpublic 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;@Autowiredpublic 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-inputv-model="loginForm.username"autocomplete="off"placeholder="用户名"prefix-icon="el-icon-user-solid"></el-input></el-form-item><el-form-item prop="password"><el-inputtype="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.datadelete user.passwordif (!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: {// 点击指定用户后,就获取两人之间的所有信息// 并将当前联系人保存在 localStorageset(user) {this.contact = userrequest({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 = indexdelete this.friendList[index].passwordthis.$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><textareaclass="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>

大功告成!

总结

由于个人的水平尚浅,本文的一些实现思路也只是作为练习使用,希望能到帮助到你,如果你有一些更好的思想思路,也欢迎留言交流。

SpringBoot + Vue 实现基于 WebSocket 的聊天室(单聊)相关推荐

  1. 基于WebSocket实现聊天室(Node)

    基于WebSocket实现聊天室(Node) WebSocket是基于TCP的长连接通信协议,服务端可以主动向前端传递数据,相比比AJAX轮询服务器,WebSocket采用监听的方式,减轻了服务器压力 ...

  2. java开发websocket聊天室_java实现基于websocket的聊天室

    [实例简介] java实现基于websocket的聊天室 [实例截图] [核心代码] chatMavenWebapp └── chat Maven Webapp ├── pom.xml ├── src ...

  3. SSM(五)基于webSocket的聊天室

    SSM(五)基于webSocket的聊天室 前言 不知大家在平时的需求中有没有遇到需要实时处理信息的情况,如站内信,订阅,聊天之类的.在这之前我们通常想到的方法一般都是采用轮训的方式每隔一定的时间向服 ...

  4. java websocket netty_用SpringBoot集成Netty开发一个基于WebSocket的聊天室

    前言 基于SpringBoot,借助Netty控制长链接,使用WebSocket协议做一个实时的聊天室. 项目效果 项目统一登录路径:http://localhost:8080/chat/netty ...

  5. springboot 使用 Spring Boot WebSocket 创建聊天室 2-11

    什么是 WebSocket WebSocket 协议是基于 TCP 的一种网络协议,它实现了浏览器与服务器全双工(Full-duplex)通信-允许服务器主动发送信息给客户端. 以前,很多网站为了实现 ...

  6. 基于webSocket的聊天室

    前言 不知大家在平时的需求中有没有遇到需要实时处理信息的情况,如站内信,订阅,聊天之类的.在这之前我们通常想到的方法一般都是采用轮训的方式每隔一定的时间向服务器发送请求从而获得最新的数据,但这样会浪费 ...

  7. 基于 WebSocket 的聊天室项目(下)

    1.创建一个聊天室的数据库 注意修改上一篇准备工作中写的配置文件中的数据库名称:: 创建数据库,如果不存在`websocket_chatroom`默认字符集`utf8`; 使用`websocket_c ...

  8. 使用Springboot+netty实现基于Web的聊天室

    一.项目创建 选择Spring Initializr 选择JDK版本 选择Spring Web 确定项目名称及保存路径 创建成功 二.编写代码 导入相关jar包及相关类的创建 下载地址: https: ...

  9. 用Springboot+netty实现基于Web的聊天室

    一.创建项目 java版本选8,选择web中的spring web 在pom.xml的dependencies加入以下代码 <dependency><groupId>io.ne ...

最新文章

  1. AIX 系统迁移安装
  2. ios面试数据结构与算法
  3. 生产订单结算KKS1常见错误
  4. Scala入门到精通——第二十八节 Scala与JAVA互操作
  5. php - preg_match
  6. python编码和解码_uu --- 对 uuencode 文件进行编码与解码 — Python 3.7.9 文档
  7. Vue系列vue-router的嵌套使用(四)
  8. [游戏服务器]第一章:多人聊天室-服务端
  9. 手机资料误删恢复有什么办法
  10. 西数480G绿盘SSD搬板,SM2258xt开卡成功,附量产工具
  11. FFMPEG使用摄像头录像并编码
  12. PHP 开源 ERP 系统 Discover
  13. 二寸证件照尺寸怎么调?这两个方法让你在家也能制作证件照
  14. Centos网络管理(三)-网络配置相关
  15. 深入浅出Yolo系列之Yolov5核心基础知识完整讲解
  16. work-stealing调度算法
  17. Windows睡眠或者休眠后无法唤醒问题的解决方案
  18. 暑期机器学习小组读书报告----机器学习概述
  19. 海上垂直无人机垂直起降平台
  20. RedHat 全部镜像

热门文章

  1. 张勋说:钢渣处理工艺流程图及解析
  2. 电子元器件品牌及其代理商
  3. 【狼人杀】初阶教学——基本规则
  4. view.setAlpha(float alpha)与view.getBackground().setAlpha(int alpha)的区别
  5. Qt中国象棋之棋子的实现
  6. SSL证书怎么购买?
  7. 前端嫌弃原生Swagger界面太low,于是我给她开通了超级VIP
  8. kaggle练习-共享单车数据
  9. SPSS——方差分析(Analysis of Variance, ANOVA)——多因素方差分析(无重复试验双因素)
  10. 经典智力题:经理年龄问题