SpringBoot
内容管理
- 基于STOMP协议聊天项目
- 项目problem记录
- Caused by: java.lang.IllegalArgumentException: Property 'dataSource' is required
- javax.servlet.ServletException: Circular view path [XXX]:
- 进入controller不能通过return 跳转 templates中的页面
- 两个静态页面之间传递数据
- 前台document.cookie获取的为空字符串
- Security获取当前登录用户信息
- Spring security注册后自动登录
- Load denied by X-Frame-Options: does not permit framing.
- 文件上传使用ajax: Current request is not a multipart request,MissingServletRequestPartException FileUploadException: the request was rejected
- 后台@RequestBody使用: 不能处理异常,类型不匹配
- thymeleaf页面JavaScript中获取model注入变量
- 使用JS 后期append新增的DOM结点的点击事件无效, 无法操作id
- 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
- 如果使用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 aMessage
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}