Springboot+Websocket实现简单的网页聊天室

一.简介

    本文使用主要使用 springboot + stomp实现一个简单的web端的聊天室,至于springboot和stomp相关的知识,各位读者可以查阅相关的资料。案例在实现的过程中,并没有将数据进行持久化,而是直接放入到内存中,而且实现的也比较的简陋,欢迎各位的指正。

二.服务端代码

2.1 案例的结构

服务端代码结构

2.2 maven的依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>springboot-websocket</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springboot-websocket</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.4</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2.3 消息代理配置实现

package com.example.springbootwebsocket.config;

import com.example.springbootwebsocket.inteceptors.MessageStatusInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker  //开启websocket的消息代理
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        /**
         * 客户单端在连接服务端的时候:http://localhost:8082/chat
         */
        registry.addEndpoint("/chat")    // 连接的地址
                .setAllowedOrigins("*")  //允许跨域
                .withSockJS();           //开启SockJS
    }

    /**
     * 配置客户端的输入
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        // 添加一个针对消息状态变化的拦截器
        registration.interceptors(new MessageStatusInterceptor());
    }
}

2.4 实体对象的定义

Message.java

public class Message {
    private String msgId;
    private String roomId;
    private String username;
    private String content;
    private Date sendDate;
    //setter and getter
}

UserInfo.java

public class UserInfo {
    private String username;
    private String roomId;
    // setters and getters
}

2.5 数据存储对象定义

/**
 * 用于数据的存储
 */
public class Constant {

    /**
     * key为房间号
     * value是房间内的所有的人
     */
    public static Map<String, Set<String>> usersInRoom = new ConcurrentHashMap<>();

    /**
     * 说明: 之所以定义该数据结构,是因为用户的退出的时候,无法在请求的头信息中获取到用户名和房间号。
     * key是同道的id
     * userInfo中包含了用户的房间号与用户名
     */
    public static Map<String, UserInfo> channelId2UserInfo = new ConcurrentHashMap<>();

    /**
     * key是房间号
     * value是房间内的所有的信息
     */
    public static Map<String, List<Message>> messagesOfRoom = new ConcurrentHashMap<>();
}

2.6 通道状态拦截器定义

package com.example.springbootwebsocket.inteceptors;

import com.alibaba.fastjson.JSONObject;
import com.example.springbootwebsocket.entity.UserInfo;
import com.example.springbootwebsocket.constants.Constant;
import com.example.springbootwebsocket.handler.ApplicationContextHandler;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;

import java.util.HashSet;
import java.util.Set;

public class MessageStatusInterceptor implements ChannelInterceptor {

    private SimpMessagingTemplate getSimpMessagingTemplate() {
        return ApplicationContextHandler.context.getBean(SimpMessagingTemplate.class);
    }

    // 用户的上线、下线、发送信息都要经过该方法
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        // 可以通过 StompHeaderAccessor来获取用户的状态 (登录、退出、发送信息)
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        // 连接的建立
        if(StompCommand.CONNECT == accessor.getCommand()) {
            MessageHeaders messageHeaders = message.getHeaders();
            // nativeHeaders = {username=[zhangsan], roomId=[123456], accept-version=[1.1,1.0], heart-beat=[10000,10000]}
            String values = messageHeaders.get("nativeHeaders").toString();
            String[] array = values.split(",");
            //截取用户名
            String username = array[0].replace("{username=[", "").replace("]", "");
            //截取用户的房间号
            String roomId = array[1].trim().replace("roomId=[", "").replace("]", "");

            Set<String> users = Constant.usersInRoom.get(roomId);
            if(null == users) {  //如果users为空,表示该用户是第一个进到房间的
                users = new HashSet<>();
                users.add(username);
                Constant.usersInRoom.put(roomId, users);
            }else { //房间中有人
                users.add(username);
            }

            String channelId = accessor.getSessionId();  //获取同道的id
            UserInfo userInfo = new UserInfo(username, roomId);

            Constant.channelId2UserInfo.put(channelId, userInfo);

            getSimpMessagingTemplate().convertAndSend("/room/" + roomId, JSONObject.toJSONString(users));
        }

        //连接的断开
        if(StompCommand.DISCONNECT == accessor.getCommand()) {
            MessageHeaders messageHeaders = message.getHeaders();

            String channelId = accessor.getSessionId();  //获取同道的id

            UserInfo userInfo = Constant.channelId2UserInfo.get(channelId);

            String roomId = userInfo.getRoomId();
            String username = userInfo.getUsername();
            /**
             * 1.将用户从房间移除掉
             * 2.给房间中的所有的人发信息
             */
            Set<String> usresInSepcifyRoom = Constant.usersInRoom.get(roomId); //获取房间内所有的用户
            usresInSepcifyRoom.remove(username);

            getSimpMessagingTemplate().convertAndSend("/room/" + roomId, JSONObject.toJSONString(usresInSepcifyRoom));
        }

        return message;
    }
}

2.7 ApplicationContext的获取

     之所以需要获取ApplicationContext,是因为在通道拦截器中需要使用SimpMessagingTemplate 对象,在消息代理配置中又需要拦截器SimpMessagingTemplate的实例又需要使用到消息代理配置,会产生一个循环依赖的问题。所以我们手动去获取ApplicationContext,在需要用到SimpMessagingTemplate的时候,再去取,而不是在服务器启动的时候去注入。

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
 * XXXXXAware是spring中非常特殊的接口(XXXXProcessor); 这类接口是spring在启动的过程中
 * 如果检测到你的bean实现了这些接口,那么spring在实例化这些bean之后,接着调用对应的方法,
 * 传递给你所需要的参数。
 */
@Component
public class ApplicationContextHandler implements ApplicationContextAware {

    public static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        ApplicationContextHandler.context = applicationContext;
    }
}

2.8 控制器的编写

UsersInRoomController.java

package com.example.springbootwebsocket.controller;

import com.example.springbootwebsocket.constants.Constant;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

// 用户在连接成功之后,需要去获取房间内的所有的用户,用于前端列表展示
@Controller
public class UsersInRoomController {

    // 用户在简历连接之后,要获取到房间内所有的人
    @SubscribeMapping("/connection/{roomId}")
    public Object getUsers(@DestinationVariable("roomId") String roomId) {
        return Constant.usersInRoom.get(roomId);
    }
}

MessageController.java

package com.example.springbootwebsocket.controller;

import com.alibaba.fastjson.JSONObject;
import com.example.springbootwebsocket.constants.Constant;
import com.example.springbootwebsocket.entity.Message;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;

@Controller
public class MessageController {

    private SimpMessagingTemplate simpMessagingTemplate;

    public MessageController(SimpMessagingTemplate simpMessagingTemplate) {
        this.simpMessagingTemplate = simpMessagingTemplate;
    }

    // @SubscribeMapping 是当前谁调用,返回的数据就给谁
    // 该接口的作用是, 当用户进入房间后, 获取该房间内的所有聊天记录
    @SubscribeMapping("/room/allMessage/{roomId}")
    public Object getAllMessages(@DestinationVariable("roomId") String roomId) {
        List<Message> messageList = Constant.messagesOfRoom.get(roomId);

        if(null == messageList) {
            messageList = new ArrayList<>();
        }

        return messageList;
    }

    // 用户发送信息,给指定房间内的所有的用户发送信息
    @MessageMapping("/room/{roomId}/{username}")
    public void sendMsg(@DestinationVariable("roomId") String roomId,
                          @DestinationVariable("username") String username, String text) {
        Message message = new Message();
        message.setMsgId(UUID.randomUUID().toString());
        message.setContent(text);
        message.setRoomId(roomId);
        message.setUsername(username);
        message.setSendDate(new Date());

        // 取出对应房间的所有的聊天记录
        List<Message> messageList = Constant.messagesOfRoom.get(roomId);
        if(null == messageList) { //房间内的首次发信息
            messageList = new ArrayList<>(50000);
            messageList.add(message);
            Constant.messagesOfRoom.put(roomId, messageList);
        }else {
            messageList.add(message);
        }
        // /room/" + roomId + "/message
        simpMessagingTemplate.convertAndSend("/room/" + roomId + "/message", JSONObject.toJSONString(message));
    }
}

2.9 启动类

package com.example.springbootwebsocket;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

@SpringBootApplication
public class SpringbootWebsocketApplication {

    public static void main(String[] args) {
        ConfigurableApplicationContext ctx = SpringApplication.run(SpringbootWebsocketApplication.class, args);
//        Stream.of(ctx.getBeanDefinitionNames()).forEach(n -> System.out.println(n));
    }
}

三.前端页面

    各位读者请勿喷,笔者前端的布局能力不是很强,前端看起来会有些乱,但是基本的布局还是有的。

3.1 前端代码结构

前端代码结构

3.2 前端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>聊天页面</title>
    <!-- bootstrap4 -->
    <link href="./css/bootstrap.css" rel="stylesheet" type="text/css" />
    <!-- 聊天页面的布局 -->
    <link href="./css/chat.css" rel="stylesheet" type="text/css" />
    <script src="./js/vue.js"></script>
    <!-- SockJS 是websocket的备选方案,如果浏览器不支持websocket那么,就会降级为轮训的方式 -->
    <script src="./js/sockjs.min.js"></script>
    <!-- 是websocket的上传协议,操作更加方便 -->
    <script src="./js/stomp.js"></script>

    <script src="./js/moment.min.js"></script>
    <script src="./js/jquery-3.3.1.min.js"></script>

    <script src="./js/bootstrap.min.js"></script>
</head>
<body>
<!--  <router-view></router-view>-->
<div class="container-fluid">
    <div class="row justify-content-center">
        <div class="col-7">
            <form class="form-inline" onsubmit="javascript: return false;">
                <div class="input-group">
                    <div class="input-group-prepend">
                        <div class="input-group-text">房间号</div>
                    </div>
                    <input class="form-control" id="roomId" placeholder="请输入房间号">
                </div>
                <div class="input-group">
                    <div class="input-group-prepend">
                        <div class="input-group-text">用户名</div>
                    </div>
                    <input class="form-control" id="username" placeholder="请输入用户名">
                </div>
                <button type="submit" onclick="loginIntoRoom()" class="btn btn-primary">连接</button>
            </form>
        </div>
    </div>
</div>
<div id="app" class="row justify-content-center" style="margin-top: 10px;">
        <div class="col-2">
            <div class="card">
                <div class="card-header chat-header">
                    <h5 class="chat-title">在线用户列表</h5>
                </div>
                <div class="card-body">
                    <ul id="userList" class="list-group">
                        <li class="list-group-item" v-for="u in userList" :key="u">{{u}}</li>
                    </ul>
                </div>
            </div>
        </div>
        <div class="col-5" style="padding-left: 0;">
            <div class="card" style="height: 600px;">
                <div class="card-header chat-header">
                    <h5 class="chat-title">聊天区</h5>
                </div>
                <div class="card-body chat-content">
                    <!-- block -->
                        <!-- {message: chatContent, username: username, sendDate: moment().format('YYYY-MM-DD HH:mm:ss')} --->
                        <!-- 别人的聊天信息 -->
                        <template v-for="msg in messageList" :key="msg.msgId">
                            <div v-if="msg.username != username" class="clearfix mb-6 fx">
                                <img class="avarta" src="http://localhost/5677yyufe.jpg">
                                <div class="other-chat-info">
                                    <span class="name-st">{{msg.username}} {{msg.sendDate | formatDate}}</span>
                                    <div class="content-other">
                                        <!-- 白色的小三角 -->
                                        <i class="triangle-common left-4 triangle-left"></i>
                                        {{msg.content}}
                                    </div>
                                </div>
                            </div>
                            <div v-else class="clearfix mb-6 self-chat-panel">
                                <!-- 自己的聊天内容 -->
                                <div class="self-chat-info">
                                    <p class="self-name">{{msg.username}} {{msg.sendDate | formatDate}}</p>
                                    <div class="content-self">
                                        <!-- 绿色的小三角 -->
                                        <i class="triangle-common right-4 triangle-right"></i>
                                        {{msg.content}}
                                    </div>
                                </div>
                                <img class="float-right avarta" src="http://localhost/5677yyufe.jpg">
                            </div>
                        </template>  <!-- vue、微信小程序(block)、react前端在做逻辑判断、循环的时候采取去 -->
                </div>
            </div>
            <div class="card chat-box">
                <!-- 输入聊天内容 -->
                <textarea id="chatContent" class="chat-content"></textarea>
                <button class="send-btn btn-success" onclick="sendMsg()">Send</button>
            </b-card-body>
        </div>
    </div>
</div>

<script>
    // 定义一个过滤器
    Vue.filter('formatDate', (value) => {
        return moment(value).format('YYYY-MM-DD HH:mm:ss')
    })

    var vm = new Vue({
        el: '#app',
        data() {
            return {
                userList: [],
                username: '',
                messageList: []  //所有的消息
            }
        }
    })

    // 如果使用原生 Websocket, 那么连接的形式:ws://localhost:8082/XXX
    // 我们使用SockJS
    var wsUrl = "http://localhost:8080/chat";

    var client = null;
    // 登录到具体的房间
    function loginIntoRoom() {
        // 创建 SockJS对象
        var sockJs = new SockJS(wsUrl);

        // 连接对象
        client = Stomp.over(sockJs);  //

        var username = document.getElementById("username").value;
        var roomId = document.getElementById("roomId").value;

        var headers = {   //构建websocket请求头信息
            username: username,
            roomId: roomId
        }

        client.connect(headers, () => {
            vm.username = username;
            // 用户上线获取所有的用户
            client.subscribe("/connection/" + roomId, (_data) => {
                vm.userList = [];
                var userList = JSON.parse(_data.body); //取得用户的列表
                for(var i = 0; i < userList.length; i++) {
                    vm.userList.push(userList[i])
                }
            })
            // 用户上线之后,获取所有的聊天记录
            client.subscribe("/room/allMessage/" + roomId, (_data) => {
                vm.messageList = [];
                var messages = JSON.parse(_data.body); //获取所有的信息
                for(var i = 0;i < messages.length; i++) {
                    vm.messageList.push(messages[i]);
                }
            })
            // 其他人上线了,传递过来的用户列表
            client.subscribe("/room/" + roomId, (_data) => {
                vm.userList = [];
                var userList = JSON.parse(_data.body); //取得用户的列表
                for(var i = 0; i < userList.length; i++) {
                    vm.userList.push(userList[i])
                }
            })
            // 监听的当前房间号的聊天信息
            client.subscribe("/room/" + roomId + "/message", (_data) => {
                 var body = _data.body; //获取聊天信息
                 vm.messageList.push((JSON.parse(body)));
            })
        })
    }

    // 用户点击发送按钮,发送信息
    function sendMsg() {
        var content = document.getElementById("chatContent").value;
        var roomId = document.getElementById("roomId").value;
        var username = document.getElementById("username").value;

        document.getElementById("chatContent").value = '';
        client.send("/room/" + roomId + "/" + username, {}, content);
    }

</script>
</body>
</html>

3.3 chat.css内容

.fx{
    display: flex;
}
.chat-header {
    padding: 0.25rem 1.25rem !important;
}

.connect-btn {
    position: relative;
    top: 2px;
}
.avarta {
    height: 36px;
    width: 36px;
}
.chat-title {
    margin-top: 0.1rem;
    margin-bottom: 0.1rem;
}
.chat-content {
    padding-left: 5px !important;
    padding-right: 5px !important;
    background-color: #e3e3e3;
    width: 100%;
}
/** 其他人的聊天信息布局,名字以及聊天内容布局 */
.other-chat-info{
    margin-left: 8px;
    position: relative;
    top: -5px;
    max-width: 60%;
}
.name-st{
    color: #428BCA;
}
/** 自己聊天信息布局 */
.self-chat-panel {
    display: flex;
    flex-direction: row;
    justify-content: flex-end;
}
.self-chat-info {
    margin-right: 8px;
    position: relative;
    top: -5px;
    max-width: 60%;
}
.self-name {
    color: #428BCA;
    text-align: right;
    margin: 0;
}
/* 左边偏 -8px */
.left-4 {
    left: -4px;
}
/* 右边偏-8px */
.right-4 {
    right: -4px;
}
.mb-6 {
    margin-bottom: 6px;
}
/** 聊天内容样式通用样式 */
.content-other {
    background-color: #ffffff;
    border-radius: 4px;
    padding: 4px;
    font-weight: 500;
    position: relative;
    width: 100%;
}
/** 左右三角的通用样式 */
.triangle-common {
    position: absolute;
    border-top: solid 4px transparent;
    border-bottom: solid 4px transparent;
    display: block;
    top: 4px;
}
/* 左三角 */
.triangle-left {
    border-right: solid 6px #ffffff;
}
/** 右三角 */
.triangle-right {
    border-left: solid 6px #85DD45;
}
/** 本人聊天内容展示效果 */
.content-self {
    background: #85DD45;
    border-radius: 4px;
    display: inline-block;
    padding: 4px;
    float: right;
    font-weight: 500;
    position: relative;
    width: 100%;
}

.chat-box {
    padding: 0 !important;
    position: relative;
}
.chat-content {
    height: 100%;
    width: 100%;
    resize: none;
}
/* 发送按钮 */
.send-btn {
    position: absolute;
    bottom: 5px;
    right: 5px;
}

四.效果展示

当前用户-正井猫
当前用户-大佬
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容