1,前言
不管是在线商城还是聊天软件,都需要一个实时的聊天系统,我们应该如何实现实时的聊天系统,可以方便我们实时聊天,对商品的一些了解等。
2,技术
java + spring boot + WebSocket +vue
3,效果展示
4,实现步骤
1,前端实现
<template>
<div>
<van-nav-bar
:title="toname"
left-arrow
@click-left="onClickLeft"
@click-right="onClickRight"
>
<template #right>
<van-icon name="ellipsis" size="18" />
</template>
</van-nav-bar>
<van-form @submit="websocketsend">
<van-notice-bar
:scrollable="true"
left-icon="volume-o"
text="提示:歡迎來到聊天內側版,同步最近历史消息可点击···,請愉快的玩耍吧!。"
/>
<van-grid :column-num="1" :border="false">
<van-grid-item>
<van-image
width="6rem"
height="6rem"
fit="contain"
src="https://www.pngdirs.com/thumb/350/orange/orange-9864.png"
/>
<h3>CHENGO FRUIT</h3>
</van-grid-item>
</van-grid>
<div class="chat-content">
<!-- recordContent 聊天记录数组-->
<div v-for="(itemc, indexc) in recordContent" :key="indexc">
<!-- 对方 -->
<div class="word" v-if="!itemc.mineMsg">
<van-row>
<van-col span="3" style="padding-top: 16px; text-align: right">
<van-image
round
fit="contain"
:src="imgUrl"
width="3rem"
height="3rem"
/>
</van-col>
<van-col span="21">
<div class="yinfo">
<p class="time">{{ itemc.nickName }} {{ itemc.timestamp }}</p>
<div class="yinfo-content">
<span v-if="itemc.type == 'video'">
{{ itemc.contactText }}
</span>
<span
v-else-if="itemc.type == 'img'"
@click="preViewImg(itemc.contactText)"
>
<van-image
fit="cover"
:src="itemc.contactText"
width="8rem"
height="100%"
/>
</span>
<span v-else>
{{ itemc.contactText }}
</span>
</div>
</div>
</van-col>
</van-row>
</div>
<!-- 我的 -->
<div class="word-my" v-else>
<van-row>
<van-col span="21">
<div class="info">
<p class="time">{{ itemc.nickName }} {{ itemc.timestamp }}</p>
<van-popover
v-model="itemc.cancellMessage"
trigger="click"
placement="top"
>
<van-grid
square
clickable
:border="false"
column-num="3"
style="width: 163px"
>
<van-grid-item
text="撤回"
icon="revoke"
@click="cancellMsg(itemc.id)"
/>
<van-grid-item
text="取消"
icon="cross"
@click="cancell(itemc)"
/>
<van-grid-item
text="引用"
icon="replay"
@click="cancell(itemc)"
/>
</van-grid>
<template #reference>
<div
class="info-content"
@touchstart="getTouchStart(itemc)"
@getTouchEnd="getTouchEnd(itemc)"
>
<span v-if="itemc.type == 'video'">
{{ itemc.contactText }}
</span>
<span
v-else-if="itemc.type == 'img'"
@click="preViewImg(itemc.contactText)"
>
<van-image
fit="cover"
:src="itemc.contactText"
width="8rem"
height="100%"
/>
</span>
<span v-else>
{{ itemc.contactText }}
</span>
</div>
</template>
</van-popover>
</div>
</van-col>
<van-col span="3" style="padding-top: 16px">
<van-image
round
fit="contain"
:src="userInfo.imgUrl"
width="3rem"
height="3rem"
/></van-col>
</van-row>
<van-row>
<van-col span="21">
<div class="status">{{ itemc.status }}</div>
</van-col>
<van-col span="3"> </van-col>
</van-row>
</div>
</div>
</div>
<div style="margin: 0px 0px">
<div style="margin: 5px">
<!-- <van-button round block type="primary" @click="getEmo()">
选择表情
</van-button> -->
<van-field
v-model="chatContent"
center
clearable
placeholder="输入新消息"
>
<template #button>
<van-button size="small" type="primary" round color="#25d4d0">发送</van-button>
</template>
</van-field>
<van-grid
square
clickable
:border="false"
column-num="5"
style="margin-top: 5px"
>
<van-grid-item key="1" text="照片" icon="photo-o">
<van-uploader
v-model="uploader"
:after-read="afterRead"
:max-count="1"
>
<van-icon name="photo-o" size="1.5rem"> </van-icon>
<div style="font-size: 12px; text-align: center">照片</div>
</van-uploader>
</van-grid-item>
<van-grid-item key="3" text="位置" icon="location-o">
<van-icon name="location-o" size="1.5rem"> </van-icon>
<div style="font-size: 12px; text-align: center">位置</div>
</van-grid-item>
<van-grid-item key="4" text="语言输入" icon="volume-o">
<van-icon name="volume-o" size="1.5rem"> </van-icon>
<div style="font-size: 12px; text-align: center">语言输入</div>
</van-grid-item>
<van-grid-item key="6" text="文件" icon="desktop-o">
<van-icon name="desktop-o" size="1.5rem"> </van-icon>
<div style="font-size: 12px; text-align: center">
文件
</div></van-grid-item
>
<van-grid-item key="8" text="音乐" icon="music-o">
<van-icon name="music-o" size="1.5rem"> </van-icon>
<div style="font-size: 12px; text-align: center">
音乐
</div></van-grid-item
>
</van-grid>
<div class="emotion-box-line" v-for="(line, i) in list" :key="i">
<emotion
class="emotion-item"
v-for="(item, i) in line"
:key="i"
@click="checkHandle(item)"
>{{ item }}</emotion
>
</div>
</div>
</div>
</van-form>
</div>
</template>
<script>
import { Form, Field, CellGroup, Toast, Notify } from "vant";
import { fetchGet, fetchPost, fetchPostFormData } from "../../http";
import { ref } from "vue";
import Vue from "vue";
import { Tab, Tabs } from "vant";
import { NavBar } from "vant";
import { Grid, GridItem } from "vant";
import { Col, Row } from "vant";
import user from "../../common/user";
import { Popover } from "vant";
import { ImagePreview } from "vant";
import { Sticky } from "vant";
import { Popup } from "vant";
export default {
name: "Message",
components: {
[Form.name]: Form,
[Field.name]: Field,
[CellGroup.name]: CellGroup,
Toast,
Notify,
Tabs,
Tab,
NavBar,
Grid,
GridItem,
Col,
Row,
Popover,
ImagePreview,
Sticky,
Popup,
},
data() {
return {
key: "",
varCodeUrl: "",
rememberMe: true,
isLoading: false,
isError: false,
chatContent: "",
password: "",
code: "",
websock: null,
recordContent: [],
list: [],
toUserId: null,
type: null,
toname: null,
imgUrl: null,
userInfo: {},
wsHasClose: false,
showPopover: false,
uploader: [],
uploadUrl: null,
plus: false,
};
},
created() {
this.getUserInfo();
this.initWebSocket();
this.getHisMessage();
},
destroyed() {
this.websock.close(); //离开路由之后断开websocket连接
},
methods: {
sendPic() {},
showPopup() {
this.plus = !this.plus;
},
preViewImg(url) {
ImagePreview([url]);
},
afterRead(file) {
file.status = "uploading";
file.message = "上传中...";
fetchPostFormData("/sys/file/upload", file.file)
.then((data) => {
this.uploadUrl = data.data.data.url;
file.status = "success";
file.message = "上传成功";
})
.catch(() => {
file.status = "failed";
file.message = "上传失败";
});
},
getTouchStart(itemc) {
itemc.cancellMessage = true;
this.showPopover = true;
},
getTouchEnd(itemc) {
itemc.cancellMessage = false;
},
cancellMsg(id) {
let data = {
msgId: id,
};
fetchPost("/sys/message/withdrawMessage", data)
.then((data) => {
if (data.data.data) {
Toast({
message: "撤回成功",
position: "top",
});
} else {
Toast({
message: data.data.msg,
position: "top",
});
}
this.$nextTick(() => {
this.getHisMessage();
});
})
.catch(() => {});
},
cancell(itemc) {
itemc.cancellMessage = false;
},
getUserInfo() {
this.userInfo = Vue.ls.get("userInfo");
},
getHisMessage() {
// 异步更新数据
fetchGet("/sys/message/listMyMessage?" + "username=" + this.toUserId)
.then((data) => {
let record = data.data.data;
let records = [];
for (let i = 0; i < record.length; i++) {
let flag = record[i].toUser == this.toUserId;
let content = JSON.parse(record[i].msgContent);
let param = {
mineMsg: flag,
timestamp: content.cuTime,
nickName: flag ? this.userInfo.name : this.toname,
contactText: content.data,
status: "1" == record[i].messageStatus ? "已读" : "送达",
cancellMessage: false,
id: record[i].id,
type: record[i].type,
};
records.push(param);
}
this.recordContent = records;
this.scrollToBottom();
})
.catch(() => {});
},
onClickLeft() {
this.$router.push("/sys/message");
},
onClickRight() {
this.getHisMessage();
},
scrollToBottom() {
this.$nextTick(() => {
console.log(document.documentElement.scrollHeight);
scrollTo(0, document.documentElement.scrollHeight);
});
},
checkHandle(item) {
let index = this.list.indexOf(item);
let imgHTML = `<img src="https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/${index}.gif">`;
this.$nextTick(() => {
this.$el.innerHTML = imgHTML;
});
},
getEmo() {
this.list = [
"微笑",
"撇嘴",
"色",
"发呆",
"得意",
"流泪",
"害羞",
"闭嘴",
"睡",
"大哭",
"尴尬",
"发怒",
"调皮",
"呲牙",
"惊讶",
"难过",
"酷",
"冷汗",
"抓狂",
"吐",
"偷笑",
"可爱",
"白眼",
"傲慢",
"饥饿",
"困",
"惊恐",
"流汗",
"憨笑",
"大兵",
"奋斗",
"咒骂",
"疑问",
"嘘",
"晕",
"折磨",
"衰",
"骷髅",
"敲打",
"再见",
"擦汗",
"抠鼻",
"鼓掌",
"糗大了",
"坏笑",
"左哼哼",
"右哼哼",
"哈欠",
"鄙视",
"委屈",
"快哭了",
"阴险",
"亲亲",
"吓",
"可怜",
"菜刀",
"西瓜",
"啤酒",
"篮球",
"乒乓",
"咖啡",
"饭",
"猪头",
"玫瑰",
"凋谢",
"示爱",
"爱心",
"心碎",
"蛋糕",
"闪电",
"炸弹",
"刀",
"足球",
"瓢虫",
"便便",
"月亮",
"太阳",
"礼物",
"拥抱",
"强",
"弱",
"握手",
"胜利",
"抱拳",
"勾引",
"拳头",
"差劲",
"爱你",
"NO",
"OK",
"爱情",
"飞吻",
"跳跳",
"发抖",
"怄火",
"转圈",
"磕头",
"回头",
"跳绳",
"挥手",
"激动",
"街舞",
"献吻",
"左太极",
"右太极",
];
},
initWebSocket() {
let asscess_token = Vue.ls.get("asscess_token");
this.toUserId = this.$route.query.username;
this.toname = this.$route.query.name;
this.imgUrl = this.$route.query.imgUrl;
//初始化weosocket
if (window.WebSocket) {
let wsBaseUrl = this.$API_BASE_URL;
if (wsBaseUrl.indexOf("https") != -1) {
wsBaseUrl = wsBaseUrl.replace("https", "wss");
} else {
if (wsBaseUrl.indexOf("http") != -1) {
wsBaseUrl = wsBaseUrl.replace("http", "ws");
}
}
const wsuri =
wsBaseUrl +
"/message_websocket/" +
asscess_token +
"/" +
this.toUserId;
this.websock = new WebSocket(wsuri);
this.websock.onmessage = this.websocketonmessage;
this.websock.onopen = this.websocketonopen;
this.websock.onerror = this.websocketonerror;
this.websock.onclose = this.websocketclose;
} else {
alert("你的浏览器不支持WebSocket。请不要使用低版本的IE浏览器。");
}
this.wsHasClose = false;
},
websocketonopen() {
//连接建立之后执行send方法发送数据
let actions = {
userId: "workOrderSendId",
provinceCode: "01",
cityCode: "02",
areaCode: "03",
gridCode: "04",
roleIds: "01,02,03",
};
this.websocketsend(JSON.stringify(actions));
},
websocketonerror() {},
websocketonmessage(e) {
//数据接收
console.log(e.data);
let retData = JSON.parse(e.data);
if (retData != "" && retData.withdrawMessage == "true") {
let msgId = retData.msgId;
for (let i = 0; i < this.recordContent.length; i++) {
if (this.recordContent[i].id == msgId) {
this.recordContent.splice(i, 1);
}
}
Notify({ type: "primary", message: "对方撤回了一条消息!" });
return;
}
if (retData != "" && retData.readFlag == "true") {
for (let i = 0; i < this.recordContent.length; i++) {
if (this.recordContent[i].status == "送达") {
this.recordContent[i].status = "已读";
}
}
return;
}
if ("" != retData.data) {
Notify({ type: "primary", message: "新消息:" + retData.data });
var param = {
mineMsg: false,
// headUrl: "https://www.pngdirs.com/thumb/350/orange/orange-9864.png",
timestamp: retData.cuTime,
nickName: this.toname,
contactText: retData.data,
cancellMessage: false,
id: retData.id,
type: retData.type,
};
this.recordContent = this.recordContent.concat(param);
//如果用户再看历史聊天记录,不能直接调最底层
//整个高度
let height = document.documentElement.scrollHeight;
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
websocketsend() {
if (this.wsHasClose) {
this.initWebSocket();
}
let type = "text";
//判断发送的消息类型
if (this.uploader.length > 0) {
type = "img";
this.chatContent = this.uploadUrl;
}
let yy = new Date().getFullYear();
let mm = new Date().getMonth() + 1;
let dd = new Date().getDate();
let hh = new Date().getHours();
let mf =
new Date().getMinutes() < 10
? "0" + new Date().getMinutes()
: new Date().getMinutes();
let ss =
new Date().getSeconds() < 10
? "0" + new Date().getSeconds()
: new Date().getSeconds();
let cuTime = yy + "/" + mm + "/" + dd + " " + hh + ":" + mf + ":" + ss;
let msgId = user.uuid(32, 15);
let requestParam = {
toUserId: this.toUserId,
data: this.chatContent,
cuTime: cuTime,
id: msgId,
type: type,
};
//数据发送
this.websock.send(JSON.stringify(requestParam));
var param = {
mineMsg: true,
// headUrl: "https://www.pngdirs.com/thumb/350/orange/orange-9864.png",
timestamp: cuTime,
nickName: this.userInfo.name,
contactText: this.chatContent,
status: "送达",
cancellMessage: false,
id: msgId,
type: type,
};
if (this.chatContent != null && this.chatContent != "") {
this.recordContent = this.recordContent.concat(param);
this.chatContent = null;
Toast.success("发送成功");
}
this.$nextTick(() => {
this.uploadUrl = null;
this.uploader = [];
this.scrollToBottom();
});
},
websocketclose(e) {
//关闭
console.log("断开连接", e);
this.wsHasClose = true;
},
},
};
</script>
<style scoped>
.reg {
color: rgb(123, 143, 143);
}
.chat-content {
width: 98%;
}
.word {
margin-bottom: 10px;
}
.yinfo {
margin-left: 10px;
text-align: left;
}
.yinfo-content {
max-width: 80%;
padding: 10px;
font-size: 14px;
float: left;
position: relative;
margin-top: 8px;
background: #ddeae7;
text-align: left;
border-radius: 5px;
word-break: break-all;
word-wrap: break-word;
}
.yinfo-content::before {
position: absolute;
left: -8px;
top: 8px;
content: "";
border-right: 10px solid #ddeae7;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.word-my {
margin-bottom: 10px;
}
.info {
margin-left: 15px;
text-align: right;
}
.time {
font-size: 12px;
color: rgba(51, 51, 51, 0.8);
margin: 0;
height: 20px;
line-height: 20px;
margin-top: -5px;
/* margin-right: 10px; */
}
.info-content {
max-width: 80%;
padding: 10px;
font-size: 14px;
float: right;
margin-right: 10px;
position: relative;
margin-top: 8px;
background: #a3c3f6;
text-align: left;
border-radius: 5px;
word-break: break-all;
word-wrap: break-word;
}
.info-content::after {
position: absolute;
right: -8px;
top: 8px;
content: "";
border-left: 10px solid #a3c3f6;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.status {
text-align: right;
font-size: 8px;
color: #757272;
}
</style>
5,后端实现
1, 增加WebSocketConfig 配置
import com.chengo.finance.orangefruitsystemservice.server.WebSocketServer;
import com.chengo.finance.orangefruitsystemservice.sys.service.ISysMessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Autowired
public void setSenderService(ISysMessageService senderService){
WebSocketServer.sysMessageService = senderService;
}
}
2,增加WebSocketServer,类似于一个controller,用于消息的接受与发送。
package com.chengo.finance.orangefruitsystemservice.server;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.chengo.finance.orangefruitcore.constant.CommonConstant;
import com.chengo.finance.orangefruitcore.pojo.LoginUser;
import com.chengo.finance.orangefruitcore.util.AESUtil;
import com.chengo.finance.orangefruitcore.util.IDGenerate;
import com.chengo.finance.orangefruitcore.util.SecurityUtil;
import com.chengo.finance.orangefruitsystemservice.sys.entity.SysMessage;
import com.chengo.finance.orangefruitsystemservice.sys.service.ISysMessageService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@ServerEndpoint(value = "/message_websocket/{token}/{toUser}")
@Component
@Slf4j
public class WebSocketServer {
private String token;
private String username;
private Session session;
public static ISysMessageService sysMessageService;
/**
* 记录当前在线连接数
*/
private static AtomicInteger onlineCount = new AtomicInteger(0);
/**
* 存放所有在线的客户端
*/
private static ConcurrentHashMap<String, WebSocketServer> clients = new ConcurrentHashMap<>();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("token") String token, @PathParam("toUser") String toUser) {
LoginUser loginUser = SecurityUtil.getLoginUserByToken(token);
this.token = token;
this.session = session;
this.username = loginUser.getUsername();
onlineCount.incrementAndGet(); // 在线数加1
clients.put(username, this);
log.info("有新连接加入:{},当前在线人数为:{}", username, onlineCount.get());
//判断是否是存在消息的人上线 初始化消息已经发送,不需要重发了注释
// LambdaQueryWrapper<SysMessage> query = Wrappers.lambdaQuery();
// query.eq(SysMessage::getToUser, username).eq(SysMessage::getSendStatus, "1")
// .eq(SysMessage::getFromUser, toUser).orderByAsc(SysMessage::getCreateTime);
// List<SysMessage> list = sysMessageService.list(query);
// if (null != list && !list.isEmpty()) {
// for (SysMessage sysMessage : list) {
// try {
// String decrypt = AESUtil.decrypt(sysMessage.getMsgContent(), AESUtil.aesKey);
// this.sendInfo(null == decrypt ? sysMessage.getMsgContent() : decrypt, username);
// sysMessage.setSendStatus(CommonConstant.TWO);
// sysMessage.setMessageStatus(CommonConstant.ONE);
// sysMessageService.updateById(sysMessage);
// } catch (IOException e) {
// log.error("推送消息失败,稍后重试!");
// }
// }
// }
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(Session session) {
onlineCount.decrementAndGet(); // 在线数减1
if (clients.get(username) != null) {
clients.remove(username);
log.info("有一连接关闭:{},当前在线人数为:{}", username, onlineCount.get());
}
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("服务端收到客户端[{}]的消息:{}", username, message);
if (StringUtils.isNotBlank(message)) {
try {
// 解析发送的报文
JSONObject jsonObject = JSONUtil.parseObj(message);
// 追加发送人(防窜改)
jsonObject.set("fromUserId", this.username);
String toUserId = jsonObject.getStr("toUserId");
// 传送给对应 toUserId 用户的 WebSocket
if (StringUtils.isNotBlank(toUserId) && clients.containsKey(toUserId)) {
clients.get(toUserId).sendMessage(JSONUtil.toJsonStr(jsonObject));
saveMessage(jsonObject, toUserId, CommonConstant.TWO);
try {
JSONObject object = new JSONObject();
object.set("readFlag","true");
this.sendInfo(object.toString(),username);
} catch (IOException e) {
log.error("发送已读消息回执失败!");
}
} else {
log.info("请求的userId:" + toUserId + "不在该服务器上,记录在数据库,上线推送"); // 否则不在这个服务器上,发送到 MySQL 或者 Redis
saveMessage(jsonObject, toUserId, CommonConstant.ONE);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 保存数据
* @param jsonObject
* @param toUserId
* @param status
*/
private void saveMessage(JSONObject jsonObject, String toUserId, String status) {
if (StringUtils.isNotBlank(jsonObject.getStr("data"))) {
SysMessage sysMessage = new SysMessage();
String encrypt = AESUtil.encrypt(JSONUtil.toJsonStr(jsonObject), AESUtil.aesKey);
if (StringUtils.isNotEmpty(jsonObject.getStr("id"))) {
sysMessage.setId(jsonObject.getStr("id"));
} else {
sysMessage.setId(IDGenerate.generateId());
}
sysMessage.setType(jsonObject.getStr("type"));
sysMessage.setMsgContent(null == encrypt ? JSONUtil.toJsonStr(jsonObject) : encrypt)
.setSendStatus(status).setToUser(toUserId)
.setMessageStatus(CommonConstant.ONE.equals(status) ? CommonConstant.ZERO : CommonConstant.ONE)
.setFromUser(username);
sysMessageService.save(sysMessage);
}
}
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}
/**
* 群发消息
*
* @param message 消息内容
*/
private void sendMessage(String message) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 群发自定义消息
*
* @param message
* @param username
* @throws IOException
*/
public void sendInfo(String message, String username) throws IOException {
// 遍历集合,可设置为推送给指定sid,为 null 时发送给所有人
Iterator entrys = clients.entrySet().iterator();
while (entrys.hasNext()) {
Map.Entry entry = (Map.Entry) entrys.next();
if (username == null) {
clients.get(entry.getKey()).sendMessage(message);
log.info("发送消息到:" + entry.getKey() + ",消息:" + message);
} else if (entry.getKey().equals(username)) {
clients.get(entry.getKey()).sendMessage(message);
log.info("发送消息到:" + entry.getKey() + ",消息:" + message);
}
}
}
}
以上就是简单的实现消息的发送与接受,实时的聊天系统就完成了