• 【Web项目实战】基于STOMP的聊天室【高仿QQ界面,世界频道,私聊,头像】


    SpringBoot


    基于STOMP协议聊天服务项目实战相关的经验 ----- 小欢Chat


    之前分享了git工具的使用【留作记录】git分布式的版本控制工具,下面分享的是小欢chat项目,主要是使用的STMOP协议【websocket子协议】

    基于STOMP协议聊天项目

    项目展示

    基于安全框架先完成登录认证的功能

    在这里插入图片描述

    当然还需要注册用户,聊天用户是有头像的,需要使用MultiPart业务进行图片的上传

    在这里插入图片描述

    Cfeng从一开始就不单单以聊天业务为核心,聊天业务只是很小的业务模块,后面就将其他的业务扩展到这里,所以先简单设计一个Main页面,frame供后期扩展
    在这里插入图片描述

    这里Cfeng利用Jquery 在点击进入聊天 【当前的业务模块】时就会触发建立websocket长连接,传输STOMP帧

    在这里插入图片描述

    当然用户也是支持修改信息的,现在是Cfeng的项目的基础起步阶段,所以真实的用户信息先只开放了两个字段

    在这里插入图片描述

    发送信息和QQ等相同,给文本域绑定js事件即可

    在这里插入图片描述

    接下来Cfeng会进一步完善项目【主要是前端】

    请添加图片描述

    i之前再java SE部分利用多线程和网络编程基础的Socket写了一个非常简单的聊天项目,主要就是有一个线程监听上线人数,同时发送的消息就选择多人或者公共,非常基础,也没有任何的权限的功能,就Demo;

    现在就使用STOMP,利用Spring Boot重新构建要给综合的web版的聊天服务

    首先项目的基本需求是:

    • 实现聊天服务最基础的注册登录功能,能够安全认证
    • 实现聊天服务的基础的群聊和私聊功能
    • 实现聊天服务的基础的聊天记录的功能

    技术架构

    首先整体的技术选型使用Springboot快速开发,采用V模型推进项目,整体基本划分为3大模块: 消息模块,存储模块,安全模块

    • 消息模块: 基于STOMP协议实现
    • 安全模块: 基于Spring Security实现
    • 存储模块: 基于MongoDB实现,因为聊天记录数据量过大,而MongoDB数据库是非常适合处理大数据量的Nosql数据库

    在这里插入图片描述

    聊天室主要基于STOMP协议,使用HTTP协议与客户端进行交互,存储模块要存储用户信息和聊天信息,安全Spring security除了基本的鉴权之外,还要能够保护WebSocket端点,所以需要引入security-websocket依赖

    项目框架搭建

    对比JPA和Mybatis之后,indv还是使用JPA,毕竟是Spring家族的,开发中当然还是自动创建表,使用Mybatis-plus【vo…】,本项目都是使用spring家族产品

    • 首先建立web项目,需要spring-boot-starter-websocket
    • 需要进行鉴权操作,引入spring-boot-starter-security
    • 使用mongoDB存储,需要引入spring-boot-starter-mongoDB
    • 使用security提供的messageing依赖 集成websocket : spring-security-messaging

    使用boot CLI 快速搭建项目

    PS D:\softVM\XiaohuanChat> spring init -d web,lombok,websocket,security --build maven -p war XiaohuanChat
    Using service at https://start.spring.io
    Project extracted to 'D:\softVM\XiaohuanChat\XiaohuanChat'
    
    • 1
    • 2
    • 3

    打开项目,添加其他的依赖,比如mongodb,autoconfigure,security-messaging等,同时test需要排除vintage-engine

    <parent>
    		<groupId>org.springframework.bootgroupId>
    		<artifactId>spring-boot-starter-parentartifactId>
    		<version>2.7.2version>
    		<relativePath/> 
    	parent>
    	<groupId>com.cfenggroupId>
    	<artifactId>XiaohuanChatartifactId>
    	<version>0.0.1-SNAPSHOTversion>
    	<packaging>warpackaging>
    	<name>XiaohuanChatname>
    	<description>基于STOMP协议的小欢聊天室web项目description>
    	<properties>
    		<java.version>17java.version>
    	properties>
    	<dependencies>
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-securityartifactId>
    		dependency>
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-webartifactId>
    		dependency>
    		
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-websocketartifactId>
    		dependency>
    
    		
    		<dependency>
    			<groupId>org.projectlombokgroupId>
    			<artifactId>lombokartifactId>
    			<optional>trueoptional>
    		dependency>
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-tomcatartifactId>
    			<scope>providedscope>
    		dependency>
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-testartifactId>
    			<scope>testscope>
    			
    			<exclusions>
    				<exclusion>
    					<groupId>org.junit.vintagegroupId>
    					<artifactId>junit-vintage-engineartifactId>
    				exclusion>
    			exclusions>
    		dependency>
    		<dependency>
    			<groupId>org.springframework.securitygroupId>
    			<artifactId>spring-security-testartifactId>
    			<scope>testscope>
    		dependency>
    		
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-data-mongodbartifactId>
    		dependency>
    		
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-autoconfigureartifactId>
    		dependency>
    		
    		<dependency>
    			<groupId>org.springframework.securitygroupId>
    			<artifactId>spring-security-messagingartifactId>
    		dependency>
    	dependencies>
    
    	<build>
    		<plugins>
    			<plugin>
    				<groupId>org.springframework.bootgroupId>
    				<artifactId>spring-boot-maven-pluginartifactId>
    				<configuration>
    					<excludes>
    						<exclude>
    							<groupId>org.projectlombokgroupId>
    							<artifactId>lombokartifactId>
    						exclude>
    					excludes>
    				configuration>
    			plugin>
    		plugins>
    	build>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91

    项目的pom依赖如上

    同时确定项目基本的包结构:

    config    controller entity service repository security
    
    • 1

    WebSocket协议【应用层】

    WebSocket协议是一种通信协议。和Http类似,都是属于OSI模型的应用层,依赖于TCP协议,但是HTTP提供的单向通信的信道,而WebSocket协议体哦那个的是双向通信信道

    在这里插入图片描述

    Http协议是http://或者https://,而WebSocket协议的协议头为ws://或者wss://,是一个双向通信协议,也是一个有状态协议,客户端与服务器之间的连接需要保持活动状态,知道其中一方终止,任何一端断掉,就会从两端终止

    WebSocket与Http协议存在依赖关系,WebSocket的请求和握手过程是基于Http协议的,WebSocket协议是升级版的Http请求83

    在这里插入图片描述

    Http是无状态协议,一次请求对应一次响应,响应结束之后就结束,但是websocket是有状态协议,但是每次斗湖进行HandShake握手,握手请求,握手响应,建立连接

    • Why we need WebSocket?

    Http协议有一个缺陷: 通信只能由客户端发起; 单向无状态协议

    也就是说比如Cfeng如果想知道今天天气,Cfeng客户端只能主动向服务器发起request,不能做到服务器主动给客户端推送天气信息

    单向请求: 如果服务器有了连续的变化,客户端要获知,就必须轮询:每间隔一段时间,就向服务器发起询问,了解新的信息【聊天室】 轮询的效率低,浪费资源,因为无状态,必须不断询问,不停连接或者Http连接一直打开

    WebSocket可以让服务器主动给客户端推送信息,双向平等对话,属于服务器推送技术,基于TCP协议【握手阶段基于HTTP协议】,能通过各种HTTP代理服务器,数据格式轻量,可以发送文本或者二进制数据;没有同源限制,客户端可以与任意服务器通信(跨域),协议标识ws,服务器网址URL

    websocket请求格式

    GET /ws HTTP/1.1
    Host: 192.168.33.1:8099
    Connection: Upgrade
    Pragma: no-cache
    Cache-Control: no-cache
    Upgrade: websocket
    Origin: http://dev.1thx.com
    Sec-WebSocket-Version: 13
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.101 Safari/537.36 QQBrowser/4.3.4986.400
    Accept-Encoding: gzip, deflate
    Accept-Language: zh-CN,zh;q=0.8
    sec-Websocket-Versin: 13
    Sec-WebSocket-Key: mIsurCgKrroYO7m/0QNqRg==
    Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    该请求与HTTP请求不同之处:

    Connection: Upgrade      告知服务器发起的是websocket请求
    Upgrade: websocket
    
    sec-Websocket-Versin: 13    告知服务端所使用的WebSocket版本
    Sec-WebSocket-Key: mIsurCgKrroYO7m/0QNqRg==   发送给服务端计算sec-xxx-Accept,帮助客户端确认客户端身份 
    Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits 请求扩展
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    WebSocket响应格式同样会返回

    Upgrade: websocket
    Connection: Upgrade
    
    • 1
    • 2
    • How we use websocket to help with our work?

    WebSocket就是对于单向无状态的HTTP协议的补充,让web开发不再局限于管理系统、博客、网站等,可以进入更加广阔的场景:【交互式】

    实时数据展示: 天气、股份、比特币; 【服务器主动发送】

    游戏: Web端应用程序

    实时通信: 聊天程序 通过WebSocket建立第一次连接,之后就可以依赖各客户端和服务端的连接实现单播或者广播

    SpringBoot集成WebSocket @ServerEndPoint

    JSR 356制定了java中使用websocket的相关的API规范,在项目中要使用WebSocket协议帮助进行实时通信,需要首先引入相关的依赖; 该部分直接使用websocket协议,demo继承在spring-test-dmeo中

    		
    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-websocketartifactId>
    		dependency>
    		<dependency>
    			<groupId>javax.websocketgroupId>
    			<artifactId>javax.websocket-apiartifactId>
    			
    		dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    WebSocket时javaEE7后支持,javax.websocket.server包含注解、类、接口用于创建和配置服务端点,javax.websocket包含服务端点和客户端点公用注解、类、接口、异常;

    Websocket的开发主要是基于endpont端点进行配置,监听各端点上面的不同的事件,和javas一样是事件驱动,需要监听事件,定义回调的方法, 实现的方式:

    编程式: 传统的继承的方式,继承javax.websocket.EndoPont;

    最主要的就是基于注解,也就是websocket-api提供的注解

    • @ServerEndpoint: 服务端使用该注解启用对端点的监听; 将类定义为一个websocket服务器端,注解的值为监听用户连接的终端访问URL地址,客户端可以通过该URL访问websocket服务端
    • @ClientEndpoint: 客户端使用该注解启用对端点的监听
    • @OnOpen: 监听websocket连接事件,当websocket连接时,就会调用带有@OnOpen的方法
    • @OnMessage: 监听消息发送到端点, 当消息发送到端点时,就会触发调用该注解的方法
    • @OnError: 监听通信的问题,当通信出现问题,就会触发调用该注解的方法
    • @OnClose: 监听连接关闭事件,连接关闭时,容器调用的方法

    快速的小demo演示websocket

    1. 如果使用springBoot内置的Tomcat容器,需要提供ServerEndPointExporter对象注册服务器端点【识别为服务器端点】
    package com.Jning.cfengtestdemo.config;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.core.Ordered;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    import org.springframework.web.socket.server.standard.ServerEndpointExporter;
    
    /**
     * @author Cfeng
     * @date 2022/7/27
     */
    
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
    
        //设置欢迎页面
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("/").setViewName("login");
            registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
        }
    
        //服务器支持跨域访问
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                    .allowedOrigins("*")  //*匹配所有的IP,设置允许访问的IP
                    .allowedMethods("GET","POST","DELETE","OPTIONS")  //设置允许的Http访问方式
                    .allowedHeaders("*")  //所有的请求头
                    .exposedHeaders("Access-Control-Allow-Headers","Access-Control-Allow-Methods","Access-Control-Allow-Origin","Access-Control-Max-Age","X-Frame-Options") //暴露,允许跨域
                    .allowCredentials(false)   //不需要凭证
                    .maxAge(3600);
        }
    
        /**
         * 可以直接在WebMVC的配置ServerEndPointExporter这个bean
         *  自动注册使用了@ServerEndPoint注解的endpoint
         *  使用了SpringBoot内置的servlet容器,需要注册,如果没有时外部的Tomcat,不需要注入
         *  Tomcat会提供
         */
        @Bean
        public ServerEndpointExporter serverEndpointExporter() {
            return new ServerEndpointExporter();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    首先是关键的websocket的服务器端,需要加上@ServerPoint和@Component,将处理器放入容器,触发其方法

    而多链接状态下,需要使用的参数都应该设计为线程安全的,比如CorrentHashMap等

    定义访问的服务端点,需要定义事件回调函数,并且定义发送消息的方法

    package com.Jning.cfengtestdemo.websocket;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    import javax.websocket.*;
    import javax.websocket.server.PathParam;
    import javax.websocket.server.ServerEndpoint;
    import java.io.IOException;
    import java.text.DateFormat;
    import java.text.SimpleDateFormat;
    import java.util.Date;
    import java.util.Map;
    import java.util.Objects;
    import java.util.concurrent.ConcurrentHashMap;
    
    /**
     * @author Cfeng
     * @date 2022/8/2
     * 测试websocket的服务端EndPoint的使用
     * 作为websocket服务器端,为一个controller处理器,加上注解@Component
     */
    
    @Component
    @Slf4j
    @ServerEndpoint("/chat/{username}")  //表明为websocket服务器端,指明访问的url地址
    public class ChatEndPoint {
        //记录当前连接数量,在多线程状态下应该设置为线程安全的
        private static int onlineCount = 0;
        //线程安全的ConcurrentHashMap,存放每一个客户端对应的服务端ChatEndPoint对象,单一服务就直接使用Map,每一个用户对应一个EndPoint
        private static Map<String,ChatEndPoint> webSocketMap = new ConcurrentHashMap<>();
    
        //与某个服务端连接的会话,websocket下面
        private Session session;
    
        //区分不同的客户端的标识,这里直接就是username
        private String currentUser = "";
    
        //维护已经创建的服务端端点实例,使用线程安全的CopyOnWriteArraySet
    //    private static Set chatEndPoints = new CopyOnWriteArraySet<>();
    
        //维护在线的用户列表, 与set合并就是上面的map
    //    private static HashMap users = new HashMap<>();
    
        /**
         * 该方法处理某个客户端连接成功后指向的方法,Session为连接的会话,可以通过其发送数据给客户端
         * 连接时会接收severEndpoint的username进行处理
         * 连接会传入一个session
         */
        @OnOpen
        public void onOpen(Session session, @PathParam("username") String username) {
            this.session = session; //会话赋值供其他的方法使用,当前用户的session
            this.currentUser = username;
            webSocketMap.put(currentUser,this); //增加在线的用户,key就是用户访问,value为其对应的EndPoint
            addOnlineCount();
            log.info("有新人加入,当前在线" + getOnlineCount());
            log.info("session = {} ||| username ={}",session,username);
            //广播该用户上线消息
            broadCast(username + "上线了");
        }
    
        /**
         * 与该客户端连接关闭后调用的方法
         */
        @OnClose
        public void onClose() {
            if(!currentUser.equals("")) {
                webSocketMap.remove(currentUser); //下线移除
                subOnlineCount(); //在线人数减少
                log.info("有用户下线,当前在线" + getOnlineCount());
            }
        }
    
        /**
         * 收到客户端消息后调用的方法
         * message 就是客户EndPoint但发送过来的消息
         * 都是平等的
         */
        @OnMessage
        public void onMessage(String message ,Session session) {
            log.info("来自客户端消息" + message);
        }
    
        /**
         * 连接错误时指向的方法
         * error就是发生的错误
         */
        @OnError
        public void OnError(Session session, Throwable error) {
            log.error("发生错误" + error.toString());
        }
    
        /**
         * 发送消息给用户,这里就是默认当前的endpoint
         * 使用连接的Session
         */
        public void sendMessage(String message) throws IOException {
            this.session.getBasicRemote().sendText(message);
        }
    
        /**
         * 获取系统当前Time
         */
        private  String getNowTime() {
            Date date = new Date();
            DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            return format.format(date);
        }
    
        /**
         * 给指定的用户发送消息,调用上面的方法即可
         */
        public void sendToUser(String username, String message) {
            String nowTime = getNowTime();
    
            //需要用户在线才可以发送,socketMap就是用户连接就会生成一个EndPoint对象
            try {
                if(webSocketMap.get(username) != null) {
                    webSocketMap.get(username).sendMessage(nowTime + "用户" + username + ":消息" + message);
                } else {
                    log.error("当前用户不在线");
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        /**
         * 广播消息
         * 当前用户也是一个EndPoint,需要排除
         */
        public void  broadCast(String message) {
            log.info("broadCast(),message={}",message);
            webSocketMap.keySet().forEach(key -> {
                if(!Objects.equals(currentUser,key)) {
                    ChatEndPoint endPoint = webSocketMap.get(key); //代表的用户对应的端点
                    synchronized (endPoint) { //必须加锁,保证线程安全
                        try {
                            endPoint.sendMessage(message); //向该哦那个胡发送消息
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            });
        }
    
    
        /**
         * 改变当前在线人数,需要注意方法应该线程安全
         */
        public static  synchronized  int getOnlineCount() {
            return onlineCount;
        }
    
        /**
         * 可修改的成员变量一次只能一个线程修改
         */
        public static synchronized void addOnlineCount() {
            ChatEndPoint.onlineCount ++;
        }
    
        public static synchronized void subOnlineCount() {
            ChatEndPoint.onlineCount --;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166

    接下来使用前端JS访问这个服务端

    DOCTYPE html>
    <html>
    
    <head>
        <>
        <meta name="viewport" charset="utf-8" />
        <title>WebSocket 客户端title>
    head>
    
    <body>
    <div>
        <input type="button" id="btnConnection" value="连接" />
        <input type="button" id="btnClose" value="关闭" />
        <input type="button" id="btnSend" value="发送" />
    div>
    <script src="/jquery.js">script>
    <script type="text/javascript">
        var socket;
        if(typeof(WebSocket) == "undefined") {
            alert("您的浏览器不支持WebSocket");
    
        }
    
        $("#btnConnection").click(function() {
            //实现化WebSocket对象,指定要连接的服务器地址与端口
            socket = new WebSocket("ws://localhost:8081/chat/"+ "Cfeng");
    
            //打开事件
            socket.onopen = function() {
                alert("Socket 已打开");
                //socket.send("这是来自客户端的消息" + location.href + new Date());
            };
            //获得消息事件
            socket.onmessage = function(msg) {
                alert(msg.data);
            };
            //关闭事件
            socket.onclose = function() {
                alert("Socket已关闭");
            };
            //发生了错误事件
            socket.onerror = function() {
                alert("发生了错误");
            }
        });
    
        //发送消息
        $("#btnSend").click(function() {
            socket.send("这是来自客户端的消息" + location.href + new Date());
        });
    
        //关闭
        $("#btnClose").click(function() {
            socket.close();
        });
    script>
    body>
    
    html>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    前台主要使用了WebSocket对象,首先应该type查看是否支持,不支持那就无法进行实时通讯监控

    INFO 14392 --- [nio-8081-exec-3] c.J.c.websocket.ChatEndPoint             : 有新人加入,当前在线1
    2022-08-02 19:00:47.261  INFO 14392 --- [nio-8081-exec-3] c.J.c.websocket.ChatEndPoint             : session = org.apache.tomcat.websocket.WsSession@3520aae9 ||| username =Cfeng
    2022-08-02 19:00:47.262  INFO 14392 --- [nio-8081-exec-3] c.J.c.websocket.ChatEndPoint             : broadCast(),message=Cfeng上线了
    2022-08-02 19:04:07.382  INFO 14392 --- [nio-8081-exec-5] c.J.c.websocket.ChatEndPoint             : 来自客户端消息这是来自客户端的消息http://localhost:8081/websocket.htmlTue Aug 02 2022 19:04:07 GMT+0800
    2022-08-02 19:05:18.499  INFO 14392 --- [nio-8081-exec-6] c.J.c.websocket.ChatEndPoint             : 有用户下线,当前在线0
    
    • 1
    • 2
    • 3
    • 4
    • 5

    最基础的websocket的运用就时利用WebSocket提供的依赖,socket连接会一直保持直到用户主动退出

    某个用户一旦上线就会创建一个EndPoint对象【所以指定@Component创建对象】,其属性session就是 当前用户的会话;所以可以使用Map管理所有的对象,标识用户通过id即可; 前台使用 使用new WebSocket(URL)就可以新建一个连接,指定的就是@ServerEndPoint的URL,连接之后创建对象由 容器管理, webSocket send close 方法就可以操作发送消息和关闭, 客户端就则使用onerror,onclose,onopen等监听 , 和服务端的监听类似

    消息模块为聊天程序的核心部分,主要要实现群聊、私聊、广播等消息。基于可靠性、生态性和开发难度考虑,消息模块基于STOMP协议进行开发,实现群聊和系统消息功能,接下来就是围绕该消息模块进行开发

    基于Webscoket主要关注的时各种Webscoket事件,比如socket开启,关闭,错误,消息等事件,前后台分别使用相关事件监控,开发就是针对不同的事件进行开发即可,但是上面的封装不够流畅,服务端和客户端还是由很大的工作量,所以选用Websocket协议的子协议STOMP

    STOMP协议

    STOMP(streaming text orentated message protocol)流文本定向消息协议,是一种MOM(message oriented middleware)面向消息中间件设计的简单文本协议,STOMP提供可以互操作的连接格式,允许客户端和任意的STOMP消息代理进行交互【MQ是再详解】

    STOMP协议是基于帧的协议,可以基于WebSocket协议进行传输; 一帧由命令、可选标头、可选消息体组成

    对STOMP协议来说:client分为消费者client和生产者,server为broker代理 --- 消息队列的管理者,本身是消息队列的一种协议,恰巧用于定义websocket

    STOMP协议基于文本,允许使用二进制传输,默认编码UTF-8;也可以其他的编码

    //STOMP协议帧
    COMMAND
    header1: value1
    header2: value2
    
    Body^@
    
    //比如
    SEND
    destination: /app/marco
    content-length: 20
    
    {\"message\":\"Marco"\}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    STOMP协议的优点就是命令模式,常用的命令如下:

    • SEND: 发送帧,将消息发送到目的地
    • SUBSCRIBE: 订阅帧,注册收听目的地的消息
    • BEGIN: 开始帧,用于开启事务
    • COMMIT: 提交帧,提交当前正在进行的事务
    • ABORT: 中止帧,回滚正在进行的事务
    • ACK: 确认帧,确认来自订阅方的消息
    • NACK: 非确认帧,告知服务器当前客户端未使用该订阅消息
    • DISCONNECT: 关闭帧,客户端可以随时关闭与服务端的连接,不能保证客户端已经收到先前发送的帧,关闭帧命令可以实现确认是否收到先前的帧

    基于STOMP协议的消息模块

    小欢Chat的消息模块将直接使用STOMP协议搭建,使用STOMP(其为webscoket子协议),直接引入spring提供的websocket的starter

    基于STOMP协议的消息服务设计思路很简单,开启了STOMP服务之后,用户发送消息给应用程序或者直接给消息代理brocker,订阅相关频道(端点)的用户都可以收到消息,比如公共频道【/topic/public】,所有用户加入聊天室时都会Subscribe该频道,达到聊天群消息的实现

    		
    		
    			org.springframework.boot
    			spring-boot-starter-websocket
    		
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 要开启STOMP(区别与普通的websocket),需要创建配置文件,实现WebSocketMessageBrokerConfigurer,同时使用@EnableWebSocketMessageBroker放置再实现类上开启STOMP(Websocket消息代理) 配置STOMP的服务端点和服务代理
    package com.Cfeng.XiaohuanChat.config;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.messaging.simp.config.MessageBrokerRegistry;
    import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
    import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
    import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
    
    /**
     * @author Cfeng
     * @date 2022/8/3
     * 该类为WebSocket子协议STOMP协议启用的配置类
     * 需要启用STOMP协议
     * @EnableWebSocketMessageBroker的作用就是引入DelegatingWebSocketMessageBrokerConfiguration类,
     * 将该注解放在WebSocketMessageBrokerConfigurer实现类上面就可以开启STOMP
     */
    
    @Configuration
    @EnableWebSocketMessageBroker  //引入配置类,开启STOMP自动配置
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
        /**
         * 注册端点
         * @param registry  端点注册器
         * 将/ws路径注册为一个STOMP端点,允许所有IP访问,让其支持SockJS(用于不支持websocket协议的浏览器上模拟websocket)
         */
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS();
        }
    
        /**
         * 注册STOMP消息代理 --- 目的地前缀
         * @param registry
         * 注册"目的地前缀",目的地前缀与 @MessagingMapping、@SubscribeMapping联合使用,可以控制消息的分发与处理
         */
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            //配置客户端向服务端发起请求时,需要以app为前缀
            registry.setApplicationDestinationPrefixes("/app");
            //消息的发送目的地址复合配置的前缀才会发送给这个代理broker
            registry.enableSimpleBroker("/topic""/queue");
            //给指定用户发送消息的前缀/user/
            registry.setUserDestinationPrefix("/user/");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    上述配置中: 注册STOMP端点后,在订阅和发布消息到目的地路径前都需要连接该端点,该路径与发送、接收、订阅的路径不同; 也就是客户端通过ws://XXXX/ws与服务端建立Websocket连接,之后就可以进行STOMP帧在不同的路径进行流通

    而消息代理Broker会处理前缀为/topic和/queue的消息, 创建了用于发送和接收消息的消息代理

    应用程序Server将会处理带有/app前缀的消息,在Controller中使用@MessageMapping和另一个订阅Mapping都会默认加上应用前缀/app, 而/user 开始的路径都会重路由到某个用户独有的目的地

    在这里插入图片描述

    • MessageHandler: 消息处理
    • MessageChannel: 消息发送渠道,生产者和消费者之间消息发送的抽象: 订阅通道

    消息从生产到消费的流程:

    客户端连接到ws://localhost:XX/ws <-----配置的endpoint; 建立之后,STOMP的帧在其上面流动,客户但发送/topic/message的目的地header的SUBSCRIBE帧,接收解码发送到clientInboundChannel,路由到消息代理,消息代理存储客户端的订阅

    客户端向/app/add发送一个SEND帧,/app路由到带注解的控制器,去掉/app前缀,剩余的部分映射到@MessageMapping的方法,返回处理结果, 如果@PayLoad转换消息,返回值默认为/topic/add【将/app替换为/topic】,如果没有@SentTo或者User注解,那么就会重路由到/topic/add

    消息代理broker会找到所有匹配的订阅者,通过clientOutBoundChannel向每一个订阅者发送MESSAGE帧,消息编码为STOMP帧在websocket连接上发送

    封装自定义的消息类型,实现消息的处理,借用@MessageMapping等注解

    @MessageMapping, @SubsicribeMapping @PayLoad SimpMessagingTemplate

    使用消息的处理器,其实就类似与之前的MVC的处理器,@MessageMapping等注解和@RequestMapping类似,都是处理器映射,处理的是/app的应用程序消息,处理时会去掉/app前缀

    二者区别?

    @SubsicribeMapping 请求-回应模式,订阅目的地后,预期在该目的地获得一次性的响应,类似HTTP GET,但是GET是同步,该方式为异步,可以在响应成功才处理,只会处理客户端向server使用SUBSCIRBE发送的消息【订阅可以是消息代理broker也可以是server直接

    @MessageMapping 一次订阅,多次获取,订阅后,预期在该目的地获得多次响应,只会处理SEND发送的消息

    @PayLoad 是一个参数注解,可以转换传入数据为指定类型

    • SimpMessageSendingOperations: 实现类SimeMessagingTemplate【boot自动注入】An implementation of SimpMessageSendingOperations Also provides methods for sending messages to a user. UserDestinationResolver for more on user destinations. 模板方法可以转换消息转发
    • SimpMessageHeaderAccessor: A base class for working with message headers in simple messaging protocols that support basic messaging patterns. Provides uniform access to specific values common across protocols such as a destination, message type (e.g. publish, subscribe, etc), session ID, and others. 消息头访问器,处理消息头 ,可以向Session中放入数据
    • StompHeaderAccessor :【上面的SimpMessageHeaderAccessor为父类】 use when creating a Message from a decoded STOMP frame, or when encoding a Message to a STOMP frame. 【stomp头访问器】也就是将STOMP帧和消息相互转换

    使用STOMP协议完成聊天服务很easy,因为STOMP broker相当于一个television,会自动给转发各频道的消息,公共频道就会自动

    用户上线提醒【公共频道】

    用户上线时将访问/app/chat.addUser,发送上线的消息,直接消息处理器会将该用户从消息中取出放入headerAccessor的sessionAttributes中,将消息转发到公共频道

    /**
     * @author Cfeng
     * @date 2022/8/3
     * 利用STOMP协议实现消息的控制分发处理
     * 处理的是server的应用程序消息,处理时会去掉/app前缀 -- 配置的
     * 默认情况下,如果不指定Send,会替换/app为/topic
     */
    
    @Controller
    @RequiredArgsConstructor
    public class ChatController {
    
        private final SimpMessageSendingOperations messageTemplate;
    
        private final ChatMessageService messageService;
    
        private final ChatUserService chatUserService;
    
        /**
         * @param chatMessageVo  向客户端发送的数据,使用@Paload转换为ChatMessage【messaging.handler中的】
         * 发送到/topic/public 消息broker进行发放推送
         */
        @MessageMapping("/chat.sendMessage")
    //    @SendTo("/topic/public")
        public void sendMessage(@Payload ChatMessageVo chatMessageVo) {
            //转发消息时持久化消息类型为Chat的消息
            if(Objects.equals(chatMessageVo.getType(),ChatMessage.MessageType.CHAT)) {
                chatMessageVo.setCreateTime(LocalDateTime.now());
                //存储的是ChatMessage
                ChatMessage message = new ChatMessage()
                        .setSender(chatMessageVo.getSender())
                        .setReceiver(chatMessageVo.getReceiver())
                        .setType(chatMessageVo.getType())
                        .setContent(chatMessageVo.getContent())
                        .setCreateTime(chatMessageVo.getCreateTime());
                //持久化message对象
                messageService.saveChatMessage(message); //需要给出发送消息的时间
            }
    //        System.out.println(chatMessageVo.getReceiver());
            //查询user.sender的图像
            chatMessageVo.setSenderHeadImg(chatUserService.queryUser(chatMessageVo.getSender()).getUserHeaderImg());
            //转发时需要注意消息接收者,转发给对应的user 代理发送
            if(StringUtils.isNullOrEmpty(chatMessageVo.getReceiver())) {
                //公共频道,使用模板转换消息为流动的STOMP负载payload,进行流动转发
                messageTemplate.convertAndSend("/topic/public",chatMessageVo);
            } else {
                //私聊服务,只有自己和对方可见消息,所以需要给自己和对方发送消息,路径可以随意,因为识别的是/user/ + 用户标识 + /chat --- 自定义
                messageTemplate.convertAndSendToUser(chatMessageVo.getReceiver(),"/chat",chatMessageVo);
                messageTemplate.convertAndSendToUser(chatMessageVo.getSender(),"/chat",chatMessageVo);
            }
        }
    
        /**
         * 将消息转发到目的地/topic/public,客户端会订阅/topic/public端点,剩下消息的群发
         * 用户上线消息转发
         * 使用SimpMessageHeaderAccessor消息头访问器访问协议头STOMP
         */
        @MessageMapping("/chat.addUser")
        @SendTo("/topic/public")
        public ChatMessageVo addUser(@Payload ChatMessageVo chatMessageVo, SimpMessageHeaderAccessor headerAccessor) {
            //通过访问器向session中添加username项
            headerAccessor.getSessionAttributes().put("username",chatMessageVo.getSender());
            return chatMessageVo;
        }
    
        /**
         * 定制消息聊天记录服务供用户订阅
         * 需要查询最新发送的10条记录,分世界频道,这里使用分发
         */
        @SubscribeMapping("/chat.lastTenMessage")
        public List<ChatMessageVo> getMessageHistory(Principal principal) {
            List<ChatMessage> messages = messageService.findWorldLastTenMessage(principal.getName());
            //排序,利用comparator的comparing方法
            messages.sort(Comparator.comparing(ChatMessage::getCreateTime));
            List<ChatMessageVo> resultList = new ArrayList<>();
            for(ChatMessage chatMessage : messages) {
                ChatMessageVo chatMessageVo = new ChatMessageVo();
                chatMessageVo.setSender(chatMessage.getSender())
                        .setContent(chatMessage.getContent())
                        .setReceiver(chatMessage.getReceiver())
                        .setCreateTime(chatMessage.getCreateTime())
                        .setType(chatMessage.getType())
                        .setSenderHeadImg(chatUserService.queryUser(chatMessage.getSender()).getUserHeaderImg());
                resultList.add(chatMessageVo);
            }
            return resultList;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88

    可以将用户上线消息通过消息代理发送给订阅端点的各个客户端

    用户下线通知【异常监听】

    用户下线通知 【基于EventListener】 不能和上线通知类似,下线经常是因为网络等异常,可以使用监听器实现用户下线功能, 监听的是SessionDisConnectEvent事件,获取user转发消息到公共频道【借助template】

    /**
     * @author Cfeng
     * @date 2022/8/3
     * websocket的事件监听器,监听用户下线消息
     * 创建的单例对象放入的容器,所以可以直接监听在线用户
     */
    
    @Component
    @Slf4j
    public class WebSocketEventListener {
        //消息模板,自动配置的,其实现对象为SimpMessagingTemplate类型
        @Resource
        private SimpMessageSendingOperations messagingTemplate;
    
        /**
         * 监听事件基于websocket提供的SessionConnectEvent,用户上线连接就会触发spring的此监听的方法
         */
        @EventListener
        public void handleWebSocketConnectLister(SessionConnectedEvent event) {
            System.out.println(event.getUser());
            log.info("Received a new web connection");
        }
    
        /**
         * 让spring监听websocket连接断开事件,表明用户下线,使用模板发送到公共频道
         * 使用StompHeaderAccessor进行消息与帧转换
         */
        @EventListener
        public void handleWebSocketDidConnectLister(SessionDisconnectEvent event) {
            //通过断开事件的消息wrap一个头访问器,为simpXX的子类,可以取出之前的放入的数据
            StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
            //获取session的数据
            String username = (String) headerAccessor.getSessionAttributes().get("username");
            if(username != null) {
                log.info("{} disconnected,下线了",username);
                ChatMessage chatMessage = new ChatMessage().setType(ChatMessage.MessageType.LEAVE).setSender(username).setContent("");
                //通过消息转换器放入STOMP帧,转发给消息代理公共频道
                messagingTemplate.convertAndSend("/topic/public",chatMessage);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    JS客户端 socketJS,stomp.js

    客户端就是利用SocketJS模拟Websocket,同时使用stomp.js操作客户端,send发送消息

    构造STOMP客户端,可以直接基于websocket协议使用client方法,或者Stomp.over使用socketJS模拟的http的地址,访问后台构造的服务端点,建立连接

    使用相关的send方法就可以发送消息,简单版本,基本的公共频道聊天服务的客户端【没有登录限制,只能公共聊天,不会加载聊天记录】

    这是最开始的最基础的JS代码,完善后就不是这么点就搞定了

    <script type="text/javascript">
        console.log(2323);
        //客户端
        var stompClient = null;
        //登录用户名
        var username = null;
        //连接函数,输入用户名后隐藏输入框
        function toConnect(event) {
            console.log("开始连接");
            username = $("#name").get(0).value.trim();
            if(username) {
                //登录成功之后,隐藏登录DIV
                $("#username-page").get(0).classList.add('hidden');
                //显示ChatPage
                $("#chat-page").get(0).classList.remove('hidden');
                //创建Socket,基于Http创建,前台路径,自动补齐Http
                let sockJs = new SockJS('/ws');
                stompClient = Stomp.over(sockJs);
                //连接,提交用户名和密码,成功和失败的回调函数
                stompClient.connect({},onConnected,onError)
            }
            event.preventDefault();
        }
        //让表单提交事件关联connect和sendMessage处理方法
    
        // $("#usernameForm").get(0).addEventListener('submit',connect,true);
        // $("#messageForm").get(0).addEventListener('submit',sendMessage,true);
    
        function sendMessage(event) {
            let messageContent = $("#message").get(0).value.trim();
            //消息不为空,连接正常
            if(messageContent && stompClient) {
                let chatMessage = {
                    sender: username,
                    content: $("#message").val(),
                    type: 'CHAT'
                };
                //将消息发送给服务端的sendMessage进行消息处理
                stompClient.send("/app/chat.sendMessage",{},JSON.stringify(chatMessage));
                //恢复
                $("#message").get(0).value = '';
            }
            event.preventDefault();
        }
    
    
        //连接成功的处理
        function onConnected() {
            //连接成功,订阅/topic/public端点,回调函数对结果进行处理,发送的消息
            stompClient.subscribe("/topic/public",onMessageReceived);
            //发送用户名到addUser,告知上线, send函数中{}为header头,最后是body,header可以为{}
            stompClient.send("/app/chat.addUser",{},JSON.stringify({sender: username, type: 'JOIN'}));
            //隐藏连接中...
            $(".connecting").get(0).add('hidden')
        }
    
        //收到消息的处理
        function onMessageReceived(payload) {
            //返回的消息payload的body,首先经过JSON.parse进行反序列化,之后判断消息类型
            let message = JSON.parse(payload.body);
            //添加li结点
            let messageElement = document.createElement('li');
    
            if(message.type == 'JOIN') {
                messageElement.classList.add('event-message');
                message.content = message.sender + ' joined !';
            } else if(message.type == 'LEAVE') {
                messageElement.classList.add('event-message');
                message.content = message.sender + ' left !';
            } else {
                messageElement.classList.add('chat-message');
                //创建一个聊天的span
                let userNameElement = document.createElement('span');
                let userNameText = document.createTextNode(message.sender + ':');
                userNameElement.appendChild(userNameText);
                messageElement.appendChild(userNameElement);
            }
            //创建p文本
            let textElement = document.createElement('p');
            let messageText = document.createTextNode(message.content);
            textElement.appendChild(messageText);
            messageElement.appendChild(textElement);
            //li放入聊天域ul
            $("#messageArea").get(0).appendChild(messageElement);
            $("#messageArea").get(0).scrollTop = $("#messageArea").get(0).scrollHeight;
        }
    script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87

    用户聊天记录

    聊天记录功能 ---- 用户进入聊天室后可以加载进入前的部分聊天记录; 需要将聊天记录持久化【只是持久化Chat类型的消息】,所以在server的send转发处理需要保存消息

    首先需要经聊天记录存入数据库 ---- MongoDB【海量】,@Doucument代表由MongoDB管理(使用repository的方式可以忽略底层) 将上面的放在domain中未持久化的聊天信息放到entity中,加上一个创建时间属性, 需要将聊天记录持久化进入MongoDB,这里首先需要配置数据库

    @Configuration
    public class MongoDBconfig {
    
        @Value("${spring.data.mongodb.database}")
        private String database;
    
        @Value("${spring.data.mongodb.uri}")
        private String uri;
    
        @Bean
        public MongoClient mongoClient() {
            ConnectionString connectionString = new ConnectionString(uri);
            //连接的uri创建Mongo客户端设置
            MongoClientSettings mongoClientSettings = MongoClientSettings.builder().applyConnectionString(connectionString).build();
            return MongoClients.create(mongoClientSettings);
        }
        //再创建MongoTemplate对象 【redisTemplate对象如果是Lettuce是自动配置了的,但是不管Lettuce还是Jedis可以手动配置修改参数】
        //使用上面的客户端和数据库名
        @Bean
        public MongoTemplate mongoTemplate() throws Exception {
            return new MongoTemplate(this.mongoClient(),database);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    为ChatMessage实体添加注解@Document代表由MongoDB管理, 之后建立相关repository和相关的Service

    在用户加入聊天室时,subscribe 服务server的聊天记录 【创建@SubscribeMapping 聊天记录处理器】

    用户聊天【世界频道,私聊】

    客户端的消息不能直接发送到消息broker,需要app进行send

    • 世界频道: 如果用户选择世界频道,也就是聊天室内所有的用户可见,直接发送给server的Mapping转发到public 的broker
    • 私聊: 用户选择在线用户列表,选择用户后,发送消息,后天在send位置检测用户消息类型,如果为私聊,则使用MessageTemplate发送到对应的用户

    进行私聊服务,需要配置WebSocket中的User目的地前缀 /user 【可以直接再配置broker目的地前缀/user】 , 之后配置私聊聊天路径/user/{user identity}/chat , 用户订阅自己的私有频道==/user/{XXX}/chat==, 收听私聊频道

    私聊的实现只要订阅个人唯一标识的频道,发送消息带有receiver经过后台处理之后前台对消息进行处理即可

    发送给sender和receiver的频道即可实现双方信息展示

    基于Spirng security的安全模块

    Spring security需要过滤相关的HTTP请求,进行资源授权,同时还需要保护WebSocket连接【避免被攻击】

    这里Spring security需要与Websocket进行整合,保护websocket连接,先引入依赖

    		<dependency>
    			<groupId>org.springframework.securitygroupId>
    			<artifactId>spring-security-messagingartifactId>
    		dependency>
    
    • 1
    • 2
    • 3
    • 4

    websocket安全配置需要继承 ,重写configurerInbound方法,通过消息安全数据资源注册器配置授权的路径,同时可以将sameOriginDisabled关闭同源策略【允许跨域】 === STOMP的端点注册位置可以不用再配置==

    @Configuration
    public class SocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
    
        @Override
        protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
            messages
                    .simpDestMatchers("/**").authenticated() //所有路径都需要登录才能访问
                    .anyMessage().authenticated();  //所有的消息都需要登录才能查看
        }
    
        @Override
        protected boolean sameOriginDisabled() {
            return true;  //允许跨域访问
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    基于MongoDB、Mysql的存储模块

    基于MongoDB存储主要就是因为MongoDB最适合存储大数据量,而这里的聊天消息恰好就是十分庞大,所以聊天记录就存储在MongoDB中

    而登录的用户等其他的表就存放在mysql数据库中,是可以同时配置的,配置一个Mongo的配置类声明客户端,在需要持久化到MongoDB数据库的实体类Message上面加上@Document即可

    @Configuration
    public class MongoDBconfig {
    
        @Value("${spring.data.mongodb.database}")
        private String database;
    
        @Value("${spring.data.mongodb.uri}")
        private String uri;
    
        @Bean
        public MongoClient mongoClient() {
            ConnectionString connectionString = new ConnectionString(uri);
            //连接的uri创建Mongo客户端设置
            MongoClientSettings mongoClientSettings = MongoClientSettings.builder().applyConnectionString(connectionString).build();
            return MongoClients.create(mongoClientSettings);
        }
        //再创建MongoTemplate对象 【redisTemplate对象如果是Lettuce是自动配置了的,但是不管Lettuce还是Jedis可以手动配置修改参数】
        //使用上面的客户端和数据库名
        @Bean
        public MongoTemplate mongoTemplate() throws Exception {
            return new MongoTemplate(this.mongoClient(),database);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    一定需要注意加上@Document注解

    @Accessors(chain = true)
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Document //MongoDB管理
    public class ChatMessage {
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这里使用的持久层框架为JPA,因为很方便,当然Mybatis-plus也可以,都很方便,安全模块的配置DataSource就需要JPA配置完全正确,上面安全模块提到了,这里不再深入

    总体来说,作为基础的桩,持久层一定要确保没有问题,使用SpringBoot的自动化测试很方便进行测试💙

    项目problem记录

    此项目思路清晰出错就会少一点,但是有些问题比较容易forget,记录一下

    Caused by: java.lang.IllegalArgumentException: Property ‘dataSource’ is required

    这是在配置安全模块时,记住密码的token_logins访问数据源时出现错误,和yml配置有关系,要正确配置才能识别datasource

    spring:
      datasource:
        url: jdbc:mysql://localhost:3306/xiaohuan_chat?servertimezone=GMT%2B8
        username: cfeng
        password: XXXXX
        #当执行schema和data.sql的用户不同时,可以配置相关的username和password
        driver-class-name: com.mysql.cj.jdbc.Driver
        dbcp2: #连接池的相关配置
          initial-size: 10
          min-idle: 10
          max-idle: 30
          max-wait-millis: 3000
          time-between-eviction-runs-millis: 200000 #检查关闭相关连接的时间
          remove-abandoned-timeout: 200000
      jpa: #Spring data jpa的配置,dialect是properties下面的
        show-sql: true
        open-in-view: true
        database: mysql
        hibernate:
          ddl-auto: update
          naming:
            physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy #SpringPhysical就是遇到下划线转大写
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    javax.servlet.ServletException: Circular view path [XXX]:

    would dispatch back to the current handler URL [/sys/hello] again.

    Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)

    这里的意思就是默认的路径解析器认为这里路径成为闭环了,所以解决办法就是让请求的路径和转发的路径不同即可,比如请求/hello, 转发页面就不应该为hello

    进入controller不能通过return 跳转 templates中的页面

    首先检查是否controller的问题,仔细检查没有问题,最后突然发现是忘记引入thymeleaf依赖了

    		<dependency>
    			<groupId>org.springframework.bootgroupId>
    			<artifactId>spring-boot-starter-thymeleafartifactId>
    		dependency>
    
    • 1
    • 2
    • 3
    • 4

    不引入模板依赖,系统就只能识别static下面的普通的html页面,要识别templates下面的页面,必须引入模板依赖,不管是mustache还是thymeleaf都可以

    两个静态页面之间传递数据

    传递数据方直接window.location.href, 在URL地址中填入?XXX= XXX即可发送数据,使用encodeURI保证传递中文不乱码

    数据接收方使用decodeURI解析数据, 接收到的消息实际上为URL,需要进行处理,获取到当前的数据,首先slice,之后以& 进行split,分别获取等号后的值【当有数据时才处理】

    //页面跳转方
    location.href = encodeURI("/login.html?" + "userName=" + chatUser.userName + "&userPwd=" + chatUser.userPwd);
    
    //数据接收方
    			//字符串裁剪slice,-1没有找到
    			var data = decodeURI(document.URL);
    			if(data.indexOf('?') != -1) {
    				data = data.slice(data.indexOf('?') + 1);
    				var arr = data.split('&');
    				//只有username和password,简单处理
    				var username = arr[0].slice(arr[0].indexOf('=') + 1);
    				var password = arr[1].slice(arr[1].indexOf('=') + 1);
    				console.log(username + ":" + password);
    			}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    这样就可以获取传递的数据,decodeURI每次访问都会自动获取当前的地址栏的完整路径,进行编码后就可以传递中文

    同时为了保证数据安全,这里使用base64进行加密

    //加密
    window.btoa(XXX)
    //解密
    window.atob(xxx)
    
    • 1
    • 2
    • 3
    • 4

    前台document.cookie获取的为空字符串

    这是因为cookie设置了HttpOnly属性为true;设置为true之后前台就不能访问该cookie,可以避免一些攻击,如果想要获取cookie,那么就改为false

    SyntaxError: JSON.parse: unexpected character at line 1 column 2 of the JSON data

    这是前台JSON转化时可能出现的问题,该报错的意思为待转化对象已经为json对象了,不能再parse,所以需要注意数据的格式

    Security获取当前登录用户信息

    获取的方式起始就是通过Bean, 获取到安全容器中的Authentication, 提供了多种派生类转换,所以可以随意转化:

    //获取Bean
    SecurityContextHolder.getContext().getAuthentication()
    
    • 1
    • 2

    可以直接使用Authentication类型来接收当前的AuthenticationToken这个Bean

    当然也可以直接使用Principal类型进行接收,因为该Bean已经注入到容器中,所以可以直接在方法位置加入依赖参数,会自动注入该Bean【配置类中就是相互为参数即可】

    Spring security注册后自动登录

    如果没有security,博主之前准备的方式为注册后跳转登录页面,将数据传送过去,base加密,但是用户密码存在威胁

    //该对象在配置类中配置过,使用configuration进行配置	
    @Resource
        protected AuthenticationManager authenticationManager;
        /**
         * 注册提交
         * @param user
         * @return
         */
        @PostMapping("/register")
        public String registerUser(User user,HttpServletRequest request) {
            //添加用户-角色关系
            List<Authority> authorities = new ArrayList<>();
            authorities.add(authorityService.getAuthorityById(ROLE_USER_AUTHORITY_ID));
            user.setAuthorities(authorities);
           //添加用户
            userService.saveUser(user);
            //进行授权登录
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
            try{
                token.setDetails(new WebAuthenticationDetails(request));
                Authentication authenticatedUser = authenticationManager.authenticate(token);
                SecurityContextHolder.getContext().setAuthentication(authenticatedUser);
                request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
            } catch( AuthenticationException e ){
                System.out.println("Authentication failed: " + e.getMessage());
                return "redirect:/register";
            }
            //跳到首页
            return "redirect:/";
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    Load denied by X-Frame-Options: does not permit framing.

    Spring security下,X-Frame-Options默认为DENY,非Spring Security环境下,X-Frame-Options的默认大多也是DENY,这种情况下,浏览器拒绝当前页面加载任何Frame页面,设置含义如下:

    ​ DENY:浏览器拒绝当前页面加载任何Frame页面
    SAMEORIGIN:frame页面的地址只能为同源域名下的页面
    ALLOW-FROM:origin为允许frame加载的页面地址。

    可以在security的安全配置文件中配置header

    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        // 省略部分代码
     
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            super.configure(http);
            //disable 默认策略。 这一句不能省。 
            http.headers().frameOptions().disable();
            //新增新的策略。 
            http.headers().addHeaderWriter(new XFrameOptionsHeaderWriter(
                    new WhiteListedAllowFromStrategy(
                            Arrays.asList("http://www.baidu.com", "https://www.baidu.com",
                                    "https://www.sougou.com"))));
     
        }
     
        // 省略部分代码
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    文件上传使用ajax: Current request is not a multipart request,MissingServletRequestPartException FileUploadException: the request was rejected

    这里可能的原因就是没有设置mimeType或者ContentType为“multipart/form-data” ,要将请求设置为POST,前两个异常就没有了

    最主要的就是表单提交会自动附加Boundary,ajax如果不设置是没有的,会出现because no multipart boundary was found

    需要将ajax的 processData 选项设置为false, 这样才会自动附加boundary

    下面贴一个完整的代码

    	 <div class="face-container">
                <span class="regist">头像span><br>
                <img id="user-header" style="width: 100px;height: 100px;object-fit: cover;" src="">
                
                <input type="file" name="file" id="file" style="display: none;"/>
            div>
            <div class="action-container">
                <button type="button" onclick="submitUser()">提交button>
            div>
    
    
    <script type="application/javascript">
        submitUser = function() {
            alert("开启");
            if(!$("#userName").val() || !$("#userPwd").val()) {
                alert("不能为空");
                return false;
            }
            console.log($("#file").get(0).files[0]);
            console.log({"userName":$("#userName").val(),"userPwd":$("#userPwd").val(),"file":$("#file").get(0).files[0]});
            <!-- 可以使用formdata提交post请求 -->
            var form = new FormData();
            //append方法将请求参数装入
            form.append("userName",$("#userName").val());
            form.append("userPwd",$("#userPwd").val());
            form.append("file",$("#file").get(0).files[0]);
            $.ajax({
                url: "/login/register",
                type: 'POST',
                contentType:  false ,  //"multipart/form-data; boundary=----WebKitFormBoundaryXUIhYHlAmsiYAKUG",  //因为上传图片,关闭type
                processData: false,  //会自动加上boundary部分
                cache: false,
                mimeType: "multipart/form-data",
                data: form,        //JSON.stringify({,})也可以进行数据的手动封装
                success: function (response) {
                    console.log(response);
                    var chatUser = response.data;
                    //自动填充,Get方式传递数据
                    console.log(chatUser);
                    chatUser.userPwd = window.btoa(chatUser.userPwd);
                    location.href = encodeURI("/login.html?" + "userName=" + chatUser.userName + "&userPwd=" + chatUser.userPwd);
                }
            });
        }
    
        //事件等同
        $("#user-header").click(function() {
            $("#file").click();
        })
    
        $("#file").change(function(e) {
            console.log(e.target.files[0]);
            var imgUrl = e.target.files[0];
            //前台使用reader读取图片立即显示
            var reader = new FileReader();
            reader.readAsDataURL(imgUrl);
            //读取完成显示
            reader.onload = function() {
                //读取结果
                var imgSrc = reader.result;
                $("#user-header").attr("src",imgSrc);
            }
        })
    script>
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66

    上面的js代码为让图片进行自动选择之后就可以显示【隐藏选择文件的样式】 ajax提交需要设置processData为false, 类型为multipart/form-data; 使用 new form-data 的append方法来提交POST参数 【不一定使用JSON.stringfy手动封装】

    同时需要注意后台配置的文件保存路径,个人认为配置绝对路径就好 : D:XXX:XXXX

    相对路径有的时候挺让人疑惑,博主之前配置的相对路径,直接测试是好的,但是经过controller就跑到IDEA的安装目录下面的另外一个项目去了…

    后台@RequestBody使用: 不能处理异常,类型不匹配

    这是因为@RequestBody使用是有限制的,不是任何的请求都可以使用

    注意get和post请求,用@RequestBody处理get就炸了 @RequestBody常用来处理Content-Type不是form-data或x-[www-form-urlencoded]编码的内容,例如application/json, application/xml等

    正常的使用普通表单提交的就是x-www-form-urlencoded, 或者封装的form-data,所以后台可以直接使用对象,然后mvc会自动将参数从reqeust中提取出来,自动赋值给对象同名属性,但是如果前台使用ajax提交的类型为 application/json, 那么后台就必须使用@RequestBody将对象接收赋值, 也就需要封装为JSON对象,之后stringfy到前台

    所以如果请求类型不是基本的form-data或者X-www… 那么就使用该注解,其余时候不需要,用了就会出现类型的异常,可以直接将对象作为参数让frame自动注入属性值即可

    并且Get方式是不能使用@RequestBody的,只有ajax提交自定义了格式才可能使用

    thymeleaf页面JavaScript中获取model注入变量

    modelAndView中addObject, 在${} 就是取上下文, #{}时取thymeleaf工具的方法、文字消息表达式, *{} 一般跟在th : object后, 选择object的属性

    所以一般使用th即可完成渲染

    但是在script标签中,必须使用文本域方式的引用,也就是[[]]

    使用[[${chatUser.userHeadImg}]],就可以将值显示

    在JavaScript中要使用该方式,必须加上引号,才能注入

    var currentUserImg = "[[${chatUser.userHeaderImg}]]";
    
    • 1

    使用JS 后期append新增的DOM结点的点击事件无效, 无法操作id

    Jquery的append就是向一个标签中添加子标签,追加;

    还有before和after,before是增加该结点的兄弟结点,在当前结点之前,语法为:

    $("choose").before($("增加的Html内容字符串"));    按照html()解析
    比如:
    $(".sendto").before($("
    "))
    • 1
    • 2
    • 3

    这是因为之前的页面DOM结点已经加载完毕,后期的结点不能再通过选择器直接找到: 两种解决办法: 事件委托 ---- 委托给Document

    直接添加行级事件,添加参数为this对象,或者${this}

    <a onclick='convertUser(this)'
    
    • 1

    无法获取的解决办法:每一个操作元素都增加一个last()选择器

    $().last().XXX 
    
    $("#chatMessage").last().append(msg);
    
    • 1
    • 2
    • 3

    springBoot静态资源虚拟路径

    图片展示显示不成功,就是因为图片是放在本地的硬盘上面,不是在默认的几个static-locations里面

    默认的是: spring.resources.static-locations=classpath:/static,classpath:/public,classpath:/resources,classpath:/META-INF/resource

    如果需要追加硬盘路径,以==file:==开头即可 【不是classpath】

    web:
        resources:
          static-locations: classpath:/static,classpath:/public,classpath:/resources,classpath:/META-INF/resource,file:${file.upload-dir}
    
    • 1
    • 2
    • 3
  • 相关阅读:
    springboot 整合 dubbo 实现分组聚合
    听GPT 讲Istio源代码--pilot(4)
    udp协议下sendto与recvfrom函数对应的errno
    Flutter Image组件如何处理图片加载过程中的错误?
    电子标准院工程师鲍薇:人工智能标准化引领产业发展
    google浏览器安装vuejs-devtools插件2022年安装记录
    vsto转换为windows服务 并部署服务
    09-锚点&精灵图
    spring initializr脚手架搭建详解
    CSS学习|css三种导入方式、基本选择器、层次选择器、结构伪类选择器、属性选择器、字体样式、文本样式
  • 原文地址:https://blog.csdn.net/a23452/article/details/126274489