1. JWT
1.1 跨域问题
用户向服务器发送用户名和密码;
服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等;
服务器向用户返回一个 session_id,写入用户的 Cookie;
用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器;
服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
以上是使用 session 进行的一般认证方式,但是对于集群服务器的跨域问题难以解决,这需要几个服务器共享 session。
解决方案:
- 配置 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败;
- 服务器将 session 保存在客户端,每次请求时发回服务器。比如 JWT。
1.2 结构样式
对于 JWT,拥有三个组成部分:
- Header(头部)
- Payload(负载)
- Signature(签名)
1.3.1 Header
{
"alg": "HS256",
"typ": "JWT"
}
//alg 表示签名的算法(algorithm)
//typ 表示这个令牌的类型,JWT 令牌统一写为 JWT
1.3.2 Payload
JWT 规定了7个官方字段:
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
当然,可以在 Payload 中添加私有字段,包括 name、uid、role 等。
1.3.3 Signature
Signature 部分是对前两部分的签名,防止数据篡改。
需要指定一个密钥(secret),然后使用 Header 里指定的签名算法进行签名。
1.3 SpringBoot 结合 JWT
导入对应的 maven 依赖:
<!--导入 maven 依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.18.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
SpringBoot 配置文件:
jwt:
#jwt需要的密钥
secret: 239FJAS993JASLVKCLS02JGFS
#跟前端固定请求头
prefix: Bearer_
#jwt设置过期时间
expiration: 864000
整合提取工具类:
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.Map;
//jwt 实现工具类
@Component
@Data
public class JWTUtil {
private final AccountMapper accountMapper;
private final RedisUtil redisUtil;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
@Value("${jwt.prefix}")
private String prefix;
public static final String USER_LOGIN_TOKEN = "TOKEN";
public JWTUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
this.redisUtil = redisUtil;
this.accountMapper = accountMapper;
}
/**
* 给每个账户生成一个 token
*
* @param account 用户信息
* @return token
*/
public String generateToken(Account account) {
Date nowDate = new Date();
Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
JWTCreator.Builder builder = JWT.create();
builder.withClaim("id", account.getId());
builder.withClaim("username", account.getUsername());
builder.withClaim("role", account.getRole());
builder.withExpiresAt(expiredDate);
return prefix + builder.sign(Algorithm.HMAC256(secret));
}
//获取 token 具体信息
public Map<String, Claim> getClaims(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
DecodedJWT decodedJWT = verifier.verify(token);
return decodedJWT.getClaims();
} catch (IllegalArgumentException | JWTVerificationException e) {
throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
}
}
public Account getAccount(String token) {
Map<String, Claim> map = getClaims(token);
return new Account(
map.get("id").asLong(),
map.get("username").asString(),
map.get("role").asInt());
}
//判断 token 是否过期
/*public boolean isTokenExpired(String token) {
Date expiresAt = getClaims(token).get("exp").asDate();
return expiresAt.getTime() - System.currentTimeMillis() < 0;
}*/
//存储在 redis 中的 token 是否过期
public boolean isTokenExpired(String token) {
Account account = getAccount(token);
long expiration = redisUtil.ddl(account.getUsername());
return expiration <= 0;
}
//判断 token 是否需要刷新
private boolean isTokenNeedRefresh(String key) {
long expiration = redisUtil.ddl(key);
return expiration < (this.expiration * 1000 >> 1);
}
//刷新 token 过期时间
/*public String refreshToken(String token) {
Date nowDate = new Date();
Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
Account account = getAccount(token);
JWTCreator.Builder builder = JWT.create();
builder.withClaim("id", account.getId());
builder.withClaim("username", account.getUsername());
builder.withClaim("role", account.getRole());
builder.withExpiresAt(expiredDate);
return prefix + builder.sign(Algorithm.HMAC256(secret));
}*/
//刷新 token 存在时间
public void refreshExpiration(String key) {
if (isTokenNeedRefresh(key)) {
redisUtil.expire(key, expiration);
}
}
}
2. Redis
2.1 什么是 Redis
我们使用 Redis 来实现 NoSQL (not only sql)。
主要是作为一个中间件,在真实的服务中,有时像后端发送的信息量会很大,如果这期间还是使用数据库的话,读取速度会很慢。Redis 提供了一个缓存,这样写入和读取数据都要快上不少。
1.方便扩展(数据之间没有关系,很好扩展);
2.大数据高性能(Redis一秒写八万次,读取11万,NoSQL 缓存记录级);
3.数据类型是多样的;
4.传统 RDBMS 和 NoSQL。
Redis 是单线程的,但是运行速度十分的快,一秒钟读取接近十万条读取数据。
CPU>内存>硬盘。
核心:Redis 将所有数据全部放在内存中,所以读写速度非常快。
在之后的八股文整合中,会更加详细的描述 MySQL 以及 Redis 的内容。
2.2 启动 Redis
Windows 的 Redis 少有人去维护更新,现在的版本停留在5版本,所以还是建议 Linux Docker 部署 redis 服务。
Redis 的容器启动时,如果没有指定配置文件,会默认无配置文件启动(当然这本身是被允许的,Redis 本身就有很多被注释掉的配置)。
在 Redis 的配置文件中,在注释中有详细的解释。
# 首先创建好一个本地的 Redis 配置文件
mkdir conf
cd conf
touch redis.conf
vim redis.conf
# 创建 data 目录,用于存储持久化缓存的 redis 数据
mkdir data
# 配置文件内容:
protected-mode no
requirepass sast_forever
# 我们只需要关闭保护模式和配置密码就可以直接使用了
# 具体配置信息可以参考 Windows 端的 redis 的配置文件说明
# 指定本地的 redis.conf 文件和容器的配置文件绑定
docker run -p 7000:6379 --name redis -v $PWD/conf/redis.conf:/etc/redis/redis.conf -v $PWD/data:/redis/data -d redis redis-server /etc/redis/redis.conf
# 可以直接在启动的时候直接使用参数配置连接密码(不推荐)
--requirepass "sast_forever"
redis-server /etc/redis/redis.conf:使用指定的配置文件启动redis
可以使用 Redis DeskTop Manager 对 Redis 的连接管理(可以像 navicat 那样进行桥接连接)。
2.3 SpringBoot 整合 redis
jedis 使用 Java 来操作 Redis。(在 springboot2.x 之后已经被改成 lettuce)
jedis:采用的直连,多个线程操作话是不安全的,如果想要避免;
lettuce:采用netty(高性能网络结构,异步传值),实例可以在多个线程中进行共享,不存在线程不安全的情况,可以减少线程数据。
原子性(atomicity):
一个事务是一个不可分割的最小工作单位,事务中包括的诸操作要么都做,要么都不做;
Redis 所有单个命令的执行都是原子性的,这与它的单线程机制有关;
Redis 命令的原子性使得我们不用考虑并发问题,可以方便的利用原子性自增操作INCR实现简单计数器功能。
package com.sast.atsast;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisTemplate;
/**
* @program: atSast
* @summary: 对 jedis 进行测试
* @author: cxy621
* @create: 2021-07-23 13:45
**/
@SpringBootTest
public class JedisTest {
@Autowired
private RedisTemplate redisTemplate;
//opsforValue String
//opsforList List
//opsforSet Set
//opsforHash Hash
//opsforZSet Zset
@Test
public void lettuceTest() {
// RedisConnection redisConnection = redisTemplate.getConnectionFactory().getConnection();
// redisConnection.flushAll();
// redisConnection.flushDb();
redisTemplate.opsForValue().set("name", "cxy");
System.out.println(redisTemplate.opsForValue().get("name"));
}
}
所有的对象需要序列化如果没有序列化就会报错。对于没有序列化的值,Java 中会有自带的 jdk 自带的序列化,但是如果不自己配置redis,会自动出现转义字符。可以自己自己去设置 redis 工具类,重写 redisUtil,就不需要调用原生麻烦的包了。
配置文件中的配置:
spring:
redis:
# Redis 数据库索引(默认为0)
database: 0
host: localhost
port: 6379
timeout: 180000
jedis:
pool:
# 连接池最大连接数(使用负值表示没有限制)
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8
# 连接池中的最小空闲连接
min-idle: 0
通过源码可以看出,SpringBoot 自动帮我们在容器中生成了一个 RedisTemplate 和一个 StringRedisTemplate。但是,这个RedisTemplate 的泛型是 <Object,Object>,写代码不方便,需要写好多类型转换的代码;我们需要一个泛型为 <String,Object> 形式的 RedisTemplate。并且,这个RedisTemplate 没有设置数据存在 Redis 时,key 及 value 的序列化方式。
编写 redis 配置类,内容如下,在该类中完成 Jedis 池、Redis 连接和 RedisTemplate 序列化三个配置完成 SpringBoot 整合 redis 的进一步配置。其中 RedisTemplate 对 key 和 value 的序列化类,各人结合自己项目情况进行选择即可。
配置 redis config 文件,防止出现转义字符的问题:
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
// 解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置序列化(解决乱码的问题),过期时间600秒
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig().entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(redisSerializer)).serializeValuesWith(RedisSerializationContext
.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
RedisCacheManager cacheManager = RedisCacheManager.builder(factory).cacheDefaults(config).build();
return cacheManager;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setConnectionFactory(factory);
// key序列化方式
template.setKeySerializer(redisSerializer);
// value序列化
template.setValueSerializer(jackson2JsonRedisSerializer);
// value hashmap序列化
template.setHashValueSerializer(jackson2JsonRedisSerializer);
return template;
}
}
除此之外,直接这样使用 RedisTemplate 还是会比较麻烦,我们可以自己写一个工具类:
import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisUtil {
private final StringRedisTemplate redisTemplate;
public RedisUtil(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void set(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
public void set(String key, String value, long time) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);//TimeUnit.SECONDS 设置时间单位为秒
}
public void set(String key, String value, long time, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, time, unit);
}
// 设置过期时间
public void expire(String key, long time) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
public void expire(String key, long time, TimeUnit unit) {
redisTemplate.expire(key, time, unit);
}
// 获取过期截止时间
public long ddl(String key) {
if (hasKey(key)) {
return redisTemplate.getExpire(key);
}
throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
}
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
public boolean hasKey(String key) {
String token = get(key);
return token != null;
}
/**
* 删除缓存
*
* @param key 可以传一个值或多个
* 使用 ... 表示不确定
*/
@SuppressWarnings("unchecked")
// 忽略编译器中的警告错误,比如 unused
public void del(String... key) {
if (key != null && key.length > 0) {
for (String s : key) {
redisTemplate.delete(s);
}
}
}
}
2.4 整合 JWT 和 Redis
yaml 自定义配置 jwt 参数:
jwt:
# jwt需要的密钥
secret: 239FJAS993JASLVKCLS02JGFS
# 跟前端固定请求头
header: Token
# jwt设置过期时间
expiration: 864000
配置 jwt 工具类:
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.Map;
// jwt 实现工具类
@Component
@Data
public class JwtUtil {
private final AccountMapper accountMapper;
private final RedisUtil redisUtil;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
@Value("${jwt.prefix}")
private String prefix;
public static final String USER_LOGIN_TOKEN = "TOKEN";
public JwtUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
this.redisUtil = redisUtil;
this.accountMapper = accountMapper;
}
// 给每个账户生成一个 token
public String generateToken(Account account) {
Date nowDate = new Date();
Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
JWTCreator.Builder builder = JWT.create();
builder.withClaim("id", account.getId());
builder.withClaim("username", account.getUsername());
builder.withClaim("role", account.getRole());
builder.withExpiresAt(expiredDate);
return prefix + builder.sign(Algorithm.HMAC256(secret));
}
// 获取 token 具体信息
public Map<String, Claim> getClaims(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
DecodedJWT decodedJWT = verifier.verify(token);
return decodedJWT.getClaims();
} catch (IllegalArgumentException | JWTVerificationException e) {
throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
}
}
public Account getAccount(String token) {
Map<String, Claim> map = getClaims(token);
return new Account(
map.get("id").asLong(),
map.get("username").asString(),
map.get("role").asInt());
}
// 判断 token 是否过期
/*public boolean isTokenExpired(String token) {
Date expiresAt = getClaims(token).get("exp").asDate();
return expiresAt.getTime() - System.currentTimeMillis() < 0;
}*/
// 存储在 redis 中的 token 是否过期
public boolean isTokenExpired(String token) {
Account account = getAccount(token);
long expiration = redisUtil.ddl(account.getUsername());
return expiration <= 0;
}
// 判断 token 是否需要刷新
private boolean isTokenNeedRefresh(String key) {
long expiration = redisUtil.ddl(key);
return expiration < (this.expiration * 1000 >> 1);
}
// 刷新 token 过期时间
/*public String refreshToken(String token) {
Date nowDate = new Date();
Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
Account account = getAccount(token);
JWTCreator.Builder builder = JWT.create();
builder.withClaim("id", account.getId());
builder.withClaim("username", account.getUsername());
builder.withClaim("role", account.getRole());
builder.withExpiresAt(expiredDate);
return prefix + builder.sign(Algorithm.HMAC256(secret));
}*/
// 刷新 token 存在时间
public void refreshExpiration(String key) {
if (isTokenNeedRefresh(key)) {
redisUtil.expire(key, expiration);
}
}
}
上面其实是一定程度有悖 JWT 的设计初衷,因为 JWT 的本意是将 token 存放在客户端,但其实我们将 token 存放在服务端。但是这也是为了安全性的考虑,因为我们需要验证是否是用户本人。
并且,由于 token 不像 cookie,不可以进行手动过期,对于过期 token 必须重新生成一个新的 token。在 FC 中,将 token 存入 redis,采取 redis 的过期时间来对 token 进行反复刷新操作。
在和一些已经工作的学长的交流中,对于登录的验证来说,不建议使用 token。在权限管理中,可能更加具有普适性。并且 token 和 session 并非是完全的替代关系,对于一些不规范的程序开发来说,
为了维护 JWT 的安全性,需要有一系列的考虑,之后可能会说。
2.5 Redis 数据结构
2.5.1 String
String 的实现类似于 Java 中的 ArrayList,作为变长字符串。
一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。
常用命令:set, get, strlen, exists, decr, incr, setex, mset, mget
。
2.5.2 List
实现类似 Java 中的 LinkedList,双向链表,对两端操作效率较高,但不适合使用索引查找。
List 的数据结构为快速链表 quickList。在列表元素较少的情况下会使用一块连续的内存存储 ziplist,分配连续的内存。当ziplist节点个数过多,quicklist 退化为双向链表,一个极端的情况就是每个 ziplist 节点只包含一个 entry,即只有一个元素。当 ziplist 元素个数过少时,quicklist 可退化为 ziplist,一种极端的情况就是 quicklist 中只有一个 ziplist 节点。
常用命令:rpush, lpop, lpush, rpop, lrange, llen
2.5.3 Hash
hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,可以只修改值中某个字段。
常用命令:hset, hmset, hexists, hget, hgetall, hkeys, hvals
。
hmset 1 name "cxy621" age 18 birthday 8-28
hgetall 1
# 1) "name"
# 2) "cxy621"
# 3) "age"
# 4) "18"
# 5) "birthday"
# 6) "8-28"
Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。
2.5.4 Set
set 类似于 Java 中的 HashSet
。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序,数据不重复。
常用命令:sadd, spop, smembers, sismember, scard, sinterstore, sunion
。
127.0.0.1:6379> sadd mySet value1 value2 # 添加元素进去
(integer) 2
127.0.0.1:6379> sadd mySet value1 # 不允许有重复元素
(integer) 0
127.0.0.1:6379> smembers mySet # 查看 set 中所有的元素
1) "value1"
2) "value2"
127.0.0.1:6379> scard mySet # 查看 set 的长度
(integer) 2
127.0.0.1:6379> sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素
(integer) 1
127.0.0.1:6379> sadd mySet2 value2 value3
(integer) 2
127.0.0.1:6379> sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
(integer) 1
127.0.0.1:6379> smembers mySet3
1) "value2"
2.5.5 ZSet
sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。
常用命令:zadd, zcard, zscore, zrange, zrevrange, zrem
。
Redis 采用的是跳跃表,跳跃表效率堪比红黑树,实现远比红黑树简单。
2.5.6 新的类型
geospatial:Redis 3.2 推出 Geo 类型,该功能可以推算出地理位置信息,两地之间的距离;
hyperloglog:基数:数学上集合的元素个数,是不能重复的。这个数据结构常用于统计网站的 UV;
bitmap:bitmap 就是通过最小的单位 bit 来进行0或者1的设置,表示某个元素对应的值或者状态。一个 bit 的值,或者是0,或者是1;也就是说一个 bit 能存储的最多信息是2。bitmap 常用于统计用户信息比如活跃粉丝和不活跃粉丝、登录和未登录、是否打卡等。
3. AOP
3.1 定义
面向对象编程(OOP)的好处是显而易见的,缺点也同样明显。当需要为多个不具有继承关系的对象添加一个公共的方法的时候,例如日志记录、性能监控等,如果采用面向对象编程的方法,需要在每个对象里面都添加相同的方法,这样就产生了较大的重复工作量和大量的重复代码,不利于维护。面向切面编程(AOP)是面向对象编程的补充,简单来说就是统一处理某一“切面”的问题的编程思想。
与面向对象的顺序流程不同,AOP采用的是横向切面的方式,注入与主业务流程无关的功能,例如事务管理和日志管理。如果使用AOP的方式进行日志的记录和处理,所有的日志代码都集中于一处,不需要再每个方法里面都去添加,极大减少了重复代码。
3.2 AOP 专业术语
通知(Advice)包含了需要用于多个应用对象的横切行为,完全听不懂,没关系,通俗一点说就是定义了“什么时候”和“做什么”;
连接点(Join Point)是程序执行过程中能够应用通知的所有点;
切点(Poincut)是定义了在“什么地方”进行切入,哪些连接点会得到通知。显然,切点一定是连接点;
切面(Aspect)是通知和切点的结合。通知和切点共同定义了切面的全部内容——是什么,何时,何地完成功能;
引入(Introduction)允许我们向现有的类中添加新方法或者属性;
织入(Weaving)是把切面应用到目标对象并创建新的代理对象的过程,分为编译期织入、类加载期织入和运行期织入。
3.3 Spring-Boot 中使用 AOP
3.3.1 定义切面和切点
Spring 采用@Aspect
注解对 POJO 进行标注,该注解表明该类不仅仅是一个 POJO,还是一个切面。切面是切点和通知的结合,那么定义一个切面就需要编写切点和通知。在代码中,只需要添加 @Aspect 注解即可。
切点是通过@Pointcut注解和切点表达式定义的。
@Pointcut
注解可以在一个切面内定义可重用的切点。
由于Spring切面粒度最小是达到方法级别,而execution表达式可以用于明确指定方法返回类型,类名,方法名和参数名等与方法相关的部件,并且实际中,大部分需要使用AOP的业务场景也只需要达到方法级别即可,因而execution表达式的使用是最为广泛的。如图是execution表达式的语法:
execution
表示在方法执行的时候触发。以*
开头,表明方法返回值类型为任意类型。然后是全限定的类名和方法名,*
可以表示任意类和任意方法。对于方法参数列表,可以使用“..”表示参数为任意类型。如果需要多个表达式,可以使用“&&”、“||”和“!”完成与、或、非的操作。
execution( 方法修饰符 返回类型 方法所属的包.类名.方法名称(方法参数) )
"*"表示不限 ".."表示参数不限
方法修饰符不写表示不限,不用"*"
3.3.2 使用通知
通知有五种类型,分别是:
前置通知(@Before):在目标方法调用之前调用通知;
后置通知(@After):在目标方法完成之后调用通知;
环绕通知(@Around):在被通知的方法调用之前和调用之后执行自定义的方法;
返回通知(@AfterReturning):在目标方法成功执行之后调用通知;
异常通知(@AfterThrowing):在目标方法抛出异常之后调用通知。
3.3.3 简单例子
首先是简单写几个控制器,方便我们之后随机进行测试。
写了一个带传递的参数是为了方面之后的切面中的演示,下面对日志功能进行简单演示:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/hello")
public String sayHello() {
System.out.println("Hello, World");
return "Hello";
}
@GetMapping("/hello/{name}")
public String sayLove(@PathVariable("name") String name) {
System.out.println("Activate Successfully");
return "i love you " + name;
}
}
首先定义切入点,切入点的位置定好之后之后便可使用注释将对应的通知插入。
这里使用 @Slf4j 的注释,使用日志(这里使用夏佬的例子进行简单的说明)。
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@Aspect
public class AopAdvice {
//表示实体类中所有的方法
@Pointcut("execution(* com.example.demo.controller.*.*(..))")
public void MyPointCut() {
}
@Before("MyPointCut()")
public void BeforeAdvice() {
log.info("this is before");
}
@After("MyPointCut()")
public void AfterAdvice() {
log.info("this is after");
}
@Around("MyPointCut()")
public Object AroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
String methodName = proceedingJoinPoint.getSignature().getName();
String className = proceedingJoinPoint.getTarget().getClass().toString();
ObjectMapper objectMapper = new ObjectMapper();
Object[] array = proceedingJoinPoint.getArgs();//获取其中的参数
log.info("调用前:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
Object object = proceedingJoinPoint.proceed();//对应事件开始处理,这里需要抛出异常
log.info("调用后:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
return object;
}
}
3.4 AOP 结合 JWT
创建自定义注释:
import com.sast.jwt.enums.AuthEnum;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
//创建一个自定义注释,里面的内容规定为需要的权限
public @interface AuthHandler {
AuthEnum value();
}
创建一个 AOP 的切面,绑定到注释:
import com.sast.jwt.annotation.AuthHandler;
import com.sast.jwt.entity.Account;
import com.sast.jwt.enums.AuthEnum;
import com.sast.jwt.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.interceptor.LoginInterceptor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Component
@Aspect
public class AuthAspect {
//将切点设置为注释,只要触发注释便会触发对应的方法
@Pointcut("@annotation(com.sast.jwt.annotation.AuthHandler)")
public void start() {
}
//绑定切入点和切入点形参
@Before("start()&&@annotation(authHandler)")
public Object authJudge(JoinPoint joinPoint, AuthHandler authHandler) {
AuthEnum authEnum = authHandler.value();
Account account = LoginInterceptor.accountThreadLocal.get();
if (!AuthEnum.checkAuth(account, authEnum)) {
throw new LocalRuntimeException(CustomError.AUTHENTICATION_ERROR);
}
return joinPoint;
}
}
配置拦截器,对发送的 Token 进行判断:
import com.sast.jwt.dao.AccountDao;
import com.sast.jwt.entity.Account;
import com.sast.jwt.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
//在多个线程中传递 token
public static ThreadLocal<Account> accountThreadLocal = new ThreadLocal<>();
private final AccountDao accountDao;
private final JwtUtil jwtUtil;
public LoginInterceptor(JwtUtil jwtUtil, AccountDao accountDao) {
this.jwtUtil = jwtUtil;
this.accountDao = accountDao;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler)
throws Exception {
//规定发送的头中带有 Token 字段
String header = request.getHeader("Token");
//规定 Token 中的开头为 Bearer_,以便以后的功能扩展
if (!StringUtils.hasLength(header) || !header.startsWith("Bearer_")) {
throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
}
String token = header.substring(7);
log.info(token);
Account account = jwtUtil.getAccount(token);
log.info(String.valueOf(account));
if (account != null) {
if (jwtUtil.isTokenExpired(token)) {
throw new LocalRuntimeException(CustomError.TOKEN_OUT_TIME);
}
if (accountDao.selectById(account.getId()) != null) {
jwtUtil.refreshExpiration(account.getUsername());
accountThreadLocal.set(account);
return true;
}
}
throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex)
throws Exception {
accountThreadLocal.remove();
}
}
配置拦截器拦截的 url:
import com.sast.jwt.converter.CustomJsonHttpMessageConverter;
import com.sast.jwt.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import java.util.List;
@Configuration
@Component
public class WebMvcConfig extends WebMvcConfigurationSupport {
private final CustomJsonHttpMessageConverter converter;
private final LoginInterceptor loginInterceptor;
public WebMvcConfig(CustomJsonHttpMessageConverter converter,
LoginInterceptor loginInterceptor) {
this.converter = converter;
this.loginInterceptor = loginInterceptor;
}
@Override
protected void addInterceptors(InterceptorRegistry registry) {
//这里需要使用自动装配的拦截器,需要公用 ThreadLocal
//所以这里不能每次都创建新的拦截器,而是需要使用 IOC 帮我们feng'z
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/user/login", "/user/register");
}
}
3.5 ThreadLocal 使用
对于 Java 中的多线程中,我们需要传递同一个参数,这个时候就可以使用 ThreadLocal,对每个一线程共享这个对象。
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
//一般写法
实际上,可以把ThreadLocal
看成一个全局Map<Thread, Object>
:每个线程获取ThreadLocal
变量时,总是使用Thread
自身作为key:
Object threadLocalValue = threadLocalMap.get(Thread.currentThread());
不过要在最后注意使用threadLocalUser.remove();
将线程清除。
4. 验证码
需要添加对应依赖包:
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
设计生成图片验证码的接口,将生成的验证码存放在 redis 中。
通过 UUID 给二维码生成独一无二的 uid。
UUID由以下几部分的组合:
当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同;
时钟序列;
全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。
配置图片验证码:
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha getDefaultKaptcha() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
// 图片边框
properties.setProperty("kaptcha.border", "no");
// 边框颜色
properties.setProperty("kaptcha.border.color", "black");
//边框厚度
properties.setProperty("kaptcha.border.thickness", "1");
// 图片宽
properties.setProperty("kaptcha.image.width", "200");
// 图片高
properties.setProperty("kaptcha.image.height", "50");
//图片实现类
properties.setProperty("kaptcha.producer.impl", "com.google.code.kaptcha.impl.DefaultKaptcha");
//文本实现类
properties.setProperty("kaptcha.textproducer.impl", "com.google.code.kaptcha.text.impl.DefaultTextCreator");
//文本集合,验证码值从此集合中获取
properties.setProperty("kaptcha.textproducer.char.string", "01234567890");
//验证码长度
properties.setProperty("kaptcha.textproducer.char.length", "4");
//字体
properties.setProperty("kaptcha.textproducer.font.names", "宋体");
//字体颜色
properties.setProperty("kaptcha.textproducer.font.color", "black");
//文字间隔
properties.setProperty("kaptcha.textproducer.char.space", "5");
//干扰实现类
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise");
//干扰颜色
properties.setProperty("kaptcha.noise.color", "blue");
//干扰图片样式
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
//背景实现类
properties.setProperty("kaptcha.background.impl", "com.google.code.kaptcha.impl.DefaultBackground");
//背景颜色渐变,结束颜色
properties.setProperty("kaptcha.background.clear.to", "white");
//文字渲染器
properties.setProperty("kaptcha.word.impl", "com.google.code.kaptcha.text.impl.DefaultWordRenderer");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
5. 完整登录逻辑
创建工具类,获取存储在 redis 中的 token:
import com.sast.jwt.entity.Account;
public class RedisKeyFetch {
public static String getTokenKey(Account account){
return "TOKEN:" + account.getUsername();
}
}
使用 LoginController:
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.sast.jwt.common.contents.RedisKeyFetch;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import com.sast.jwt.utils.JWTUtil;
import com.sast.jwt.utils.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
public class LoginController {
public static final String LOGIN_VALIDATE_CODE = "VAL_CODE:";
private final DefaultKaptcha kaptchaProducer;
private final RedisUtil redisUtil;
private final AccountMapper accountMapper;
private final JWTUtil jwtUtil;
public LoginController(DefaultKaptcha kaptchaProducer, RedisUtil redisUtil, AccountMapper accountMapper, JWTUtil jwtUtil) {
this.kaptchaProducer = kaptchaProducer;
this.redisUtil = redisUtil;
this.accountMapper = accountMapper;
this.jwtUtil = jwtUtil;
}
/**
* 返回验证码图片,并将验证码存入Redis
*
* @param response 设置响应头参数信息
*/
@GetMapping("/getValidateCode")
public void getImgValidateCode(HttpServletResponse response) {
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
response.addHeader("Cache-Control", "post-check=0, pre-check=0");
response.setHeader("Pragma", "no-cache");
//为每个请求生成一个唯一的验证码
response.addHeader("CAPTCHA", uuid);
response.setContentType("image/jpeg");
String capText = kaptchaProducer.createText();
BufferedImage bi = kaptchaProducer.createImage(capText);
try {
ServletOutputStream out = response.getOutputStream();
ImageIO.write(bi, "jpg", out);
out.flush();
} catch (Exception e) {
e.printStackTrace();
}
redisUtil.set(LOGIN_VALIDATE_CODE + uuid, capText, 60 * 5);
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody Account account,
@RequestHeader("User-Agent") String agent,
@RequestParam("validateCode") String validateCode,
@RequestHeader("CAPTCHA") String uuid) {
//验证验证码
String currentCode = redisUtil.get(LOGIN_VALIDATE_CODE + uuid);
if (currentCode == null) {
throw new LocalRuntimeException("验证码失效");
} else if (!currentCode.equals(validateCode)) {
throw new LocalRuntimeException("验证码错误");
}
redisUtil.del(LOGIN_VALIDATE_CODE + uuid);
//登录处理
QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", account.getUsername());
Account accountFromDB = accountMapper.selectOne(queryWrapper);
if (accountFromDB == null) {
throw new LocalRuntimeException("账号不存在");
} else if (!SecureUtil.md5(account.getPassword()).equals(accountFromDB.getPassword())) {
throw new LocalRuntimeException("密码错误");
}
String token = jwtUtil.generateToken(accountFromDB);
Map<String, String> map = new HashMap<>();
map.put("role", accountFromDB.getRole().toString());
map.put("token", token);
log.info("===============================================");
log.info("用户登录:{},role:{}", accountFromDB.getUsername(), accountFromDB.getRole());
log.info("登录Agent:{}", agent);
log.info("===============================================");
//用 Redis 中的过期时间代替 JWT 的过期时间,每次经过拦截器时更新过期时间
//先设置为30天
redisUtil.set(RedisKeyFetch.getTokenKey(account), token, 30, TimeUnit.DAYS);
return map;
}
}
6. open api
6.1 swagger
配置 swagger 配置文件:
import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.oas.annotations.EnableOpenApi;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
@Configuration
@EnableOpenApi//开启 Swagger 功能
public class SwaggerConfig {
@Bean
public Docket docket() {
return new Docket(DocumentationType.OAS_30)
.apiInfo(apiInfo()).enable(true)
.select()
//apis: 添加swagger接口提取范围
.apis(RequestHandlerSelectors.basePackage("com.sast.jwt.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("toy_Backend 项目接口文档")
.description("toy_Backend 项目描述")
.contact(new Contact("cxy621", "https://hexo.cxy621.top", "1580779474@qq.com"))
.version("1.0")
.build();
}
}
但在 SpringBoot 更新到新版本之后,springfox 已经不再更新了,所以之后 swagger 的使用频率一定会减少,甚至停用。
在 SpringBoot 2.6.X 版本及以上使用 swagger 时,会出现Failed to start bean 'documentationPluginsBootstrapper';
的错误。
因为 Springfox 使用的路径匹配是基于 AntPathMatche r的,而 SpringBoot 2.6.X 使用的是 PathPatternMatcher。
我们需要修改配置文件:
spring.mvc.pathmatch.matching-strategy: ANT_PATH_MATCHER
注意:对于 swagger 来说,返回的页面会被我们的全局异常给捕获,所以无法正常显示。
好消息是,我们有一个新的 open api 生成工具。
6.2 springdoc
依赖导入:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.7</version>
</dependency>
springdoc 相比于单纯的 swagger,不需要配置 config 文件就能够直接使用。
默认 json 数据访问链接🔗为:/v3/api-docs/
;
默认 swagger 页面访问链接🔗为:swagger-ui.html
。
可以在对应的 yaml 文件中配置制定自定义路径:
springdoc:
swagger-ui:
path: /swagger-ui-custom.html
api-docs:
path: /api-docs
之后打开对应链接,就可以打开 open-api 界面。
之后,需要在对应 controller 中加入 swagger 注释,让生成 api 具有一定的可读性。
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
//使用 @Tag 对 controller 进行确认
@Tag(name = "测试接口", description = "第一次使用 swagger 形式进行书写")
public class TestController {
@GetMapping("/hello")
@Operation(summary = "输出 hello world")
public String index() {
return "Hello World!";
}
@PostMapping("")
public String test() {
return "Hello!";
}
@GetMapping("/call/{name}")
@Operation(summary = "对特定的用户问好", description = "需要传入对应的用户名",
//对需要的参数进行声明,会通过注释来判断参数的类型
parameters = {
@Parameter(name = "name", description = "用户名", required = true)
})
public String call(@PathVariable String name) {
return "Hello " + name + "!";
}
}
7. poi
依赖包导入:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.15</version>
</dependency>
FreshCup 过程中的示例:
public Workbook importAdmin(Long contestId, MultipartFile file)
throws IOException {
if (contestMapper.selectOne(new LambdaQueryWrapper<Contest>()
.eq(Contest::getId, contestId)) == null) {
throw new LocalRunTimeException(ErrorEnum.NO_CONTEST);
}
AtomicInteger success = new AtomicInteger(0);
AtomicInteger failure = new AtomicInteger(0);
// 创建一个新的工作簿
Workbook output = new XSSFWorkbook();
// 创建一个“结果” sheet
Sheet sheetOutput = output.createSheet("结果");
Row rowOutput = sheetOutput.createRow(0);
rowOutput.createCell(0).setCellValue("账号");
rowOutput.createCell(1).setCellValue("密码");
//通过文件流,读取文件的文件流
Workbook workbook = new XSSFWorkbook(file.getInputStream());
Sheet sheet = workbook.getSheetAt(0);
int rowNum = sheet.getLastRowNum();
for (int i = 1; i <= rowNum; i++) {
Row row = sheet.getRow(i);
Cell cell = row.getCell(0);
String username = cell.getStringCellValue();
Account account = accountMapper.selectOne(new LambdaQueryWrapper<Account>()
.eq(Account::getUsername, username));
if (account != null) {
if (!account.getRole().equals(1)) {
throw new LocalRunTimeException(ErrorEnum.ROLE_ERROR);
}
log.info(username + " 管理员已导入,无需再次导入");
failure.getAndIncrement();
} else {
log.info(username + " 管理员导入成功");
success.getAndIncrement();
rowOutput = sheetOutput.createRow(success.get());
rowOutput.createCell(0).setCellValue(username);
String password = createAdmin(username);
rowOutput.createCell(1).setCellValue(password);
}
addContestUser(contestId, username, accountMapper, accountContestManagerMapper);
}
//判断是否有创建成功的学生账号
if (sheetOutput.getLastRowNum() == 0) {
sheetOutput.createRow(1);
}
sheetOutput.getRow(0).createCell(3).setCellValue("成功导入" + success.get() + "个");
sheetOutput.getRow(1).createCell(3).setCellValue("失败" + failure.get() + "个");
return output;
}