注:此篇博客专为使用springboot框架结合redis来实现一个注册、登录、验证登录状态的功能的人群使用,具体就是使用redis存储一个短信验证码以及用户的信息来实现上述的功能,总体的实现还算比较复杂,所以就写下这篇博客来记录一下,以后自己忘记了可以过来回忆一下知识点

本文开始之前的小tips

为什么要使用一个redis这样的nosql,而不是选择一个本地缓存呢?例如session和cookie

答:因为后期我们要使用到一个tomcat集群,而我们的session的值是存在于每个tomcat服务器之中的,其实也可以将我们的session的数据共享给其他的tomcat服务器,但是这样内存占用大,而且还是会出现问题,所以就出现redis。

一、导包

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

二、配置redis(yaml文件) 

spring:
  redis:
    host: xxx.xxx.xxx.xxx
    port: 6379
    password: xxxxxx
    lettuce:
      pool:
        # 最大连接数
        max-active: 10
        # 最大闲置数
        max-idle: 10
        # 最小闲置数
        min-idle: 1
        # 最长等待时间
        time-between-eviction-runs: 10s

三、功能展示

(1)发送验证码(将验证码存入redis中)

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.验证手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机格式错误");
        }
        // 2.生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 3.存入redis中
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 4.发送验证码
        log.debug("发送验证码成功,验证码为:{}", code);
        // 5.返回ok
        return Result.ok();
    }

上面则是业务的代码,是service的实现类,下面的说明则是对上面一段代码的所有解释

RegexUtils工具类是用来检测我们的手机号码和邮箱等的格式是否正确的一个工具类,里面还用到了另一个封装成常量的工具类,和我们的RedisConstant不一样:如下图所示:

这张图是真正的工具类

public class RegexUtils {
    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }
    /**
     * 是否是无效邮箱格式
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email){
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code){
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {
            return true;
        }
        return !str.matches(regex);
    }
}

下面这张图是封装成常量的工具类,给上面的工具类所调用的

public abstract class RegexPatterns {
    /**
     * 手机号正则
     */
    public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。4~32位的字母、数字、下划线
     */
    public static final String PASSWORD_REGEX = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";

}

这里涉及到一个Result对象,我觉得很好,以后在工程中可以用作统一的返回格式:如下图所示

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    // 没有资源返回的成功消息返回
    public static Result ok(){
        return new Result(true, null, null, null);
    }
    // 有资料返回的成功消息返回
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    // 有资料,有长度的成功消息返回
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    // 有信息的错误消息返回
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

这里还设计到了一个工具类:RandomUtil,这个是以下这个依赖下的工具类:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.17</version>
</dependency>

通过RandomUtil我们就可以来很简单的生成一个6位的随机数(用作验证码)

这里还用到了Spring提供的一个redis的序列化器:StringRedisTemplate,这个序列化的key和value都是string,这里还将存入redis的key的前缀封装成了一个Util包的常量,方便在其他地方调用(起到了代码复用的作用,而且b格满满),如下图所示:

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 36000L;

    public static final Long CACHE_NULL_TTL = 2L;

    public static final Long CACHE_SHOP_TTL = 30L;
    public static final String CACHE_SHOP_KEY = "cache:shop:";

    public static final String LOCK_SHOP_KEY = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 10L;

    public static final String SECKILL_STOCK_KEY = "seckill:stock:";
    public static final String BLOG_LIKED_KEY = "blog:liked:";
    public static final String FEED_KEY = "feed:";
    public static final String SHOP_GEO_KEY = "shop:geo:";
    public static final String USER_SIGN_KEY = "sign:";
}

上面没有真正的调用阿里云或者腾讯云的发送短信的api接口,因为学习用,所以不想搞得太复杂,大家有需求的话我可以出一期博客细讲这个(通过@Slf4j这个注解来debug一段话当作成功发送了验证码)

(2)登录和注册功能(将一个前端所需的用户信息存入到redis中)

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
            return Result.fail("手机格式错误");
        }

        // 2.校验验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
        String code = loginForm.getCode();
        if (code == null || !cacheCode.equals(code)) {
            return Result.fail("验证码错误");
        }

        // 3.根据手机号查询用户
        User user = query().eq("phone", loginForm.getPhone()).one();
        // 4.判断用户是否存在
        if (user == null) {
            user = createUserWithPhone(loginForm.getPhone());
        }
        // 5.不存在则创建一个新用户
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,
                new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        String token = String.valueOf(UUID.randomUUID(true));
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 6.返回ok
        return Result.ok(token);
    }

上面则是业务的代码,是service的实现类,下面的说明则是对上面一段代码的所有解释

在步骤3下面其实用到了query()这样的方法的(其实就是mybatis-plus)

在步骤4下面有一个createUserWithPhone,就是下面这个方法,这里用到了一个用户的常量前缀和调用了RandomUtil工具类的随机字符串,长度为10,其实就是随机生成一个用户的nickname,save()就是mybatis-plus封装的方法

    private User createUserWithPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        save(user);
        return user;
    }

这里还通过一个BeanUtil工具类的copyProperties方法来将我们的User类的对象转换成UserDTO类的对象,之后还将我们的userDTO对象转换成了一个map对象,主要是通过上述的工具类的beanToMap,这里涉及到一个难点,就是userDTO的类变量有一个Long类型的id值,当它转换成string的时候会报错,这个时候需要加后面一串代码,或者你可以自己一个一个转换格式后加入到一个map对象中

这里token值的生成用的是UUID的工具类,通过用户的key前缀的拼接形成我们存在redis中的key值,这里我们存的redis数据结构用的是hash的方式,易于后续对个别值的修改

将我们的数据存入之后记得要设置一个时长,不然随着用户数据的增加我们的redis也会内存溢出的

(3)我的页面获取个人信息

    @GetMapping("/me")
    public Result me(){
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

上面则是controller层的代码,下面的说明则是对上面一段代码的所有解释

这里获取个人信息直接通过我们UserHolder获取数据返回,这是因为我们通过这个工具类在我们当前线程中存入了一个局部变量(Thread Local),通过拦截器来对返回数据前后进行一个处理,就能起到一个维护用户状态的这么一个作用,具体拦截器代码在下面下面,下面是UserHolder类的代码:

public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

这里我们使用了两个拦截器,第一个拦截器其实是对所有的请求进行一个处理,其实也不能算是拦截,就是处理所有的请求,第二个拦截器实现的功能才是一个拦截的作用

第一个拦截器:

public class RefreshInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.通过token获取user对象
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        // 3.判断对象是否为空
        if (userMap == null) {
            return true;
        }
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 4.将user对象保存到ThreadLocal中
        UserHolder.saveUser(userDTO);
        // 5.刷新token的有效时间
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

上面这个类不会进入到SpringIOC容器中,所以不能进行一个自动转配,所以我们通过一个构造方法在注册拦截器的时候来传进来,这样我们就可以使用这个类了

这里获取了前端传过来的token值,这个token值是存在前端中的头部的,之后通过我们的StrUtil的工具类来判断我们的token是否为空,如果为空就通行,因为这个用户可能没登录,只是在首页观看商品而已,这种请求在第二个拦截器中也不会被拦截,属于是在规定范围内的未登录用户的请求

之后如果有token值,就从我们的redis中读取我们的一些key和value,如果没有数据说明token值过期了,需要重新登录,则放行经过到第二个拦截器中来阻止放行。如果有数据则通过BeanUtil工具类的fillBeanWithMap将map对象转换成UserDTO对象,之后将该对象存进线程的局部变量中,属于是线程私有的,之后刷新该token的有效时间,因为有一些用户的请求不会被第二个拦截器,例如用户访问首页,这些也是需要刷新token值的时间的,不然用户一直在访问,但是token值没有被刷新,一段时间后用户可能就token值失效了,需要重新登录了

第二个拦截器:

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

这里如果我们没从线程局部变量中拿到数据,所以token值失效,需要重新访问,不进行放行

上面的拦截器需要在我们的springboot中进行注册,下面是注册的代码:

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
        registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

上面的注册@Configuration表示我们这个类是一个配置类,它会被加载进SpringIOC容器中,所以我们这里可以用一个@Resource的注解进行一个自动装配

excludePathPatterns就是排除一些请求,就是说下面的这些请求不会经过这个拦截器,order就是一个拦截器的先后顺序,先后顺序是从0、1、2、3、4、5这样进行下去的

第一个拦截器在最下面,所以我用了order(0),这个拦截器我们用到了StringRedisTemplate,所以通过构造方法将这个参数传入进去就可以了

上面的这个类就好放在一个Config包下,方便后续管理

下面我通过一张图来解释整个请求过程以及用户请求经过拦截器的详细过程

整个请求过程:

用户请求经过拦截器的详细过程:

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐