基于SpringBoot与WebSocket实现json消息模块功能(二)

604 阅读4分钟

效果展示:

Video_21-08-26_18-25-33.gif

效果说明:

  1. websocket的链接和关闭
  2. 实现实时在线人数变化
  3. 消息接收发送
  4. 单点登录
  5. 在线用户列表展示 基于上一篇文章的内容,做继续的扩展,文章链接:基于SpringBoot与WebSocket实现json消息模块功能(一)

技术栈说明:

前端:

react
react-redux
typescript
antd

后端:

Springboot
WebSocket
Mybaits-plus
lombok
druid
mysql

实现步骤

  1. 基于上一篇文章的后端,修改了后端的代码:
// 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));
}
  1. 搭建前端项目
$ npx create-react-app my-app --template redux-typescript  // 创建项目

通过上面的命令创建工程,得到一个如下图的工程 image.png

$ yarn add antd -S // 安装antd样式库
  1. 在src/features下创建一个socket目录,如下图: image.png

  2. 基础工程搭建

// 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')
);

这里就完成了基础工程的创建。

  1. 页面分析 WeChat5ca4670735c0f40eda347eb7994d674c.png 页面布局:使用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>
  1. 处理业务逻辑
// 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来存储这个消息列表,如果有知道的也希望能帮我解决一下困惑,之后可能会对里面的内容再做一次优化,将业务和页面做更深层的抽离。