使用springboot3.X+spring security6+ JWT+MyBatisPlus搭建一个后端基础脚手架(2)

形成接口文档

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的实现类生成了。 运行接口,这次就可以成功调用了。

到目前为止,一个最基础的脚手架基本可用了。上面的步骤是我一步步做下来的,做到哪文档就截取到哪里,如果你跟谁上面步骤,应该会得到与我同样的结果。

当然现在还有很多不完善的地方。

  1. 想到哪写到哪,目前的结构比较混乱;
  2. 缺少缓存机制;
  3. 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/...

相关推荐
lang201509287 小时前
Spring Boot配置属性:类型安全的最佳实践
spring boot
Jabes.yang13 小时前
Java面试场景:从Spring Web到Kafka的音视频应用挑战
大数据·spring boot·kafka·spring security·java面试·spring webflux
程序员小凯15 小时前
Spring Boot性能优化详解
spring boot·后端·性能优化
tuine16 小时前
SpringBoot使用LocalDate接收参数解析问题
java·spring boot·后端
番茄Salad17 小时前
Spring Boot项目中Maven引入依赖常见报错问题解决
spring boot·后端·maven
摇滚侠17 小时前
Spring Boot 3零基础教程,yml配置文件,笔记13
spring boot·redis·笔记
!if18 小时前
springboot mybatisplus 配置SQL日志,但是没有日志输出
spring boot·sql·mybatis
阿挥的编程日记18 小时前
基于SpringBoot的影评管理系统
java·spring boot·后端
java坤坤18 小时前
Spring Boot 集成 SpringDoc OpenAPI(Swagger)实战:从配置到接口文档落地
java·spring boot·后端
摇滚侠19 小时前
Spring Boot 3零基础教程,整合Redis,笔记12
spring boot·redis·笔记