This is the multi-page printable view of this section.
Click here to print.
Return to the regular view of this page.
Java进阶能力
内容来源:
Java进阶能力
Java作为一门编程语言, 需要和其他组件一起完成某个项目
一名好的程序员不仅要知道去哪里import, 更要知道import什么组件, 甚至为组件贡献代码
- Redis
- WebSocket
- FastExcel报表
- Gateway网关(SpringCloudGateway)
- 中间件
- 消息队列
- Nacos配置中心
- Sential声明式服务
需要相关模块依赖, 如下是模块的实例
wms-sample-mq
模块
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent> <groupId>com.hamhuo.star</groupId>
<artifactId>wms-java</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>wms-sample</artifactId>
<dependencies> <!-- spring mvc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- common -->
<dependency>
<groupId>com.hamhuo</groupId>
<artifactId>wms-common</artifactId>
</dependency>
<dependency> <groupId>com.hamhuo.star</groupId>
<artifactId>wms-domain</artifactId>
</dependency>
<!-- alibaba nacos discovery -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- alibaba nacos config -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
</dependencies>
</project>
启动类
/**
* <p> 描述:模块程序启动入口
* </p>
*/
@SpringBootApplication
@EnableDiscoveryClient
@EnableBinding(Source.class)
public class MqApplication {
public static void main(String[] args) {
SpringApplication.run(MqApplication.class, args);
}
}
1 -
1.1 - FastDFS分布式存储
分布式存储中间件
为什么需要文件服务器?
浏览器请求可能会携带头像文件, 服务器处理后存储在本地磁盘
但是假如还有一个服务需要操作订单
那么就需要异步从存储头像拿头像
也就是说用户信息业务崩溃后, 导致订单服务器也一并崩溃
为了解耦合, 我们把文件存储到专门的服务器里, 保证系统稳定
当用户在订单管理修改信息后, 还需要同步到用户信息服务器
这需要传递两次, 还要保证一致性, 倒不如直接把服务器分离, 只传递文件地址
这样就可以减少服务间的传递, 而且也不需要传递大尺寸的文件, 只要传链接就可以
使用
检查nacos配置
第三方服务
下载地址是由nginx管理的, 所以还需要nginx配置
注入fastdfs客户端
配置文件服务器url 也就是 nginx的IP地址
上传文件
传入 MultipatFile file
首先提取后缀名, 左闭右开去掉点放到新的子串
直接调客户端上传即可
用户回显, 是否上传成功, 看有没有文件信息就可以
下载
也就是响应文件, 创建响应实体内部放字节流
调客户端下载, 传入group组和文件id
下载完成的文件命名, 用时间就可以
定义好的名字回传道响应的响应头
删除文件
还是组和文件id
调客户端删除
回调是int值, 匹配定义的预设输出传回即可
2 -
2.1 - RocketMQ
MQ的一种, 高并发常用
了解: WebSocket
学习过 websocekt
后, 我们知道通过建立长连接可以实现 服务器和浏览器的双向推送
通过浏览器协调, 可以实现端对端的通信
webServer
初始化一个 Session 池作为核心容器, 当 webServer
在分布式中作为服务注入时, 其他服务也要调用消息怎么办?
可以让其他服务作为消费者, 让服务生产消息, 但是webserver
一旦负责消息通讯和消息生产职责就不再单一, 需要解耦
长连接传递的消息是即时的, 一旦连接断开消息就会丢失, 我们需要一个方法持久化消息
长连接会阻塞服务线程, 建立连接后双方不得不占用一个线程持续的监听, 需要把监听的任务解耦出去. 一旦并发消息过多, 服务器可能会崩溃
综上, 我们有了消息队列
场景描述:当系统中的应用程序需要异步通信时,可以使用消息中间件来实现。
示例:在一个电商系统中,订单系统下单后需要给库存系统发送异步消息,以减少系统之间的耦合,避免同步调用导致的性能问题或响应延迟。
优势:
- 减少耦合:系统可以在不等待响应的情况下继续进行其他操作。
- 提高系统性能:减少了同步阻塞,提高了响应速度。
- 应用解耦
场景描述:当应用程序需要进行松耦合的通信时,可以使用消息中间件来实现。
示例:在微服务架构中,不同服务之间可以通过消息中间件进行通信,避免直接调用服务导致的依赖关系过强。
优势:
- 降低耦合度:服务之间通过消息中间件进行通信,不需要直接调用,提高系统的灵活性。
- 增强可维护性:服务之间的更新和维护不影响其他服务。
- 消息排队
场景描述:当系统中需要处理大量的消息时,可以使用消息中间件来实现消息排队,确保消息的顺序和可靠性。
示例:在金融行业中,需要处理大量的交易消息,消息中间件可以实现消息排队,确保每笔交易都得到正确处理。
优势:
- 保证消息顺序:确保按顺序处理每条消息。
- 消息可靠性:即使出现系统故障,未处理的消息仍然可以从队列中恢复。
- 负载均衡
场景描述:当系统需要处理大量的请求时,可以使用消息中间件来实现负载均衡。
示例:在电商系统中,订单系统下单请求可以通过消息中间件发送到多个库存系统中,从而实现负载均衡。
优势:
- 分担负载:通过将消息分发到多个消费者,实现负载均衡,避免单点压力。
- 提高系统吞吐量:通过多节点并发处理提高系统处理能力。
- 系统削峰填谷
场景描述:当系统中出现高峰期时,可以使用消息中间件来平滑处理请求。
示例:在电商系统中,双十一等促销活动可能会导致系统请求量急剧增加,消息中间件可以通过缓存请求,避免系统崩溃。
优势:
- 平滑流量:在高峰期通过消息队列缓存请求,避免系统过载。
- 提高系统稳定性:避免高并发请求导致的性能瓶颈或服务崩溃。
使用 RocketMQ
并发环境下的消息队列用阿里提供的 RocketMQ
合适
server:
port: ${sp.publish}
spring:
application:
name: ${sn.publish}
cloud:
stream:
rocketmq:
binder:
name-server: ${rocket-mq.name-server}
bindings:
output:
producer:
group: socketGroup
sync: true
bindings:
output:
destination: test-topic
content-type: application/json
#开启MQ的日志
logging:
level:
com:
alibaba:
cloud:
stream:
binder:
rocketmq: DEBUG
项目依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>
写 Swagger
配置, 不同于 webSocket
mq作为异步消息组件可以用C-S模型, 也就是请求-响应模型, 我们从 swagger测试请求响应
/**
* <p> * 描述:Swagger配置
* </p>
* @author hamhuo
* @version 1.0.0
*/@Configuration
@EnableSwagger2WebMvc
public class SwaggerConfig {
@Bean
Docket rpApi() {
return SwaggerCore.defaultDocketBuilder("消息推送", "com.zeroone.star.mq.controller", "publish");
}
}
消息发布组件 RmqPublish
, 该组件封装了发布方法
/**
* <p> * 描述:消息发布组件
* </p>
* @author hamhuo
* @version 1.0.0
*/@Component
public class RmqPublish {
@Resource
Source source;
public void publish(SampleNotifyDTO dto) {
source.output().send(MessageBuilder.withPayload(dto).build());
}
}
这里通知DTO封装的是 websocket
的客户端id 和消息
/**
* <p> * 描述:示例通知数据对象
* </p>
* @author hamhuo
* @version 1.0.0
*/@Data
public class SampleNotifyDTO {
/**
* 客户端编号
*/
private String clientId;
/**
* 通知消息内容
*/
private String message;
}
继续之前, 我们要解释一下发布组件
@Component
是声明式注解, 要求 Spring
作为组件注入
- 通过
@Resourse
注入了一个依赖, 该注解会查找指定名称的资源/Bean, 如果不指定就通过反射拿指定类型的资源, 这里查找到了一个 Source
Source
接口返回一个 MessageChannel
这个可以理解为发送消息到消息队列
- 构建信息后链式调用 output 返回一个 MC, 调用MC发送信息
Source 信息源接口如下

其中 @OutPut
注解 要求 Spring
容器将名字为 OUTPUT
值的消息频道与中间件绑定
因此这里我们需要做一些配置
rocketmq:
binder:
name-server: ${rocket-mq.name-server}
bindings:
output:
producer:
group: socketGroup
sync: true
bindings:
output:
destination: test-topic
content-type: application/json
我们先看mq
的配置
spring.cloud.rocketmq
下配置名称服务器地址, 这里已经通过 nacos
配置好了
RocketMQ 的 NameServer 是 RocketMQ 集群中的一个核心组件,它提供了一个简单的服务注册与发现机制。具体来说,NameServer 主要用于管理 RocketMQ Broker 的信息,客户端通过访问 NameServer 来查询和获取消息队列的路由信息,从而实现消息的发送和接收。
这是为了让 Spring Cloud Stream 知道如何将消息发送到 RocketMQ
之后再配置 bindings.output
下配置生产者
producer.group
: 配置生产者的 消费组,用于指定该生产者的分组。
sync
: 如果设置为 true
,则表示生产者将同步发送消息。即,发送消息后会等待消息发送确认,确保消息发送成功。
生产者就是在 RocketMQ 中生产信息的角色

然后是第二个 bindings
, 这是 Spring Cloud 的配置
bindings:
output:
destination: test-topic
content-type: application/json
这里的 output
配置与 Source.OUTPUT
直接相关。通过这个配置,Spring Cloud Stream 会将 Source.OUTPUT
这个输出通道的 消息目标(即 destination
)设置为 test-topic
,并且设置消息的 内容类型 为 application/json
。这意味着,消息将通过名为 output
的通道发送到 test-topic
主题,并且消息格式是 JSON。
总结下, 为了保证代码松耦合, Spring 将消息发送和驱动(也就是连接不同品牌的MQ)分离开, 将驱动交由第三方提供. spring只需要调用Source发送消息即可
3 - AOP面向切面编程
The key unit of modularity in OOP is the class, whereas in AOP the unit of modularity is the aspect. Aspects enable the modularization of concerns (such as transaction management) that cut across multiple types and objects.
Aspect-oriented Programming (AOP) complements Object-oriented Programming (OOP) by providing another way of thinking about program structure.
AOP是OOP的延续, 两者互补
为什么要用AOP
为了不涉及过多术语, 在介绍AOP之前, 我们介绍下为什么要提出AOP这个概念
OOP作为Java的核心设计原则, 是存在一些缺点的.
比如日志, 我们知道可以用slf4j 提供的工厂发法返回logger,
但是这个logger的作用范围是初始化的类, 为了实现全局日志需要在所有业务类注入这个logger, 也就是@Slf4j注解
这会导致代码冗余
类似的还有 事务管理
4 - FastExcel报表组件
报表组件
提前封装好一些服务
快速导入
组件扫描
写一个单元测试
导入excel客户端
生成excle
新建一个数组
调用导入功能
解析
传入文件
5 - Gateway网关
网关是一个位于微服务架构前端的组件,它充当了所有微服务的入口
什么是网关
网关负责路由请求、负载均衡、安全认证、流量控制、监控和日志记录等任务。
我的理解是, 网关是请求真正进入服务前的过滤, 管理, 控制(重定向), 可以理解为保安大哥
网关的结构

客户端发起请求, 由 Gateway Handler Mapping
接收, 这里执行断言并路由到 Gateway Web Handler
这里做过滤链, 过滤请求, 最后传递给服务
过滤器可以在执行前和执行后回调
在“pre”类型的过滤器可以做参数校验、权限校验、流量监控、日志输出、协议转换等;
在“post”类型的过滤器中可以做响应内容、响应头的修改,日志的输出,流量监控等有着非常重要的作用。
了解:
响应式编程
过滤器
过滤器是由 Servlet
容器管理的, 也就是说初始化

实践: Spring Cloud Gateway 全局过滤器
网关选型SpringCloudGateway
这是目前比较新的选择
模板工程基于 pmhub
新建自定义的全局过滤器
全局过滤器需要实现 GlobalFilter
过滤器需要确定执行顺序, 实现Order
接口
@Component
public class AuthFilter implements GlobalFilter, Ordered
该过滤器实现如下功能
- 请求耗时记录
- 白名单过滤
- Token鉴权
- 日志输出
请求耗时记录:
全局过滤器的优先级较高, 因此把请求进入过滤器的时间作为起点, 业务执行完毕执行post
业务逻辑. 进行简单的时间相减
白名单过滤:
nacos
配置白名单, 有些路径不需要严格的鉴权, 比如首页. 所有人发请求都能通过, 就直接跳过完事
Token
鉴权:
作为全局过滤器, 当用户请求进入应当进行身份鉴权, 过滤无效的请求
从请求头里拿 Token
, 跟缓存做匹配即可
Redis
策略是到期销毁, 不做更新
日志输出:
在过滤链回调后执行, 代表服务执行完成
结束时间减去开始时间就行
为了方便解释, 我们一项项实现
请求耗时:
@Component
public class AuthFilter implements GlobalFilter, Ordered{
//常量, 记录开始访问的时间
private static final String BEGIN_VISIT_TIME = "begin_visit_time";//开始访问时间
//补上日志实例
private static final Logger log = LoggerFactory.getLogger(AuthFilter.class);
//过滤器接口方法, 这个必须实现
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//token和白名单稍后实现
//在上下文加上开始时间
exchange.getAttributes().put(BEGIN_VISIT_TIME, System.currentTimeMillis());
//等待链执行完成回调
return chain.filter(exchange)
//链式调用then, 使用Mono.fromRunnable方法顺序执行日志输出
.then(Mono.fromRunnable{})
//日志输出的逻辑
// 记录接口访问日志
Long beginVisitTime = exchange.getAttribute(BEGIN_VISIT_TIME);
if (beginVisitTime != null){
logData.put("duration", (System.currentTimeMillis() - beginVisitTime) + "ms");
log.info("访问接口信息: {}", logData);
}
}
}
白名单过滤
@Component
public class AuthFilter implements GlobalFilter, Ordered{
//常量, 记录开始访问的时间
private static final String BEGIN_VISIT_TIME = "begin_visit_time";//开始访问时间
//补上日志
private static final Logger log = LoggerFactory.getLogger(AuthFilter.class);
//过滤器接口方法
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//白名单
//首先拿到请求路径
//上下文拿请求
ServerHttpRequest request = exchange.getRequest();
//请求拿请求路径, 先拿URI, 再去掉首部
String url = request.getURI().getPath();
// 跳过不需要验证的路径, 直接进下一级
if (StringUtils.matches(url, ignoreWhite.getWhites())) {
return chain.filter(exchange);
}
//在上下文加上开始时间
exchange.getAttributes().put(BEGIN_VISIT_TIME, System.currentTimeMillis());
//等待链执行完成回调
return chain.filter(exchange)
//链式调用then, 使用Mono.fromRunnable方法顺序执行日志输出
.then(Mono.fromRunnable{})
//日志输出的逻辑
// 记录接口访问日志
Long beginVisitTime = exchange.getAttribute(BEGIN_VISIT_TIME);
if (beginVisitTime != null){
logData.put("duration", (System.currentTimeMillis() - beginVisitTime) + "ms");
log.info("访问接口信息: {}", logData);
}
}
}
token鉴权:
选型 Jwt
下图是流程, 我们将鉴权的业务从服务层分离移至网关进行, 优化性能
鉴权(认证)是在网关层进行的,而 请求并没有进入到 MVC 控制器中, 也就没有触发拦截器
了解:
SpringMVC

@Component
public class AuthFilter implements GlobalFilter, Ordered{
//常量, 记录开始访问的时间
private static final String BEGIN_VISIT_TIME = "begin_visit_time";//开始访问时间
//补上日志
private static final Logger log = LoggerFactory.getLogger(AuthFilter.class);
//补上redis
@Autowired
private RedisService redisService;
//过滤器接口方法
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//白名单
//首先拿到请求路径
//上下文拿请求
ServerHttpRequest request = exchange.getRequest();
//请求拿请求路径, 先拿URI, 再去掉首部
String url = request.getURI().getPath();
// 跳过不需要验证的路径, 直接进下一级, 返回链的执行结果
if (StringUtils.matches(url, ignoreWhite.getWhites())) {
return chain.filter(exchange);
}
//剩下的请求过筛子
//从请求拿token
String token = getToken(request);
if (StringUtils.isEmpty(token)) {
return unauthorizedResponse(exchange, "令牌不能为空");
}
//拿JWT的声明, 这里写了一个字符串工具拿声明, 令牌秘钥就在工具里
Claims claims = JwtUtils.parseToken(token);
if (claims == null) {
return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
}
//从声明拿用户ID
String userkey = JwtUtils.getUserKey(claims);
//从缓存匹配用户ID的token, 这里getTokenKey格式化, 就加了个前缀
boolean islogin = redisService.hasKey(getTokenKey(userkey));
if (!islogin) {
return unauthorizedResponse(exchange, "登录状态已过期");
}
//最后从声明拿用户详细信息
String userid = JwtUtils.getUserId(claims);
String username = JwtUtils.getUserName(claims);
if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) {
return unauthorizedResponse(exchange, "令牌验证失败");
}
//重新设置请求, 鉴权完毕
//设置用户信息到请求
addHeader(mutate, SecurityConstants.USER_KEY, userkey);
addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid);
addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username);
// 内部请求来源参数清除(防止网关携带内部请求标识,造成系统安全风险)
removeHeader(mutate, SecurityConstants.FROM_SOURCE);
//在上下文加上开始时间
exchange.getAttributes().put(BEGIN_VISIT_TIME, System.currentTimeMillis());
//等待链执行完成回调
return chain.filter(exchange)
//链式调用then, 使用Mono.fromRunnable方法顺序执行日志输出
.then(Mono.fromRunnable{})
//日志输出的逻辑
// 记录接口访问日志
Long beginVisitTime = exchange.getAttribute(BEGIN_VISIT_TIME);
if (beginVisitTime != null) {
URI uri = exchange.getRequest().getURI();
Map<String, Object> logData = new HashMap<>();
logData.put("host", uri.getHost());
logData.put("port", uri.getPort());
logData.put("path", uri.getPath());
logData.put("query", uri.getRawQuery());
logData.put("duration", (System.currentTimeMillis() - beginVisitTime) + "ms");
log.info("访问接口信息: {}", logData);
log.info("我是美丽分割线: ###################################################");
}
}
}
addHeader
和 removeHeader
其实没什么说的, 就是重新构造了个请求, 方便后续服务直接拿信息
private void addHeader(ServerHttpRequest.Builder mutate, String name, Object value) {
if (value == null) {
return;
}
String valueStr = value.toString();
String valueEncode = ServletUtils.urlEncode(valueStr);
mutate.header(name, valueEncode);
}
private void removeHeader(ServerHttpRequest.Builder mutate, String name) {
mutate.headers(httpHeaders -> httpHeaders.remove(name)).build();
}
6 - Intercepter拦截器
拦截器 Interceptor 在 Spring MVC 中的地位等同于 Servlet 规范中的过滤器 Filter,拦截的是处理器的执行,由于是全局行为,因此常用于做一些通用的功能,如请求日志打印、权限控制等。
7 - JWT与Session
一种令牌格式, 改进了 Session 需要状态管理的缺点, 通过用户提供token来实现令牌发放
作为令牌格式, JWT 和 session 在用户认证上时常用到
Session
经典的 Session
登录流程如下
-
用户向服务器发送用户名和密码。
-
服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
-
服务器向用户返回一个 session_id,写入用户的 Cookie。
-
用户随后的每一次请求,都会通过 Cookie,将 session_id
传回服务器。
-
服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
单机环境下, session 实现简单, 方便快速, 服务器维护与用户的连接(下称 Sesssion
), 用户传入 id 来更新session
但是分布式集群下, 各个服务需要不断的请求并写入session, 这使得session服务器成为了登录中心服务器, 一旦中心服务器挂了, 整个分布应用宕机
既然服务器维护session成本这么高, 干脆不维护了, 让用户维护
于是诞生了 JWT
JWT
JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。
{
"姓名": "张三",
"角色": "管理员",
"到期时间": "2018年7月1日0点0分"
}
服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
8 - Nacos配置中心
动态决策中心, 当然可以理解为分布式的配置中心
9 - OAuth2认证
OAuth 2.0, which stands for “Open Authorization”, is a standard designed to allow a website or application to access resources hosted by other web apps on behalf of a user.
OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版。
为什么需要 OAuth
以网易云音乐云村账号登录为例
传统的用户授权方式是
- 用户输入用户名密码
- 服务器验证密码, 这里需要存储用户密码到本地
- 返回session/token
这对于单一服务没什么问题, 因为用户敏感信息和服务都是由网易云存储并提供的
但是网易云也支持qq登录
按照常识, 用户不会把qq密码提供给网易, 没别的意思, 就是不合逻辑
那么上述流程就存在问题了
- 网易云为了实现qq登录, 不得不保存qq用户信息并部署qq密码登录
- 网易获得了登录用户qq的权限
- qq密码权限范围被意外扩大了, 现在不止腾讯, 网易也可以获取密码了, 被破解的几率增加
所以我们需要在网易和腾讯之间设置一个转换层, 密码在腾讯处理, 通过转换层把令牌交给网易
角色介绍
OAuth 就是这样的一种协议
此协议定义了四种角色
- 客户端/客户, 一个应用程序发出 API 请求对受保护资源执行一些行为,而这些行为是经过资源拥有授权的
- 资源拥有者Resource owner):通常是一个应用的用户,也是资源拥有者,它拥有授予访问在资源 服务器上的资源
- 授权服务器Authorization server):当前授权服务器从资源拥有者达成共识时,它会发放访问令牌 给客户以允许访问在资源服务器上的受保护的资源 。比较小的 API 提供商可能会使用相同的应用和 URL空间作为授权服务器和资源服务器
- 资源服务器Resource server):该服务器是用户拥有资源的服务器,它是受 一般我们叫做 OAuth 保护的服务器。 API 提供商,因为它拥有受保护的数据,比如图片、视频、日历或者合同等。

OAuth 的优点包括简化了用户授权流程,提高了安全性,允许用户选择性地授权资源访问,并支持多 种授权方式(如授权码模式、密码模式、客户端模式等)。它广泛应用于各种互联网应用程序中,例如第 三方登录、 API 访问授权等场景。
实践: Spring Cloud GateWay + 密码模式的 OAuth2 授权模型
网关, JWT与Session
警告: 最新的 OAuth 废弃了密码模式鉴权, 此种方法会将密码暴露给客户端
搭建认证服务模块 OAuth2
配置文件
OAuth2 要求使用 JWT 作为令牌格式, 这里需要 RSA 证书
提取公/私钥
10 - SpringMVC
MVC 也就是 Model-View-Controller, 这种模式用于应用程序的分层开发
11 - TTL
基于 TransmittableThreadLocal (TTL) 自定义请求头拦截器,将Header 数据封装到线程变量中方便获取,减少用户信息数据库查询次数,同时验证当前用户有效期自动刷新有效期。
TransmittableThreadLocal
(TTL) 是增强版的 ThreadLocal
前置:
ThreadLocal
, 拦截器
实现TTL
流程图如下

前置:
Gateway网关
上半部分业务为用户请求登录并返回Token
该业务从MVC剥离, 设置在网关进行
我们主要关注下半部分, 用户携带 Token
后如何在服务层获取用户信息
整体流程为:
-
用户登录请求:
- 用户通过提交用户名和密码进行登录,经过网关的认证,认证服务(如
Auth
)验证用户身份并生成 token
。
- 登录请求返回
token
给用户,用户保存 token
。
-
后续请求携带 Token 登录:
- 用户在后续请求中携带该
token
,这个 token
用于证明用户的身份。
-
Nginx 负载均衡:
- 请求首先被发送到
Nginx
,Nginx 会根据负载均衡策略将请求转发到具体的 网关(Gateway
)。
-
网关鉴权:
- 网关负责根据请求中的
token
进行 鉴权,验证该 token
是否有效。如果验证通过,网关会继续转发请求;如果验证失败,则拒绝请求或重定向至登录页面。
-
拦截器将用户信息放入 TTL:
- 在请求经过网关后,网关会调用拦截器(如
HeaderInterceptor
)。拦截器会提取请求中的用户信息(如 token
解密后的用户信息),然后将这些信息存放到 TTL(Thread-Local)中。
- TTL 是一种线程局部存储机制,用于在当前线程内传递数据,确保在同一请求的生命周期内,后续的服务(如
ProjectService
等)能够访问到这些用户信息。
-
后续服务从 TTL 获取用户信息:
- 后续的服务(如
ProjectService
)可以通过访问当前线程的 TTL 中的数据来获取用户信息,而无需每次都从请求中提取或解析 token
。
我们所关注的 TTL 在拦截器里实现
新建 HeaderInterceptor.java
public class HeaderInterceptor implements AsyncHandlerInterceptor
该拦截器实现了拦截器接口, 该业务在请求执行之前执行拦截业务逻辑, 所以重写 preHandle
在请求执行完毕后需要删除用户信息, 所以重写 afterCompletion
public class HeaderInterceptor implements AsyncHandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { }
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
}
首先是 preHandle
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//不是控制器方法不拦截
if (!(handler instanceof HandlerMethod)) {
return true;
}
//自定义holder 用来设置信息
SecurityContextHolder.setUserId(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USER_ID));
SecurityContextHolder.setUserName(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USERNAME));
SecurityContextHolder.setUserKey(ServletUtils.getHeader(request, SecurityConstants.USER_KEY));
//根据Token创建用户
String token = SecurityUtils.getToken();
if (StringUtils.isNotEmpty(token)) {
LoginUser loginUser = AuthUtil.getLoginUser(token);
if (StringUtils.isNotNull(loginUser)) {
AuthUtil.verifyLoginUserExpire(loginUser);
SecurityContextHolder.set(SecurityConstants.LOGIN_USER, loginUser);
}
} else {
// 首页免登场景展示
// 检查请求路径是否匹配特定路径
String requestURI = request.getRequestURI();
if (isExemptedPath(requestURI)) {
// 创建一个默认的 LoginUser 对象
LoginUser defaultLoginUser = createDefaultLoginUser();
SecurityContextHolder.set(SecurityConstants.LOGIN_USER, defaultLoginUser);
}
}
return true;
}
这里使用 TTL的就是 设置信息的工具类
public class SecurityContextHolder{
//TTL
private static final TransmittableThreadLocal<Map<String, Object>> THREAD_LOCAL = new TransmittableThreadLocal<>();
//设置方法, 在线程Map中存储私有信息
public static void set(String key, Object value)
{
Map<String, Object> map = getLocalMap();
map.put(key, value == null ? StringUtils.EMPTY : value);
}
//这里只就一个例子, 存储固定的用户名前缀和用户名的方法
public static void setUserName(String username)
{
set(SecurityConstants.DETAILS_USERNAME, username);
}
}
最后添加其他方法
static {
// 在这里添加所有需要免登录默认展示首页的的路径
EXEMPTED_PATHS.add("/system/user/getInfo");
EXEMPTED_PATHS.add("/project/statistics");
EXEMPTED_PATHS.add("/project/doing");
EXEMPTED_PATHS.add("/project/queryMyTaskList");
EXEMPTED_PATHS.add("/project/select");
EXEMPTED_PATHS.add("/system/menu/getRouters");
}
// 需要免登录的路径集合
private static final Set<String> EXEMPTED_PATHS = new HashSet<>();
// 判断请求路径是否匹配特定路径, 也就是免密白名单
private boolean isExemptedPath(String requestURI) {
// 你可以根据需要调整特定路径的匹配逻辑
return EXEMPTED_PATHS.stream().anyMatch(requestURI::startsWith);
}
// 创建一个默认的 LoginUser 对象
private LoginUser createDefaultLoginUser() {
LoginUser defaultLoginUser = new LoginUser();
defaultLoginUser.setUserId(173L); // 设置默认的用户ID
defaultLoginUser.setUsername(Constants.DEMO_ACCOUNT); // 设置默认的用户名
SysUser demoSysUser = new SysUser();
demoSysUser.setUserId(173L);
demoSysUser.setUserName(Constants.DEMO_ACCOUNT);
demoSysUser.setDeptId(100L);
demoSysUser.setStatus("0");
defaultLoginUser.setUser(demoSysUser);
// 设置其他必要的默认属性
return defaultLoginUser;
}
//结束后清理用户信息, 防止线程复用导致内存泄露
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
SecurityContextHolder.remove();
}
12 - WebSocket
HTTP只能单向向服务器请求, 如果要实现服务器通知, 就必须由客户端轮询, 压力非常大. 并且HTTP会携带无效信息, 延迟很高
因此出现了WebSocket
如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高。
2009年出现的新技术Websocket
允许建立浏览器客户端和服务端之间的双向连接, 并且发送轻量级的数据模型, 服务端可以在需要时直接向客户端推送消息
不同于传统的Http
,Websocket
允许建立通路, 可以直接发送信息而不需像HTTP一样创建连接上下文, 原理如下
在建立TCP连接后, 附带几个请求头
GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade
此后连接升级为长连接
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
收到成功响应后表示WebSocket“握手”成功,这样,代表WebSocket的这个TCP连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可随时向服务器推送消息。双方推送的消息既可以是文本消息,也可以是二进制消息,一般来说,绝大部分应用程序会推送基于JSON的文本消息。
使用 Websocket
开始前阅读 架构说明
建立新模块 sample-ws
<!-- ws -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
启动类
/**
* 描述:程序启动入口
*
* @author hamhuo
* @version 1.0.0
*/@SpringBootApplication
@EnableBinding(Sink.class)
@EnableDiscoveryClient
public class WsApplication {
public static void main(String[] args) {
SpringApplication.run(WsApplication.class, args);
}
}
实现服务端和浏览器的双向通信, 我们要写一个服务器
@Component
@ServerEndpoint("/chat")
public class ChatServer {
/**
* 保存连接对象, 连接池, 放用户session和key
*/
private static final ConcurrentHashMap<String, Session> SESSION_POOL = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session)
@OnMessage
public String onMessage(String text, Session session)
@OnClose
public void onClose(Session session)
@OnError
public void onError(Session session, Throwable throwable)
}
解释一下
-
首先组件注解加上, Spring 会自动装载为组件
-
关于 @ServerEndpoint
WebSocket 是一种基于 TCP 的协议,它是持久化连接,通信过程中不会像 HTTP 请求那样每次都经过请求-响应的流程,因此不能直接通过传统的 Spring MVC 控制器方法来处理。
@ServerEndpoint
是 WebSocket 规范中的一种标注方式,用来标识一个 WebSocket 服务器端点。当客户端连接到这个端点时,会自动调用这个类中的相应方法。
我们只需要知道服务器与 /server 绑定即可
-
我们在server中定义了四个方法, 启动时调用, 消息时调用, 关闭时调用, 错误时调用
-
我们创建一个链接池, 存放浏览器和服务器的长连接
这个端点注解依赖一个配置类

我们在 ws.config
下新建一个配置类 ,这是写死的, 不用理解
/**
* @program: demo
* @description: 服务端点配置
* @author: hamhuo
**/
@Configuration
@EnableWebSocket
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpoint() {
return new ServerEndpointExporter();
}
}
当浏览器首先试图建立与服务器的连接, 这里调用 @OnOpen
注解方法
@OnOpen
public void onOpen(Session session) throws IOException {
//判断客户端对象是否存在
//排除重复的连接对象
if (SESSION_POOL.containsKey(session.getQueryString())) {
CloseReason reason = new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "ID冲突,连接拒绝");
session.getUserProperties().put("reason", reason);
session.close();
return;
}
//将客户端记录到Map中
SESSION_POOL.put(session.getQueryString(), session);
System.out.println("客户端(" + session.getQueryString() + "):开启连接");
}
解释一下, 浏览器会传入一个客户端ID, 服务器根据ID来提供服务
每个ID标识了长连接, 服务器根据ID提供服务
- 传入连接后判断是否重复, 从连接池拿session里的key, 如果重复就拒绝连接
- 没有重复, 将Session保存到连接池(Map)里
- 控制套打印日志, 这里可以用slf4j
注意:
这里的Session
指的是长连接, 和 HTTP Session
不是一个东西
之后长连接建立, 可以双向监听信息, 一旦服务器或者浏览器有信息了就调用 @OnMessage
@OnMessage
public String onMessage(String text, Session session) throws IOException {
//解析消息 => ID::消息内容
String[] msgArr = text.split("::", 2);
//群发消息 ID=all表示群发
if ("all".equalsIgnoreCase(msgArr[0])) {
for (Session one : SESSION_POOL.values()) {
//排除自己
if (session == one) {
continue;
}
//发送消息
one.getBasicRemote().sendText(msgArr[1]);
}
}
//指定发送人, 不是all的情况
else {
Session target = SESSION_POOL.get(msgArr[0]);
if (target != null) {
target.getBasicRemote().sendText(msgArr[1]);
}
}
return session.getQueryString() + ":消息发送成功";
}
消息发送的方式有两种, 点对点和群发
点对点就找出目标Session, 服务器向Session写信息发送就行
群发就需要遍历连接池的每一个Session, 全部写信息
当然, 前提是排除发送者的Session
关闭连接, 也就是浏览器离线需要执行线程池移除线程的操作, 调用 @OnClose
方法
这里注意, webSocket并没有强制关闭连接的能力, 这是通知远程连接自主关闭, 服务器只负责监视是否关闭
@OnClose
public void onClose(Session session) {
//处理拒绝连接session关闭对象, 不从池子里移除
Object obj = session.getUserProperties().get("reason");
if (obj instanceof CloseReason) {
CloseReason reason = (CloseReason) obj;
if (reason.getCloseCode() == CloseReason.CloseCodes.CANNOT_ACCEPT) {
System.out.println("拒绝客户端(" + session.getQueryString() + "):关闭连接");
return;
}
}
//将session对象从map中移除,正常关闭
SESSION_POOL.remove(session.getQueryString());
System.out.println("客户端(" + session.getQueryString() + "):关闭连接");
}
这是比较简单的应用不再考虑连接复用的问题
接下来写浏览器页面
这里我们略过了~
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天室客户端</title>
</head>
<body>
<h1>Chat Client</h1>
<div>
<input id="clientId" placeholder="输入ID" value="1">
<input onclick="init()" value="连接服务器" type="button"><br><br>
<input id="receiverId" placeholder="输入接收人ID" value="all"><br><br>
<textarea id="message" style="margin: 0; height: 197px; width: 362px;"
placeholder="发送消息内容"></textarea><br>
<input onclick="send()" value="发送消息" type="button">
<input onclick="closeConnect()" value="关闭连接" type="button">
</div>
<div id="output"></div>
<script type="text/javascript" language="JavaScript">
//屏幕回显输出方法
function writeToScreen(message) {
let pre = document.createElement("p");
pre.style.wordWrap = "break-word";
pre.innerHTML = message;
document.getElementById("output").appendChild(pre);
}
//初始化websocket
let echo_websocket;
function init() {
let clientId = document.getElementById("clientId").value;
/* 这里端口是写死的 */ let wsUri = "ws://localhost:10800/chat?" + clientId;
writeToScreen("连接到" + wsUri);
//1.创建WebSocket客户端对象
echo_websocket = new WebSocket(wsUri);
//2.开门握手完成回调
echo_websocket.onopen = function (evt) {
console.log(evt);
writeToScreen("连接打开成功 !");
};
//3.监听服务端的消息
echo_websocket.onmessage = function (evt) {
writeToScreen("接收服务端消息:<br> " + evt.data);
};
//4.如果连接中断
echo_websocket.onerror = function (evt) {
writeToScreen('<span style="color: red;">ERROR:'+evt.data+'</span>');
//关闭连接
closeConnect();
};
//5.注册close事件
echo_websocket.onclose = function(evt){
writeToScreen('<span style="color: green;">INFO:关闭连接</span> ');
if(evt.reason){
writeToScreen
(`<span style="color: red;">错误信息:${evt.reason}</span> `);
}
}
}
//6.向服务发送消息
function send() {
let message = document.getElementById("message").value;
let receiver = document.getElementById("receiverId").value;
echo_websocket.send(receiver + "::" + message);
writeToScreen("发送消息: " + message);
}
//7.如果不需要通讯,那么关闭连接
function closeConnect() {
echo_websocket.close();
}
</script>
</body>
</html>
我们需要解释下构建/发送信息的代码
//6.向服务发送消息
function send() {
let message = document.getElementById("message").value;
let receiver = document.getElementById("receiverId").value;
echo_websocket.send(receiver + "::" + message);
writeToScreen("发送消息: " + message);
}
这里拿到表单组件, 取值后通过 ::
拼接为消息, 链接符之前为id, 之后为消息
其中初始化的 echo_websocket
组件是dom库提供的 echo_websocket = new WebSocket(wsUri);
, 除了IE其他浏览器都支持
在前端打开三个浏览器页面, 作为三个客户端

注意id要不同
后台运行服务器, 就可以开始监听了
13 - 事务管理
事务是什么?
一组操作, 要么全部执行, 要么全部失败, 把非原子操作封装为原子操作的概念
还是老生长谈的原子性问题
原子性问题
事务的特性
ACID
原子性: 事务是最小的执行单位, 不允许分割. 事务的原子性确保动作要么全部完成, 要么不起作用
隔离性: 一个用户的事务不能被其他用户干扰, 并发事务之间的数据库独立
持久性: 事务提交后, 数据库改变时持久的, 即使发生故障也不能有任何影响
最终目的: 一致性: 事务前的数据一致, 事务后的数据一致
原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性
MySQL如何保证事务的原子性的?
所有事务操作都会记录到回滚操作日志, 一旦异常发生就会根据日志进行数据回滚
回滚日志的优先级高于数据持久化, 也就是说数据库宕机导致数据没能完整写到磁盘上, 再次开机可以根据回滚日志回滚未完成的事务
14 - 响应式编程Reactor库
15 - 声明式服务