形成接口文档
springboot 2.X时代,swagger是事实上的接口文档,其实现在也是,不过版本升级了下,改了名字,变成OpenAPI了。 国内流行的knife4j对swaggerUI做了进一步封装,更符合我们对接口文档的期望,我们也用knife4j。 引入依赖:
xml
<properties>
......
<knife4j.version>4.4.0</knife4j.version>
</properties>
<dependencies>
......
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
</dependencies>
配置文件中加上相关配置:
yaml
# springdoc-openapi项目配置
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
enable: true
group-configs:
- group: 'XX管理端'
paths-to-match: '/**'
packages-to-scan: com.sptan
# knife4j的增强配置,不需要增强可以不配
knife4j:
enable: true
setting:
language: zh_cn
注意这个配置项尽量不要配置在通用的配置文件中,选择放在local、dev、test等profile对应的配置文件中,UAT和生产环境中一般不建议引入接口文档,安全问题时刻都需要注意。 当然可以有更多配置项,参考官方文档来搞就行,这个比较简单,不再演示更复杂的配置。 改下主类,把接口文档的地址打在log中。
java
package com.sptan.ssmp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import java.net.InetAddress;
import java.net.UnknownHostException;
@SpringBootApplication
@Slf4j
public class SsmpApplication {
public static void main(String[] args) {
log.info("开始启动...");
ConfigurableApplicationContext applicationContext = SpringApplication.run(SsmpApplication.class, args);
Environment env = applicationContext.getEnvironment();
logApplicationStartup(env);
}
private static void logApplicationStartup(Environment env) {
String protocol = "http";
if (env.getProperty("server.ssl.key-store") != null) {
protocol = "https";
}
String serverPort = env.getProperty("server.port");
String contextPath = env.getProperty("server.servlet.context-path");
if (!StringUtils.hasText(contextPath)) {
contextPath = "/doc.html";
} else {
contextPath = contextPath + "/doc.html";
}
String hostAddress = "localhost";
try {
hostAddress = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.warn("The host name could not be determined, using `localhost` as fallback");
}
log.info("""
----------------------------------------------------------
\t应用程序"{}"正在运行中......
\t接口文档访问 URL:
\t本地: \t{}://localhost:{}{}
\t外部: \t{}://{}:{}{}
\t配置文件: \t{}
----------------------------------------------------------
""",
env.getProperty("spring.application.name"),
protocol,
serverPort,
contextPath,
protocol,
hostAddress,
serverPort,
contextPath,
env.getActiveProfiles());
}
}
跑一下看看。 打开链接: 怎么回事???脑瓜子疼。。。 谷歌一下ERR_UNSAFE_PORT是个什么玩意。。。 Chrome浏览器的原因。。。 我们换个端口试试,发现6667不行,6789可以。。。 莫名其妙。。 进一步了解一下,Chrome做了一些限制,具体参考: superuser.com/questions/1... 可知,以下端口都是不行的,奇怪的知识又增加了,这是诚心不让我们6起来啊。
java
1, // tcpmux
7, // echo
9, // discard
11, // systat
13, // daytime
15, // netstat
17, // qotd
19, // chargen
20, // ftp data
21, // ftp access
22, // ssh
23, // telnet
25, // smtp
37, // time
42, // name
43, // nicname
53, // domain
69, // tftp
77, // priv-rjs
79, // finger
87, // ttylink
95, // supdup
101, // hostriame
102, // iso-tsap
103, // gppitnp
104, // acr-nema
109, // pop2
110, // pop3
111, // sunrpc
113, // auth
115, // sftp
117, // uucp-path
119, // nntp
123, // NTP
135, // loc-srv /epmap
137, // netbios
139, // netbios
143, // imap2
161, // snmp
179, // BGP
389, // ldap
427, // SLP (Also used by Apple Filing Protocol)
465, // smtp+ssl
512, // print / exec
513, // login
514, // shell
515, // printer
526, // tempo
530, // courier
531, // chat
532, // netnews
540, // uucp
548, // AFP (Apple Filing Protocol)
554, // rtsp
556, // remotefs
563, // nntp+ssl
587, // smtp (rfc6409)
601, // syslog-conn (rfc3195)
636, // ldap+ssl
993, // ldap+ssl
995, // pop3+ssl
1719, // h323gatestat
1720, // h323hostcall
1723, // pptp
2049, // nfs
3659, // apple-sasl / PasswordServer
4045, // lockd
5060, // sip
5061, // sips
6000, // X11
6566, // sane-port
6665, // Alternate IRC [Apple addition]
6666, // Alternate IRC [Apple addition]
6667, // Standard IRC [Apple addition]
6668, // Alternate IRC [Apple addition]
6669, // Alternate IRC [Apple addition]
6697, // IRC + TLS
10080, // Amanda
参数说明都是空的,肯定不是我们想要的。 补齐一下,代码例子如下: 效果如下:
添加一个普通的接口作为例子
Controller层代码:
java
package com.sptan.ssmp.controller;
import com.sptan.ssmp.dto.AdminUserDTO;
import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.core.ResultEntity;
import com.sptan.ssmp.service.AdminUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* The type Admin user controller.
*/
@RestController
@RequestMapping("/api/v1/admin-user")
@RequiredArgsConstructor
@Tag(name = "admin-user", description = "管理员用户控制器")
public class AdminUserController {
private final AdminUserService adminUserService;
/**
* Register response entity.
*
* @param id the id
* @return the response entity
*/
@PostMapping("/detail/{id}")
@Operation(summary = "详情", description = "根据ID查看详情")
@Parameters({
@Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "标识用户信息的请求头", required = true),
@Parameter(in = ParameterIn.PATH, name = "id", description = "ID", required = true)
})
public ResponseEntity<ResultEntity<AdminUserDTO>> register(@PathVariable(name = "id") Long id) {
ResultEntity<AdminUserDTO> detail = adminUserService.detail(id);
return ResponseEntity.ok(detail);
}
}
跑一下
竟然又出错了。。。 控制台有以下错误:
java
java.lang.ClassNotFoundException: Cannot find implementation for com.sptan.ssmp.converter.AdminUserConverter
这是MapStruct的接口类没有生成实现类导致的,需要在maven构建时设置。
xml
<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>
<exclude>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>
-Amapstruct.unmappedTargetPolicy=IGNORE
</compilerArg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
再次构建,发现AdminUserConverter的实现类生成了。 运行接口,这次就可以成功调用了。
到目前为止,一个最基础的脚手架基本可用了。上面的步骤是我一步步做下来的,做到哪文档就截取到哪里,如果你跟谁上面步骤,应该会得到与我同样的结果。
当然现在还有很多不完善的地方。
- 想到哪写到哪,目前的结构比较混乱;
- 缺少缓存机制;
- MyBatisPlus的审计处理器中留了个坑还没有填上
到这里,代码已经很乱了,我会把代码结构简单整理一下,但是整理的过程不再赘述,如果你看到我上传的代码跟上述文档不同,是我重新整理过的原因。
整理项目结构
理想的项目结构是分层的,依赖是单向的,二不是出现循环依赖的情况。上述想到哪里做到哪里的方式太随性,不太符合工程化的思想。整理了一下,搞成一个framework包和一个业务包,业务包引用framework包,而没有相反的依赖关系。因为之前我搞得乱七八糟,所以整理的过程中出现了些循环依赖。 整理后的结构如下: 整理的过程中,UserDetailsService的实现方式导致了循环依赖,改变了实现方式,framework中应用Spring的UserDetailsService,作为实现类的CustomUserDetailsService由于引用到业务包的Mapper,放在了业务包中。 MetaObjectHandler的实现也修改了,更新时的userId从Request中取,要考虑到以后job也会插入数据,这时是获取不到HttpRequest对象的。
java
@Component
public class CustomMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
Long userId = getUserIdFromContext();
this.setFieldValByName("delete_flag", false, metaObject);
this.setFieldValByName("cuid", userId, metaObject);
this.setFieldValByName("opuid", userId, metaObject);
LocalDateTime now = LocalDateTime.now();
this.setFieldValByName("ctime", now, metaObject);
this.setFieldValByName("utime", now, metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
Long userId = getUserIdFromContext();
this.setFieldValByName("opuid", userId, metaObject);
this.setFieldValByName("utime", LocalDateTime.now(), metaObject);
}
private Long getUserIdFromContext() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
if (request == null) {
return 0L;
}
Long userId = (Long)request.getAttribute(SsmpConstants.REQUEST_ATTRIBUTE_USER_ID);
if (userId != null) {
return userId;
} else {
return 0L;
}
}
}
这里用到的userId是在过滤器中存储下来的。
java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userName;
if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
userName = jwtProvider.extractUserName(jwt);
if (StringUtils.isNotEmpty(userName)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
if (jwtProvider.isTokenValid(jwt, userDetails)) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
context.setAuthentication(authToken);
SecurityContextHolder.setContext(context);
if (userDetails instanceof LoginUser) {
// 将用户ID存到Request中, 在数据审计时用到
LoginUser loginUser = (LoginUser)userDetails;
request.setAttribute(SsmpConstants.REQUEST_ATTRIBUTE_USER_ID, loginUser.getUser().getId());
}
}
}
filterChain.doFilter(request, response);
}
}
业务类中再增加一个save接口。
java
/**
* 保存.
*
* @param dto the dto
* @return the response entity
*/
@PostMapping("/save")
@Operation(summary = "保存", description = "保存对象,包括新增和修改")
@Parameters({
@Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "标识用户信息的请求头", required = true)
})
public ResponseEntity<ResultEntity<AdminUserDTO>> save(@Validated @RequestBody AdminUserDTO dto) {
ResultEntity<AdminUserDTO> resultEntity = adminUserService.save(dto);
return ResponseEntity.ok(resultEntity);
}
执行我们新的保存接口: 我们使用admin的token,可以看到新增的记录的cuid和opuid是admin的userId: 现在虽然结构还没有整理到很细致,但是结构混乱的肯算是填上了;数据审计时userId固定为0的坑也填上了。
增加缓存
引入Redis
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-tomcat</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.jsr310.version}</version>
</dependency>
增加redis配置,这玩意基本都差不多,有个小坑要注意一下,就是jackson默认不支持java8引入的LocalDateTime对象,需要特殊处理一下。
java
package com.sptan.framework.redis;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import org.springframework.beans.factory.annotation.Autowired;
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.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* redis配置.
*
* @author lp
*/
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
private final RedisConnectionFactory redisConnectionFactory;
/**
* Instantiates a new Redis config.
*
* @param redisConnectionFactory the redis connection factory
*/
@Autowired
public RedisConfig(RedisConnectionFactory redisConnectionFactory) {
this.redisConnectionFactory = redisConnectionFactory;
}
/**
* Redis template redis template.
*
* @param connectionFactory the connection factory
* @return the redis template
*/
@Bean
@SuppressWarnings(value = {"unchecked", "rawtypes"})
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
RedisSerializer<Object> serializer = redisSerializer();
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
/**
* String redis template string redis template.
*
* @param redisConnectionFactory the redis connection factory
* @return the string redis template
*/
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate redisTemplate = new StringRedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
return redisTemplate;
}
/**
* Limit script default redis script.
*
* @return the default redis script
*/
@Bean
public DefaultRedisScript<Long> limitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(limitScriptText());
redisScript.setResultType(Long.class);
return redisScript;
}
/**
* Cache manager cache manager.
* @return the cache manager
*/
@Bean
public CacheManager cacheManager() {
// 初始化一个 RedisCacheWriter
// RedisCacheWriter 提供了对 Redis 的 set、setnx、get 等命令的访问权限
// 可以由多个缓存实现共享,并负责写/读来自 Redis 的二进制数据
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
// 设置 CacheManager 的值序列化方式
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
RedisSerializationContext.SerializationPair<Object> pair = RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer);
// 提供 Redis 的配置
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(pair);
// 默认配置(强烈建议配置上)。 比如动态创建出来的都会走此默认配置
RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig)
.build();
// 初始化 RedisCacheManager 返回
return redisCacheManager;
}
@Bean
public RedisSerializer<Object> redisSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//必须设置,否则无法将JSON转化为对象,会转化成Map类型
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 自定义ObjectMapper的时间处理模块
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
objectMapper.registerModule(javaTimeModule);
// 禁用将日期序列化为时间戳的行为
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
//创建JSON序列化器
return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
}
/**
* 限流脚本.
*/
private String limitScriptText() {
return "local key = KEYS[1]\n"
+ "local count = tonumber(ARGV[1])\n"
+ "local time = tonumber(ARGV[2])\n"
+ "local current = redis.call('get', key);\n"
+ "if current and tonumber(current) > count then\n"
+ " return tonumber(current);\n"
+ "end\n"
+ "current = redis.call('incr', key)\n"
+ "if tonumber(current) == 1 then\n"
+ " redis.call('expire', key, time)\n"
+ "end\n"
+ "return tonumber(current);";
}
}
再增加一个redis工具类,这玩意也是全网都差不多,随便抄一个就行。
java
package com.sptan.framework.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* spring redis 工具类.
*
* @author lp
*/
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
@RequiredArgsConstructor
public class RedisCache {
/**
* The Redis template.
*/
public final RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等.
*
* @param <T> the type parameter
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等.
*
* @param <T> the type parameter
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间.
*
* @param key Redis键
* @param timeout 超时时间
* @return true =设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout) {
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间.
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true =设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit) {
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获取有效时间.
*
* @param key Redis键
* @return 有效时间 expire
*/
public long getExpire(final String key) {
return redisTemplate.getExpire(key);
}
/**
* 判断 key是否存在.
*
* @param key 键
* @return true 存在 false不存在
*/
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
/**
* 获得缓存的基本对象.
*
* @param <T> the type parameter
* @param key 缓存键值
* @return 缓存键值对应的数据 cache object
*/
public <T> T getCacheObject(final String key) {
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象.
*
* @param key the key
* @return the boolean
*/
public boolean deleteObject(final String key) {
return redisTemplate.delete(key);
}
/**
* 删除集合对象.
*
* @param collection 多个对象
* @return boolean
*/
public boolean deleteObject(final Collection collection) {
return redisTemplate.delete(collection) > 0;
}
/**
* 缓存List数据.
*
* @param <T> the type parameter
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象 cache list
*/
public <T> long setCacheList(final String key, final List<T> dataList) {
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象.
*
* @param <T> the type parameter
* @param key 缓存的键值
* @return 缓存键值对应的数据 cache list
*/
public <T> List<T> getCacheList(final String key) {
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set.
*
* @param <T> the type parameter
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象 cache set
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) {
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext()) {
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set.
*
* @param <T> the type parameter
* @param key the key
* @return cache set
*/
public <T> Set<T> getCacheSet(final String key) {
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map.
*
* @param <T> the type parameter
* @param key the key
* @param dataMap the data map
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map.
*
* @param <T> the type parameter
* @param key the key
* @return cache map
*/
public <T> Map<String, T> getCacheMap(final String key) {
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据.
*
* @param <T> the type parameter
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value) {
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据.
*
* @param <T> the type parameter
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象 cache map value
*/
public <T> T getCacheMapValue(final String key, final String hKey) {
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 获取多个Hash中的数据.
*
* @param <T> the type parameter
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合 multi cache map value
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) {
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 删除Hash中的某条数据.
*
* @param key Redis键
* @param hKey Hash键
* @return 是否成功 boolean
*/
public boolean deleteCacheMapValue(final String key, final String hKey) {
return redisTemplate.opsForHash().delete(key, hKey) > 0;
}
/**
* 获得缓存的基本对象列表.
*
* @param pattern 字符串前缀
* @return 对象列表 collection
*/
public Collection<String> keys(final String pattern) {
return redisTemplate.keys(pattern);
}
}
改进login和过滤器的性能
我们的过滤器每次都解析出token,从db中取出用户进行校验,我们用一下缓存,看看能否避免不必要的DB访问。 修改login接口:
java
public ResultEntity<AuthResponse> login(AuthRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
var user = findByUserName(request.getUsername())
.orElseThrow(() -> new IllegalArgumentException("用户名或者密码不对."));
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
redisCache.setCacheObject(uuid, user, 1, TimeUnit.HOURS);
var jwt = jwtProvider.generateToken(uuid);
AuthResponse authResponse = AuthResponse.builder().token(jwt).build();
return ResultEntity.ok(authResponse);
}
过滤器的逻辑也改一下,优先从redis中取:
java
public class CustomUserDetailsService implements UserDetailsService {
private final AdminUserMapper adminUserMapper;
private final RedisCache redisCache;
@Override
public UserDetails loadUserByUsername(String username) {
AdminUser adminUser = (AdminUser) redisCache.getCacheObject(username);
if (adminUser == null) {
adminUser = adminUserMapper.selectByUserName(username);
if (adminUser == null) {
throw new UsernameNotFoundException("User not found");
} else {
return LoginUser.builder()
.user(adminUser)
.build();
}
} else {
return LoginUser.builder()
.user(adminUser)
.build();
}
}
}
LoginUser的getUsername也需要改一下:
java
@Override
public String getUsername() {
if (StringUtils.hasText(this.user.getUserIdentifier())) {
return this.user.getUserIdentifier();
} else {
return this.user.getUserName();
}
}
是新增的一个字段,跟DB中的字段没有对应关系。 这时登录有返回的token就是一个随机串,安全性高了不少。 用户信息存储在了redis中: 变得性能更好,更安全;同时,也更晦涩难懂,并且深度依赖redis了。 缓存值得说的地方太多了,最重要的是缓存与数据一致性保证,还有spring的声明式缓存也特别好用,但是坑也不少,这个话题太大,以后开个新话题再讲。这个项目把缓存引入进来后,缓存的问题先告一段落。
全局异常处理
通用的全局异常处理
通用的全局异常处理很简单,就是被Controller层调用的Service等层出现异常但又未捕获处理时,可以有机会做统一的异常处理。 我们写的全局统一异常处理的例子:
java
package com.sptan.framework.exception;
import com.sptan.framework.core.ResultEntity;
import jakarta.validation.UnexpectedTypeException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.sql.SQLException;
import java.util.List;
import java.util.stream.Collectors;
/**
* GlobalExceptionHandler.
*
* @author lp
*/
@ControllerAdvice
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
/**
* 定义参数异常处理器.
*
* @param e 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(BindException.class)
@ResponseBody
@ResponseStatus(HttpStatus.OK)
public ResponseEntity<ResultEntity<String>> validateErrorHandler(BindException e) {
BindingResult bindingResult = e.getBindingResult();
return new ResponseEntity<>(toErrorResultEntity(bindingResult), HttpStatus.BAD_REQUEST);
}
/**
* 定义参数异常处理器.
*
* @param e 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResponseEntity<ResultEntity<String>> validateErrorHandler(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
String message = "";
for (FieldError fieldError : bindingResult.getFieldErrors()) {
message = fieldError.getDefaultMessage();
}
log.error("[服务] - [捕获参数校验异常]", message);
return ResponseEntity.ok(ResultEntity.error(message));
}
/**
* 定义参数异常处理器.
*
* @param e 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(UnexpectedTypeException.class)
@ResponseBody
public ResponseEntity<ResultEntity<String>> validateErrorHandler(UnexpectedTypeException e) {
log.error("[服务] - [捕获参数校验异常]", e.getMessage());
return ResponseEntity.ok(ResultEntity.error(e.getMessage()));
}
/**
* 定义异常处理器.
*
* @param exception 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ResultEntity<String>> sassExceptionHandler(BadRequestException exception) {
log.warn("[服务] - [捕获业务异常]", exception);
return ResponseEntity.ok(ResultEntity.error(exception.getMessage()));
}
/**
* 定义异常处理器.
*
* @param exception 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler({AuthRequestException.class})
public ResponseEntity<ResultEntity<String>> authExceptionHandler(AuthRequestException exception) {
log.warn("[服务] - [捕获Auth异常]", exception);
return ResponseEntity.ok(ResultEntity.error(HttpStatus.UNAUTHORIZED.value(),exception.getMessage()));
}
/**
* 定义异常处理器.
*
* @param exception 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ResultEntity<String>> dataAccessException(DataAccessException exception) {
log.error("[服务] - [捕获SQL异常]", exception);
return ResponseEntity.ok(ResultEntity.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.getMessage()));
}
/**
* 定义异常处理器.
*
* @param exception 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(SQLException.class)
public ResponseEntity<ResultEntity<String>> sqlException(SQLException exception) {
log.error("[服务] - [捕获SQL异常]", exception);
return ResponseEntity.ok(ResultEntity.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.getMessage()));
}
/**
* 定义异常处理器.
*
* @param exception 当前平台异常参数对象.
* @return org.springframework.http.ResponseEntity response entity
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ResultEntity<String>> exception(Exception exception) {
log.error("[服务] - [未捕获异常]", exception);
return ResponseEntity.ok(ResultEntity.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.getMessage()));
}
private ResultEntity<String> toErrorResultEntity(BindingResult bindingResult) {
String errorMessage = "";
List<String> errorMsg;
if (bindingResult.hasErrors()) {
List<FieldError> errorList = bindingResult.getFieldErrors();
errorMsg = errorList.stream().map(err -> {
return "字段:" + err.getField() + "不合法,原因:" + err.getDefaultMessage();
}).collect(Collectors.toList());
errorMessage = errorMsg.get(0);
}
return ResultEntity.error(errorMessage);
}
}
看上去杂七杂八一堆东西,其实看一个就行。另外,你可能注意到即使出现了异常,仍旧返回的200的http状态码,不过里面是错误信息;当然,你可以返回401之类的错误码。没有好坏之分,跟前端约定好就行。 保存接口:
java
/**
* 保存.
*
* @param dto the dto
* @return the response entity
*/
@PostMapping("/save")
@Operation(summary = "保存", description = "保存对象,包括新增和修改")
@Parameters({
@Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "标识用户信息的请求头", required = true)
})
public ResponseEntity<ResultEntity<AdminUserDTO>> save(@Validated @RequestBody AdminUserDTO dto) {
ResultEntity<AdminUserDTO> resultEntity = adminUserService.save(dto);
return ResponseEntity.ok(resultEntity);
}
AdminUserDTO加上邮箱校验:
java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserDTO {
private Long id;
@NotBlank(message = "邮箱不能为空")
private String email;
private String mobile;
private String userName;
}
试一下: 我去。。。竟然成功了。一定是漏了什么。。。 我们好像没有加上validation依赖。加一下依赖再试:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
这次起作用了,设置断点的话,可以看到确实进入了我们的全局异常处理代码中。
过滤器中的异常处理
全局异常处理可以处理Controller层以下的异常,但是过滤器中的异常是这个全局拦截器拦截不到的。 过滤器属于servlet那一层,一般这种异常是在过滤器中增加一个forward处理,转到特定的一个Controller,然后再捕获Controller的异常,但是我这次使用的版本不知道太高,还是姿势不对,没有转成功,这个回头我再详细调查一下原因。没有关系,解决问题的道路千千万万,我们有其他思路。 首先增加一个认证进入点(AuthenticationEntryPoint)的实现:
java
package com.sptan.framework.config;
import com.sptan.framework.core.ResultEntity;
import com.sptan.framework.util.JSONUtils;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 处理JWT认证过滤器中的异常.
*
* @author liupeng
* @date 2024/5/5
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.OK.value());
ResultEntity resultEntity = ResultEntity.error("认证登录失败");
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(JSONUtils.toJSONString(resultEntity).getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
安全配置中把它加进去:
java
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http = http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(request -> request
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/doc.html",
"/swagger-resources/configuration/ui",
"/swagger*",
"/swagger**/**",
"/webjars/**",
"/favicon.ico",
"/**/*.css",
"/**/*.js",
"/**/*.png",
"/**/*.gif",
"/v3/**",
"/**/*.ttf",
"/actuator/**",
"/static/**",
"/resources/**").permitAll()
.anyRequest().authenticated())
.sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
.authenticationProvider(authenticationProvider())
.exceptionHandling(exception ->
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
JWT过滤器中处理一下异常:
java
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userName;
if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
userName = jwtProvider.extractUserName(jwt);
if (StringUtils.isNotEmpty(userName)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
if (jwtProvider.isTokenValid(jwt, userDetails)) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
context.setAuthentication(authToken);
SecurityContextHolder.setContext(context);
if (userDetails instanceof LoginUser) {
// 将用户ID存到Request中, 在数据审计时用到
LoginUser loginUser = (LoginUser)userDetails;
request.setAttribute(SsmpConstants.REQUEST_ATTRIBUTE_USER_ID, loginUser.getUser().getId());
}
}
}
filterChain.doFilter(request, response);
} catch (Exception e) {
throw new AccessDeniedException(e.getMessage());
}
}
如果JWT过滤器中有异常,那么会进入这个处理逻辑。 想出现异常很容易,使用伪造的token,等着合法token过期都应该能触发这个逻辑,我们的认证信息在redis中缓存了,如果redis中查不到token对应的用户信息,应该也是属于认证问题。 我们试一下,把redis中的对应key删掉后再尝试保存用户信息: 这时,邮箱信息其实也是空的,但是明显认证信息优先级更高,所以我们得到了认证登录失败的错误信息,而不是展示邮箱不能为空的消息。 到目前为止,一个开发脚手架就具备雏形了。当然还确认很多东西,没有Excel的导入导出功能,没有工作流,权限控制还很粗糙,但是已经可以作为一个小项目的起点了。 截止目前代码,我放在码云上,感兴趣的可以参考: gitee.com/peng.liu.s/...