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

发布于:2023-01-22 ⋅ 阅读:(14) ⋅ 点赞:(0) ⋅ 评论:(0)

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'

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

<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.7.2</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.cfeng</groupId>
	<artifactId>XiaohuanChat</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>war</packaging>
	<name>XiaohuanChat</name>
	<description>基于STOMP协议的小欢聊天室web项目</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!-- websocket 依赖-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

		<!-- Lombok依赖 -->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-tomcat</artifactId>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<!-- 需要排除vintage-engine-->
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!-- mongodb依赖-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-mongodb</artifactId>
		</dependency>
		<!-- 自动配置依赖-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-autoconfigure</artifactId>
		</dependency>
		<!-- Websocket 集成 spring  security-->
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-messaging</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

项目的pom依赖如上

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

config    controller entity service repository security

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

该请求与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 请求扩展

WebSocket响应格式同样会返回

Upgrade: websocket
Connection: Upgrade
  • 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中

		<!-- 测试使用websocket简单协议连接 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
		<dependency>
			<groupId>javax.websocket</groupId>
			<artifactId>javax.websocket-api</artifactId>
			<!--			<version>1.1</version>-->
		</dependency>

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();
    }
}

首先是关键的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<ChatEndPoint> chatEndPoints = new CopyOnWriteArraySet<>();

    //维护在线的用户列表, 与set合并就是上面的map
//    private static HashMap<String,String> 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 --;
    }
}

接下来使用前端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>

前台主要使用了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

最基础的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"\}

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

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

基于STOMP协议的消息模块

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

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

		<!-- websocket 依赖-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
  • 要开启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/");
    }
}

上述配置中: 注册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;
    }
}

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

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

用户下线通知 【基于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);
        }
    }
}

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>

用户聊天记录

聊天记录功能 ---- 用户进入聊天室后可以加载进入前的部分聊天记录; 需要将聊天记录持久化【只是持久化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);
    }
}

为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.security</groupId>
			<artifactId>spring-security-messaging</artifactId>
		</dependency>

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;  //允许跨域访问
    }

基于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);
    }

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

@Accessors(chain = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document //MongoDB管理
public class ChatMessage {

这里使用的持久层框架为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就是遇到下划线转大写

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.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>

不引入模板依赖,系统就只能识别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);
			}

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

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

//加密
window.btoa(XXX)
//解密
window.atob(xxx)

前台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()

可以直接使用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:/";
    }

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"))));
 
    }
 
    // 省略部分代码
}

文件上传使用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>

<!-- 下面为js -->
<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>

<!-- 上面的请求为form-data,也就是使用new fromData的append参数,和基本的表单一样,后台直接用对象或者变量接收,不使用@RequstBody -->

上面的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}]]";

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

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

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

$("choose").before($("增加的Html内容字符串"));    按照html()解析
比如:
$(".sendto").before($("<div id='" + receiver +"channel' class='recvfrom'>"))

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

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

<a onclick='convertUser(this)'

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

$().last().XXX 

$("#chatMessage").last().append(msg);

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}