效果展示:
效果说明:
- websocket的链接和关闭
- 实现实时在线人数变化
- 消息接收发送
- 单点登录
- 在线用户列表展示 基于上一篇文章的内容,做继续的扩展,文章链接:基于SpringBoot与WebSocket实现json消息模块功能(一)
技术栈说明:
前端:
react
react-redux
typescript
antd
后端:
Springboot
WebSocket
Mybaits-plus
lombok
druid
mysql
实现步骤
- 基于上一篇文章的后端,修改了后端的代码:
// Message.java
public class Message {
@TableId(type = IdType.ASSIGN_UUID)
private String messageId;
private Integer fromId;
private Integer toId;
private String content;
private int isRead=0;
private Date createTime;
private int type = 0;
private String event; // 新增事件字段
}
// 新增一个枚举类MessageType.java 做event类型的区分
public enum MessageType {
OnlineCount("onlineCount"), // 在线数量事件
DuplicateError("duplicateError"), // 重复登录事件
Offline("offline"), // 下线事件
SendMessage("sendMessage"); // 发送消息事件
private final String type;
MessageType(String type){
this.type = type;
}
public String getType() {
return type;
}
}
// WebSocketServer.java
@OnOpen
public void onOpen(@PathParam("id") int id,Session session) throws IOException {
Session oldSession = WebSocketUtils.ONLINE_USER_SESSIONS.get(id); // 通过id获取到原来在线的session,
WebSocketUtils.ONLINE_COUNT.incrementAndGet(); // 将当前连接数先加一
if(oldSession!=null){ // 判断获取到的连接是不是空
//不为空,说明重复登录,则下发一个重复登录的事件消息,并且关闭旧的session
Message message = Message.builder().fromId(0).createTime(new Date()).type(0).event(MessageType.DuplicateError.getType()).content("已在其他地方登录").build();
WebSocketUtils.sendMessage(oldSession,JSON.toJSONString(message));
// 调用session.close()会触发onClose监听事件。
oldSession.close();
}
WebSocketUtils.ONLINE_USER_SESSIONS.put(id,session);
// 通知所有在线的session,当前在线的用户数量和用户id
List<Integer> onlineUsers = WebSocketUtils.ONLINE_USER_SESSIONS.keySet().stream().collect(Collectors.toList());
JSONObject jsonObject = new JSONObject();
jsonObject.put("onlineCount",WebSocketUtils.ONLINE_COUNT.get());
jsonObject.put("onlineUsers",onlineUsers);
Message message = Message.builder().fromId(0).createTime(new Date()).type(0).event(MessageType.OnlineCount.getType()).content(jsonObject.toJSONString()).build();
WebSocketUtils.sendMessageAll(JSON.toJSONString(message));
}
@OnClose
public void onClose(Session session,@PathParam("id") int id) throws IOException {
WebSocketUtils.ONLINE_COUNT.decrementAndGet();
WebSocketUtils.ONLINE_USER_SESSIONS.remove(id);
// 通知所有在线的session,当前在线的用户数量和用户id
List<Integer> onlineUsers = WebSocketUtils.ONLINE_USER_SESSIONS.keySet().stream().collect(Collectors.toList());
JSONObject jsonObject = new JSONObject();
jsonObject.put("onlineCount",WebSocketUtils.ONLINE_COUNT.get());
jsonObject.put("onlineUsers",onlineUsers);
Message data = Message.builder().fromId(0).createTime(new Date()).type(0).event(MessageType.OnlineCount.getType()).content(jsonObject.toJSONString()).build();
WebSocketUtils.sendMessageAll(JSON.toJSONString(data));
}
- 搭建前端项目
$ npx create-react-app my-app --template redux-typescript // 创建项目
通过上面的命令创建工程,得到一个如下图的工程
$ yarn add antd -S // 安装antd样式库
-
在src/features下创建一个socket目录,如下图:
-
基础工程搭建
// src/features/socket/Socket.tsx 此处创建一个基本的函数组件
export default function Index(){
return <div>123</div>
}
// src/index.css 此处引入antd的样式
@import '~antd/dist/antd.css'; // 顶部添加
// src/index.tsx 引入Index组件,并且将此处的<App/>替换为<Index/>
import Index from './features/socket/Socket';
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
{/* <App /> */}
<Index/>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
这里就完成了基础工程的创建。
- 页面分析
页面布局:使用Grid栅格化布局 代码:
// src/feature/socket/Socket.tsx Index函数组件return的页面渲染的部分
<div style={{padding:"20px",width:"100%"}}> // 外层容器添加一个独立的padding保证不会靠的太边
<Row align="middle" gutter={8}> // 顶部用antd的Row包裹 gutter设置内部Col的间距
<Col> // 用Col做栅格化的控制
请输入用户id:
</Col>
<Col span={6}>
<Input value={uid} onChange={changeUidValue}/> // 使用antd的输入框绑定uid和监听change事件
</Col>
<Col>
<Space> // Space用于做两个组件之间的间隔
<Button type="primary" onClick={() => {createWebSocket()}} disabled={ws!==undefined}>链接</Button> // 链接按钮,绑定click事件,事件触发创建websocket链接,判断ws对象是否WebSocket对象来确认是否设置不可点击
<Button type="primary" danger onClick={closeWebSocket} disabled={ws===undefined}>断开</Button> // 链接按钮,绑定click事件,事件触发关闭websocket链接,判断ws对象是否WebSocket对象来确认是否设置不可点击
</Space>
</Col>
<Col>{`在线人数:${onlineCount}`}</Col> // 显示在线人数
</Row>
<Row gutter={8} justify="space-between" style={{border: '1px solid black',marginTop:20,height: 500,padding:10,borderRadius:10}}> // 对内部使用flex布局,下面的主体区域也用一个Row包裹,用border家伙是哪个边框
<div style={{border: '1px solid black',borderRadius:5,width:"30%"}}> // 用div对主体部分先分为左右布局,左边容器
<Row justify="center" style={{height:"5%",borderBottom:'1px solid black'}}>用户列表</Row> // 左边顶部给个头部用户列表
<Row style={{height:"95%"}}> // 下半部分展示在线用户
<List style={{width:"100%"}}> // 用antd的List组件进行展示
{onlineUser.map((item)=><List.Item style={{backgroundColor:item===selectedUser?"rgb(192,192,192)":"white"}} key={item} onClick={()=>{setSelectedUser("");setSelectedUser(item)}}>{item}</List.Item>)} // map循环渲染List.Item,并且设置点击事件,并且根据当前点击的用户设置选中用户和修改选中状态
</List>
</Row>
</div>
<div style={{border: '1px solid black',borderRadius:5,width:"68%"}}> // 右边容器
<Row justify="center" style={{height:"5%",borderBottom:'1px solid black'}}>消息列表</Row> // 右边顶部给个头部用户列表
<Row style={{height:"65%",overflow:"auto"}} align="bottom" justify="center"> // 设置消息展示区域,设置内容从底部往上展示
<Col span={23}>
{messages.map((item:any,index:number)=><Row justify={item.fromId==uid?"end":"start"} key={index}>{item.content}</Row>)} //循环渲染消息内容,并且判断当前消息的发送者是不是本人,如果是的话则展示在右边,不是则在左边
</Col>
</Row>
<Row style={{height:"30%",position:"relative"}}> // 设置消息输入区域,设置相对布局
<Input.TextArea value={content} onChange={changeContent} bordered={false} style={{borderTop: '1px solid black',resize:"none",borderBottomLeftRadius:5,borderBottomRightRadius:5,borderTopRightRadius:0,borderTopLeftRadius:0}}/> // textarea设置,绑定content和监听onChange事件
<Button disabled={selectedUser===""} onClick={()=>{send()}} type="primary" style={{position:'absolute',bottom:10,right:10}}>发送</Button> // 按钮设置绝对布局,在右下角
</Row>
</div>
</Row>
</div>
- 处理业务逻辑
// src/feature/socket/Socket.tsx Index函数组件的业务的部分
const [uid,setUid] = useState<string>(""); // 当前用户的id
const [content,setContent] = useState<string>(""); // 消息输入框的文本内容
const [onlineUser,setOnlineUser] = useState<Array<string>>([]); // 当前在线用户列表
const [onlineCount,setOnlineCount] = useState<number>(0); // 当前在线人数
const [ws,setWs] = useState<WebSocket|undefined>(); // websocket对象
const [selectedUser,setSelectedUser] = useState<string>(""); // 当前选中的用户
const messages = useAppSelector(messageList); // 存放在redux中的消息列表
const dispatch = useAppDispatch(); // redux的dispatch方法
const changeUidValue = (event:React.ChangeEvent<HTMLInputElement>)=>{ // 监听用户uid变化的
setUid(event.target.value);
}
const changeContent = (event:React.ChangeEvent<HTMLTextAreaElement>)=>{ // 监听消息内容变化的
setContent(event.target.value);
}
const createWebSocket = ()=>{ // 创建函数
// 创建websocket对象 因为我服务端是下面的连接所以就用这种,如果有变的自行修改
let websocket = new WebSocket(`ws://localhost:9091/v1/websocket/${uid}`);
websocket.addEventListener('open',(event)=>{ // 监听open事件,设置ws为websocket
setWs(websocket);
})
websocket.addEventListener('message',(event)=>{ // 监听消息事件
const msg = event.data; // 获取返回的消息主体
let json; // 定义一个json
try{
json = JSON.parse(msg); // 将消息转换为json对象 也就是后端的Message类
if(json.event==="onlineCount"){ // 判断事件类型为在线人数
let contentText = JSON.parse(json.content); // 解析内部json内容
setOnlineCount(contentText.onlineCount??0); // 设置当前在线人数
let userList:Array<string> = contentText.onlineUsers??[]; // 获取在线用户列表
userList = userList.filter(item=>item!=uid); // 过滤掉当前页面的用户
setOnlineUser(userList); // 设置当前在线人数
}else if(json.event==="duplicateError"){ // 监听重复登录事件
message.error(json.content); // 弹出消息
}else if(json.event === "sendMessage"){ // 监听消息发送事件
dispatch(setMessages(json)) // 设置redux下的messagelist
}
}catch(e){ // 异常捕获
console.error(e)
console.log(json)
message.error(`转换失败:${msg}`)
}
});
websocket.addEventListener('close',(event)=>{ // 监听关闭事件,无论是客户端发起还是服务端主动都可以监听
setOnlineCount(0); // 当前用户下线,就不展示在线人数,设置为0
setWs(undefined); // 将原有的websocket对象从ws上移除
setOnlineUser([]); // 当前用户下线,就不展示在线用户,设置为[]
})
websocket.addEventListener('error',(event)=>{ // 监听异常
message.error("创建链接异常!") // 弹出消息 message是antd的组件
})
}
const closeWebSocket = ()=>{ // 关闭连接
ws && ws.close() // 触发websocket的关闭事件
}
const send = () => { // 发送消息
if(content===""){ // 判断消息不能为空
message.error("输入内容不能为空"); // 给出提醒
return; // 直接返回
}
let sendData:any = {}; // 构造消息主体(前后端统一过的消息对象)
sendData['type'] = 1; // 类型发送给个人的消息
sendData['fromId'] = parseInt(uid); // 发送者的id
sendData['content'] = content; // 消息主体内容
sendData['toId'] = selectedUser; // 接收者为选中的用户
ws && ws.send(JSON.stringify(sendData)); // 发送消息
setContent(""); // 发送完成后清空当前输入框的内容
dispatch(setMessages(sendData)) // 将自己发的消息添加到消息列表中
}
// 另一部分的业务在socketSlice.ts文件中
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; // 从reduxjs/toolkit中引入
import { RootState } from '../../app/store'; // 引入主store
export interface SocketState { // 设置state的interface
messageList:Array<any>;
}
const initialState: SocketState = { // 初始化state
messageList:[] // 消息列表
};
export const socketSlice = createSlice({ // 创建reducers
name: "socket", // 设置命名空间
initialState, // 调用初始化的state
reducers:{
setMessages:(state,action:PayloadAction<any>)=>{ // 修改消息的列表
let messages = [...state.messageList];
messages.push(action.payload);
state.messageList = [...messages];
}
}
})
export const {setMessages} = socketSlice.actions; // 将reducers的方法暴露出去
export const messageList = (state: RootState) => state.socket.messageList; // 将消息列表暴露出去
export default socketSlice.reducer; // 将reducer暴露出去
// src/app/store.ts
export const store = configureStore({
reducer: {
counter: counterReducer,
socket: socketReducer, // 将新增的reducers加入到store中
},
});
以上是针对页面上的业务处理
页面的完整内容如下:
// src/feature/socket/Socket.tsx
import { Button, Col, Input, List, message, Row, Space } from 'antd';
import { useState } from 'react';
import { useAppSelector,useAppDispatch } from '../../app/hooks';
import { messageList, setMessages } from './socketSlice';
export default function Index(){
const [uid,setUid] = useState<string>("");
const [content,setContent] = useState<string>("");
const [onlineUser,setOnlineUser] = useState<Array<string>>([]);
const [onlineCount,setOnlineCount] = useState<number>(0);
const [ws,setWs] = useState<WebSocket|undefined>();
const [selectedUser,setSelectedUser] = useState<string>("");
const messages = useAppSelector(messageList);
const dispatch = useAppDispatch();
const changeUidValue = (event:React.ChangeEvent<HTMLInputElement>)=>{
setUid(event.target.value);
}
const changeContent = (event:React.ChangeEvent<HTMLTextAreaElement>)=>{
setContent(event.target.value);
}
const createWebSocket = ()=>{
let websocket = new WebSocket(`ws://localhost:9091/v1/websocket/${uid}`);
websocket.addEventListener('open',(event)=>{
setWs(websocket);
})
websocket.addEventListener('message',(event)=>{
const msg = event.data;
let json;
try{
json = JSON.parse(msg);
if(json.event==="onlineCount"){
let contentText = JSON.parse(json.content);
setOnlineCount(contentText.onlineCount??0);
let userList:Array<string> = contentText.onlineUsers??[];
userList = userList.filter(item=>item!=uid);
setOnlineUser(userList);
}else if(json.event==="duplicateError"){
message.error(json.content);
}else if(json.event === "sendMessage"){
dispatch(setMessages(json))
}
}catch(e){
console.error(e)
console.log(json)
message.error(`转换失败:${msg}`)
}
});
websocket.addEventListener('close',(event)=>{
setOnlineCount(0);
setWs(undefined);
setOnlineUser([]);
})
websocket.addEventListener('error',(event)=>{
message.error("创建链接异常!")
})
}
const closeWebSocket = ()=>{
ws && ws.close()
}
const send = () =>{
if(content===""){
message.error("输入内容不能为空");
return;
}
let sendData:any = {};
sendData['type'] = 1;
sendData['fromId'] = parseInt(uid);
sendData['content'] = content;
sendData['toId'] = selectedUser;
ws && ws.send(JSON.stringify(sendData));
setContent("");
dispatch(setMessages(sendData))
}
return (
<div style={{padding:"20px",width:"100%"}}>
<Row align="middle" gutter={8}>
<Col>
请输入用户id:
</Col>
<Col span={6}>
<Input value={uid} onChange={changeUidValue}/>
</Col>
<Col>
<Space>
<Button type="primary" onClick={() => {createWebSocket()}} disabled={ws!==undefined}>链接</Button>
<Button type="primary" danger onClick={closeWebSocket} disabled={ws===undefined}>断开</Button>
</Space>
</Col>
<Col>{`在线人数:${onlineCount}`}</Col>
</Row>
<Row gutter={8} justify="space-between" style={{border: '1px solid black',marginTop:20,height: 500,padding:10,borderRadius:10}}>
<div style={{border: '1px solid black',borderRadius:5,width:"30%"}}>
<Row justify="center" style={{height:"5%",borderBottom:'1px solid black'}}>用户列表</Row>
<Row style={{height:"95%"}}>
<List style={{width:"100%"}}>
{onlineUser.map((item)=><List.Item style={{backgroundColor:item===selectedUser?"rgb(192,192,192)":"white"}} key={item} onClick={()=>{setSelectedUser("");setSelectedUser(item)}}>{item}</List.Item>)}
</List>
</Row>
</div>
<div style={{border: '1px solid black',borderRadius:5,width:"68%"}}>
<Row justify="center" style={{height:"5%",borderBottom:'1px solid black'}}>消息列表</Row>
<Row style={{height:"65%",overflow:"auto"}} align="bottom" justify="center">
<Col span={23}>
{messages.map((item:any,index:number)=><Row justify={item.fromId==uid?"end":"start"} key={index}>{item.content}</Row>)}
</Col>
</Row>
<Row style={{height:"30%",position:"relative"}}>
<Input.TextArea value={content} onChange={changeContent} bordered={false} style={{borderTop: '1px solid black',resize:"none",borderBottomLeftRadius:5,borderBottomRightRadius:5,borderTopRightRadius:0,borderTopLeftRadius:0}}/>
<Button disabled={selectedUser===""} onClick={()=>{send()}} type="primary" style={{position:'absolute',bottom:10,right:10}}>发送</Button>
</Row>
</div>
</Row>
</div>
)
}
完成后,通过命令行运行yarn start 即可在浏览器中打开项目进行操作
总结:
目前整体功能不算完善,后续也会继续完善功能以及业务处理
基于这个内容可以给大家做个参考
有什么新的想法,或者有什么建议的都欢迎提出来一起讨论。
一些问题:
关于使用redux这个工具,是因为我试过用state直接来存储messagelist,但是不知道为啥onmessage事件里面会对messagelist一直置为空,导致数据不对,所以才考虑采用redux来存储这个消息列表,如果有知道的也希望能帮我解决一下困惑,之后可能会对里面的内容再做一次优化,将业务和页面做更深层的抽离。