前言
在前一篇文章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>
大功告成!
总结
由于个人的水平尚浅,本文的一些实现思路也只是作为练习使用,希望能到帮助到你,如果你有一些更好的思想思路,也欢迎留言交流。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/5373.html