天天看點

Spring Security結合Redis實作緩存功能

Redis是一個開源的,基于記憶體的資料結構存儲,可用作于資料庫、緩存、消息中間件。

  • Redis基于記憶體,支援多種資料結構。
  • Redis 提供了多種資料類型來支援不同的業務場景。Redis 還支援事務 、持久化、Lua 腳本、多種叢集方案。

Mac安裝

brew install redis           

檢視安裝及配置檔案位置

  • Homebrew安裝的軟體會預設在/usr/local/Cellar/路徑下
  • redis的配置檔案redis.conf存放在/usr/local/etc路徑下

啟動 redis

//方式一:使用brew幫助我們啟動軟體
brew services start redis
//方式二
redis-server           

檢視redis服務程序

我們可以通過下面指令檢視redis是否正在運作

ps axu | grep redis           

redis-cli連接配接redis服務

redis預設端口号6379,預設auth為空,輸入以下指令即可連接配接

redis-cli -h 127.0.0.1 -p 6379           

啟動 redis 用戶端,打開終端并輸入指令 redis-cli。該指令會連接配接本地的 redis 服務。

$redis-cli
redis 127.0.0.1:6379>
redis 127.0.0.1:6379> PING
PONG           
在以上執行個體中我們連接配接到本地的 redis 服務并執行 PING 指令,該指令用于檢測 redis 服務是否啟動。

關閉 redis 服務

  • 正确停止Redis的方式應該是向 Redis 發送 SHUTDOWN 指令
redis-cli shutdown           
  • 強行終止 redis
sudo pkill redis-server           

redis.conf 配置檔案詳解

redis 預設是前台啟動,如果我們想以守護程序的方式運作(背景運作),可以在 redis.conf 中将 daemonize no,修改成 yes即可。

可視化工具Redis Desktop Manager

安裝方法

  • 安裝brew cask : 在終端中輸入下面語句 回車
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" < /dev/null 2> /dev/null ; brew install caskroom/cask/brew-cask 2> /dev/null           
  • 可能會需要你的mac密碼,輸入即可
  • 安裝Redis Desktop Manager
  • 安裝完cask之後,在終端中輸入 回車
brew install rdm --cask
brew install another-redis-desktop-manager --cask           

整合Resis

我們使用 Redis 最主要是将其作為緩存,使用緩存主要是為了提升使用者體驗以及應對更多的使用者,從程式設計而言是為了高性能和高并發。

高性能 :

假如使用者第一次通路資料庫中的某些資料的話,這個過程是比較慢,畢竟是從硬碟中讀取的。但是,如果說,使用者通路的資料屬于高頻資料并且不會經常改變的話,那麼我們就可以很放心地将該使用者通路的資料存在緩存中。

這樣有什麼好處呢? 那就是保證使用者下一次再通路這些資料的時候就可以直接從緩存中擷取了。操作緩存就是直接操作記憶體,是以速度相當快。

不過,要保持資料庫和緩存中的資料的一緻性。 如果資料庫中的對應資料改變的之後,同步改變緩存中相應的資料即可!

高并發:

一般像 MySQL 這類的資料庫的 QPS 大概都在 1w 左右(4核8g) ,但是使用 Redis 緩存之後很容易達到 10w+,甚至最高能達到30w+(就單機redis的情況,redis 叢集的話會更高)。

QPS(Query Per Second):伺服器每秒可以執行的查詢次數;

是以,直接操作緩存能夠承受的資料庫請求數量是遠遠大于直接通路資料庫的,是以我們可以考慮把資料庫中的部分資料轉移到緩存中去,這樣使用者的一部分請求會直接到緩存這裡而不用經過資料庫。進而,我們也就提高的系統整體的并發。

不過在本文中,我們僅僅使用 Redis 作為臨時緩存,用來存儲使用者注冊時所需的驗證碼以及使用者登入後緩存使用者資訊。

項目實踐

建立一個新項目,名為 springboot-redis.

1、首先引入依賴:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.6.3</version>
  <relativePath/>
</parent>

<properties>
  <mysql.version>8.0.19</mysql.version>
  <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
  <org.projectlombok.version>1.18.20</org.projectlombok.version>
  <druid.version>1.1.18</druid.version>
</properties>

<dependencies>
  <!-- 以下是>spring boot依賴-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>

  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>

  <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.8</version>
  </dependency>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>${druid.version}</version>
  </dependency>

  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>
  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>

  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.1</version>
  </dependency>
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus</artifactId>
    <version>3.5.1</version>
  </dependency>
</dependencies>           

2、修改 application.yml

server:
  port: 8085

spring:
  redis:
    host: 127.0.0.1
    database: 0 # Redis資料庫索引(預設為0)
    port: 6379 # Redis伺服器連接配接端口
    password: # Redis伺服器連接配接密碼(預設為空)
    jedis:
      pool:
        max-active: 8 # 連接配接池最大連接配接數(使用負值表示沒有限制)
        max-wait: -1ms # 連接配接池最大阻塞等待時間(使用負值表示沒有限制)
        max-idle: 8 # 連接配接池中的最大空閑連接配接
        min-idle: 0 # 連接配接池中的最小空閑連接配接
    timeout: 3000ms # 連接配接逾時時間(毫秒)
  application:
    name: springboot-redis
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring_security?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false
    username: root
    password: root
  thymeleaf:
    cache: false

redis:
  key:
    prefix:
      authCode: "authCode:"
    expire:
      time: 120  #驗證碼超期時間,機關s           

3、自定義配置 RedisTemplate

Spring 預設為我們注入了 RedisTemplate 和 StringRedisTemplate ,如果我們沒有手動注入相同名字的 bean 的話,RedisTemplate 預設的 key,value,hashKey,hashValue 序列化方式都為 JdkSerializationRedisSerializer,即二進制序列化方式,StringRedisTemplate 所有的序列化方式都為 RedisSerializer.string(),即 String。

@Configuration(
  proxyBeanMethods = false
)
@ConditionalOnClass({RedisOperations.class})
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
  public RedisAutoConfiguration() {
  }

  @Bean
  @ConditionalOnMissingBean(
    name = {"redisTemplate"}
  )
  @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
  public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<Object, Object> template = new RedisTemplate();
    template.setConnectionFactory(redisConnectionFactory);
    return template;
  }

  @Bean
  @ConditionalOnMissingBean
  @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
  public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
    return new StringRedisTemplate(redisConnectionFactory);
  }
}           

Springboot 2.6.3 預設的 Redis 用戶端為 Lettuce,預設的連接配接工廠為 LettuceConnectionFactory:

org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration#redisConnectionFactory           

如下圖所示:

Spring Security結合Redis實作緩存功能

對應源碼為:

@Bean
  @ConditionalOnMissingBean({RedisConnectionFactory.class})
  LettuceConnectionFactory redisConnectionFactory(ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers, ClientResources clientResources) {
    LettuceClientConfiguration clientConfig = this.getLettuceClientConfiguration(builderCustomizers, clientResources, this.getProperties().getLettuce().getPool());
    return this.createLettuceConnectionFactory(clientConfig);
  }           

另外,Spring Data Redis提供了其他 ConnectionFactory,比如說 JedisConnectionFactory 等,目前 SpringBoot 預設 LettuceConnectionFactory,加之 Lettuce 社群更活躍一些,我們跟着 SpringBoot 的預設值走就好了。

Spring-data-redis 提供的序列化方式:

Spring Security結合Redis實作緩存功能

對于字元串,我們希望 key,value 序列化方式都為 String,但是對于 Hash,key 的序列化方式為 String,但是 value 的序列化方式,我們希望為 JSON。是以我們需要自己配置 RedisTemplate 并注入到 Spring 容器中。

自定義配置 RedisTemplate 檔案

@Configuration
public class RedisConfig {

  @Bean
  public RedisTemplate<String, Object> redisTemplate(
      RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory);

    // 建立JSON序列化器
    Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(
        Object.class);
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    //必須設定,否則無法将JSON轉化為對象,會轉化成Map類型
    objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
        ObjectMapper.DefaultTyping.NON_FINAL);
    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

    StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

    // key采用String的序列化方式
    template.setKeySerializer(stringRedisSerializer);
    // hash的key也采用String的序列化方式
    template.setHashKeySerializer(stringRedisSerializer);
    // value序列化方式采用jackson
    template.setValueSerializer(jackson2JsonRedisSerializer);
    // hash的value序列化方式采用jackson
    template.setHashValueSerializer(jackson2JsonRedisSerializer);
    template.afterPropertiesSet();

    return template;
  }
}           

4、添加 RedisService 接口用于定義一些常用 Redis 操作

public interface RedisService {

  /**
   * 儲存屬性.
   */
  void set(String key, Object value, long time);

  /**
   * 儲存屬性.
   */
  void set(String key, Object value);

  /**
   * 擷取屬性.
   */
  Object get(String key);

  /**
   * 删除屬性.
   */
  Boolean del(String key);

  /**
   * 批量删除屬性.
   */
  Long del(List<String> keys);

  /**
   * 設定過期時間.
   */
  Boolean expire(String key, long time);

  ......
}           

因篇幅原因,這裡就不貼 RedisServiceImpl 實作類的代碼。

5、添加 UserService,定義使用者注冊、登入以及擷取驗證碼這三個方法,具體實作如下:

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

  private final RedisService redisService;
  private final UserMapper userMapper;
  private final PasswordEncoder passwordEncoder;
  private final UserStruct userStruct;
  private final MyUserDetailsService userDetailsService;

  @Value("${redis.key.prefix.authCode}")
  private String REDIS_KEY_PREFIX_AUTH_CODE;
  @Value("${redis.key.expire.time}")
  private Long AUTH_CODE_EXPIRE_SECONDS;


  @Override
  public String login(String username, String password) {
    String token = IdUtil.simpleUUID();
    try {

      UserDetails userDetails = userDetailsService.loadUserByUsername(username);
      if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        BusinessException.fail("密碼不正确");
      }
      if (!userDetails.isEnabled()) {
        BusinessException.fail("帳号已被禁用");
      }
      UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
          userDetails, null, userDetails.getAuthorities());
      SecurityContextHolder.getContext().setAuthentication(authentication);
      redisService.set(token, username, 60 * 60);
    } catch (AuthenticationException e) {
      log.error("登入異常,detail" + e.getMessage());
    }

    return token;
  }


  @Override
  public void register(UserRequest userRequest) {
    if (Objects.isNull(userRequest) || isBlank(userRequest.getUsername()) ||
        isBlank(userRequest.getPassword())) {
      BusinessException.fail("賬号或密碼為空!");
    }
    boolean flag = verifyAuthCode(userRequest.getUsername(), userRequest.getAuthCode());
    if (flag) {
      User user = userMapper.selectByUserName(userRequest.getUsername());
      if (Objects.nonNull(user)) {
        BusinessException.fail("使用者名已存在!");
      }
      String encodePassword = passwordEncoder.encode(userRequest.getPassword());
      User obj = userStruct.toUser(userRequest);
      obj.setPassword(encodePassword);
      userMapper.insert(obj);
    }
  }

  public String generateAuthCode(String username) {
    StringBuilder sb = new StringBuilder();
    Random random = new Random();
    for (int i = 0; i < 6; i++) {
      sb.append(random.nextInt(10));
    }
    String code = sb.toString();
    redisService.set(REDIS_KEY_PREFIX_AUTH_CODE + username, code, AUTH_CODE_EXPIRE_SECONDS);
    return code;
  }

  private boolean verifyAuthCode(String username, String authCode) {
    if (!StringUtils.hasLength(authCode)) {
      BusinessException.fail("請輸入驗證碼!");
    }
    String realAuthCode = (String) redisService.get(REDIS_KEY_PREFIX_AUTH_CODE + username);
    return authCode.equals(realAuthCode);
  }

}           

注冊使用者不允許使用者名重複,嚴格來講,根據使用者名生成驗證碼時應該校驗使用者名是否重複,不然同時注冊一個使用者名,驗證碼會被覆寫,此處驗證碼的過期時間為自己配置的時間(這裡為120s)。

6、添加 UserController 和 ResourceController

@RestController
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @GetMapping("/verify-code")
  public Result getVerifyCodePng(@RequestParam String username) {
    String authCode = userService.generateAuthCode(username);
    return Result.ok(authCode);
  }

  @PostMapping("/register")
  public Result register(@RequestBody UserRequest request) {
    userService.register(request);
    return Result.ok();
  }

  @PostMapping(value = "/login")
  public Result login(@RequestParam("username") String username,
      @RequestParam("password") String password) {
    String token = userService.login(username, password);
    Map<String, String> tokenMap = new HashMap<>();
    tokenMap.put("token", token);
    tokenMap.put("tokenHead", "Bearer ");
    return Result.ok(tokenMap);
  }
}

@RestController
@RequiredArgsConstructor
public class ResourceController {

  private final RedisService redisService;

  @GetMapping(value = "/home/level1")
  @PreAuthorize("hasAuthority('home')")
  public Result getHomeLevel1(HttpServletRequest request) {
    String token = TokenUtil.getTokenFromAuthorizationHeader(request);
    if (StrUtil.isBlank(token)) {
      return null;
    }
    String username = (String) redisService.get(token);
    return Result.ok(username + " 成功通路Home目錄下的Level1頁面");
  }

  @GetMapping(value = "/customer/level1")
  @PreAuthorize("hasAuthority('customer')")
  public Result getCustomerLevel1(HttpServletRequest request) {
    String token = TokenUtil.getTokenFromAuthorizationHeader(request);
    if (StrUtil.isBlank(token)) {
      return null;
    }
    String username = (String) redisService.get(token);
    return Result.ok(username + " 成功通路Customer目錄下的Level1頁面");
  }
}           

7、自定義過濾器,繼承 OncePerRequestFilter

@Slf4j
public class AuthenticationTokenFilter extends OncePerRequestFilter {

  @Autowired
  private MyUserDetailsService userDetailsService;
  @Autowired
  private RedisService redisService;
  private static final String AUTH_HEADER = "Bearer ";

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    String authHeader = request.getHeader("Authorization");
    if (authHeader != null && authHeader.startsWith(AUTH_HEADER)) {
      String authToken = authHeader.substring(AUTH_HEADER.length());// The part after "Bearer "
      String username = (String) redisService.get(authToken);
      logger.info("checking username:" + username);
      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        logger.info("authenticated user:" + username);
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    }
    filterChain.doFilter(request, response);
  }
}           

8、添加 SecurityConfig

@Configuration
public class SecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public AuthenticationTokenFilter authenticationTokenFilter() {
    return new AuthenticationTokenFilter();
  }

  //安全攔截機制(最重要)
  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
        .authorizeRequests()
        .antMatchers("/register").permitAll()
        .antMatchers("/verify-code").permitAll()
        .antMatchers("/login").permitAll()
        .antMatchers("/home/level1").hasAuthority("home")
        .antMatchers("/customer/level1").hasAuthority("customer")
        .anyRequest().authenticated()
        .and()
        .addFilterBefore(authenticationTokenFilter(),
            UsernamePasswordAuthenticationFilter.class);// 自定義認證過濾器
    ;
    return http.build();
  }
}           

測試

啟動項目,我們通過 postman 進行測試。

1、注冊使用者前,先根據使用者名擷取驗證碼

Spring Security結合Redis實作緩存功能

2、注冊使用者

Spring Security結合Redis實作緩存功能

3、使用者登入

Spring Security結合Redis實作緩存功能

4、通路資源,我們提前給 zhangsan 使用者配置設定通路 home 路徑下的 api 權限

Spring Security結合Redis實作緩存功能

zhangsan 使用者無權通路 customer 路徑下的資源

Spring Security結合Redis實作緩存功能

總結

本文簡單介紹了下 Redis 的使用,結合 SpringSecurity 用于使用者注冊時增加驗證碼校驗邏輯,以及使用者登入時将使用者名存儲到 Redis 中,以供後續使用。驗證碼的邏輯在實際項目中可以添加,使用者資訊存儲則推薦考慮 JWT,本文案例還未自定義授權處理邏輯,相對來說還是較簡單。

作者:hresh

連結:https://juejin.cn/post/7177188004677550141

來源:稀土掘金

繼續閱讀