掘金 后端 ( ) • 2024-05-05 17:54

形成接口文档

springboot 2.X时代,swagger是事实上的接口文档,其实现在也是,不过版本升级了下,改了名字,变成OpenAPI了。 国内流行的knife4j对swaggerUI做了进一步封装,更符合我们对接口文档的期望,我们也用knife4j。 引入依赖:

<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>

配置文件中加上相关配置:

# 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中。

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());
    }
}

跑一下看看。 image.png 打开链接: image.png 怎么回事???脑瓜子疼。。。 谷歌一下ERR_UNSAFE_PORT是个什么玩意。。。 image.png Chrome浏览器的原因。。。 我们换个端口试试,发现6667不行,6789可以。。。 image.png 莫名其妙。。 进一步了解一下,Chrome做了一些限制,具体参考: https://superuser.com/questions/188058/which-ports-are-considered-unsafe-by-chrome/188070#188070 可知,以下端口都是不行的,奇怪的知识又增加了,这是诚心不让我们6起来啊。

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

参数说明都是空的,肯定不是我们想要的。 image.png 补齐一下,代码例子如下: image.png 效果如下: image.png

添加一个普通的接口作为例子

Controller层代码:

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);
    }
}

跑一下 image.png

竟然又出错了。。。 控制台有以下错误:

java.lang.ClassNotFoundException: Cannot find implementation for com.sptan.ssmp.converter.AdminUserConverter

这是MapStruct的接口类没有生成实现类导致的,需要在maven构建时设置。

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

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

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

  1. 想到哪写到哪,目前的结构比较混乱;
  2. 缺少缓存机制;
  3. MyBatisPlus的审计处理器中留了个坑还没有填上

到这里,代码已经很乱了,我会把代码结构简单整理一下,但是整理的过程不再赘述,如果你看到我上传的代码跟上述文档不同,是我重新整理过的原因。

整理项目结构

理想的项目结构是分层的,依赖是单向的,二不是出现循环依赖的情况。上述想到哪里做到哪里的方式太随性,不太符合工程化的思想。整理了一下,搞成一个framework包和一个业务包,业务包引用framework包,而没有相反的依赖关系。因为之前我搞得乱七八糟,所以整理的过程中出现了些循环依赖。 整理后的结构如下: image.png 整理的过程中,UserDetailsService的实现方式导致了循环依赖,改变了实现方式,framework中应用Spring的UserDetailsService,作为实现类的CustomUserDetailsService由于引用到业务包的Mapper,放在了业务包中。 MetaObjectHandler的实现也修改了,更新时的userId从Request中取,要考虑到以后job也会插入数据,这时是获取不到HttpRequest对象的。

@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是在过滤器中存储下来的。

@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接口。


    /**
     * 保存.
     *
     * @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);
    }

执行我们新的保存接口: image.png 我们使用admin的token,可以看到新增的记录的cuid和opuid是admin的userId: image.png 现在虽然结构还没有整理到很细致,但是结构混乱的肯算是填上了;数据审计时userId固定为0的坑也填上了。

增加缓存

引入Redis

        <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对象,需要特殊处理一下。

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工具类,这玩意也是全网都差不多,随便抄一个就行。

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接口:

    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中取:

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也需要改一下:

    @Override
    public String getUsername() {
        if (StringUtils.hasText(this.user.getUserIdentifier())) {
            return this.user.getUserIdentifier();
        } else {
            return this.user.getUserName();
        }
    }

是新增的一个字段,跟DB中的字段没有对应关系。 image.png 这时登录有返回的token就是一个随机串,安全性高了不少。 image.png 用户信息存储在了redis中: image.png 变得性能更好,更安全;同时,也更晦涩难懂,并且深度依赖redis了。 缓存值得说的地方太多了,最重要的是缓存与数据一致性保证,还有spring的声明式缓存也特别好用,但是坑也不少,这个话题太大,以后开个新话题再讲。这个项目把缓存引入进来后,缓存的问题先告一段落。

全局异常处理

通用的全局异常处理

通用的全局异常处理很简单,就是被Controller层调用的Service等层出现异常但又未捕获处理时,可以有机会做统一的异常处理。 我们写的全局统一异常处理的例子:

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之类的错误码。没有好坏之分,跟前端约定好就行。 保存接口:

    /**
     * 保存.
     *
     * @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加上邮箱校验:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserDTO {

    private Long id;

    @NotBlank(message = "邮箱不能为空")
    private String email;

    private String mobile;

    private String userName;

}

试一下: image.png 我去。。。竟然成功了。一定是漏了什么。。。 我们好像没有加上validation依赖。加一下依赖再试:


<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

image.png 这次起作用了,设置断点的话,可以看到确实进入了我们的全局异常处理代码中。 image.png

过滤器中的异常处理

全局异常处理可以处理Controller层以下的异常,但是过滤器中的异常是这个全局拦截器拦截不到的。 过滤器属于servlet那一层,一般这种异常是在过滤器中增加一个forward处理,转到特定的一个Controller,然后再捕获Controller的异常,但是我这次使用的版本不知道太高,还是姿势不对,没有转成功,这个回头我再详细调查一下原因。没有关系,解决问题的道路千千万万,我们有其他思路。 首先增加一个认证进入点(AuthenticationEntryPoint)的实现:

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();
    }
}

安全配置中把它加进去:


    @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过滤器中处理一下异常:

    @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删掉后再尝试保存用户信息: image.png image.png 这时,邮箱信息其实也是空的,但是明显认证信息优先级更高,所以我们得到了认证登录失败的错误信息,而不是展示邮箱不能为空的消息。 到目前为止,一个开发脚手架就具备雏形了。当然还确认很多东西,没有Excel的导入导出功能,没有工作流,权限控制还很粗糙,但是已经可以作为一个小项目的起点了。 截止目前代码,我放在码云上,感兴趣的可以参考: https://gitee.com/peng.liu.s/springboot3-springsecurity6-mybatisplus