Redis实战之Session实现短信登录以及Redis完善登录功能

目录

一、环境准备

1、导入黑马点评项目

1.1、导入 SQL

1.2、有关当前模型

 1.3、导入后端项目

1.4、导入前端工程

1.5、运行前端项目

二、基于 Session 实现登录流程

1、发送验证码:

2、短信验证码登录、注册:

3、校验登录状态:

 4、实现发送短信验证码登录功能

4.1、页面流程

4.2、验证码获取功能

 4.3、短信验证码的登入

 4.4、实现登录拦截功能

 5、session 共享问题

5.1、核心思路分析:

5.2、Redis 代替 session 的业务流程

5.3、整体访问流程

三、基于 Redis 实现短信登录

1、发送短信验证码

 2、短信登入、注册

 3、解决状态登录刷新问题

3.1、初始方案思路总结

 3.2、 优化方案

 3.3、RefreshTokenInterceptor

3.4、LoginInterceptor

 3.5、MvcConfig


一、环境准备

1、导入黑马点评项目

1.1、导入 SQL

1.2、有关当前模型

        手机或者 app 端发起请求,请求我们的 nginx 服务器,nginx 基于七层模型走的事 HTTP 协议,可以实现基于 Lua 直接绕开 tomcat 访问 redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游 tomcat 服务器,打散流量,我们都知道一台 4 核 8G 的 tomcat,在优化和处理简单业务的加持下,大不了就处理 1000 左右的并发, 经过 nginx 的负载均衡分流后,利用集群支撑起整个项目,同时 nginx 在部署了前端项目后,更是可以做到动静分离,进一步降低 tomcat 服务的压力,这些功能都得靠 nginx 起作用,所以 nginx 是整个项目中重要的一环。

        在 tomcat 支撑起并发流量后,我们如果让 tomcat 直接去访问 Mysql ,根据经验 Mysql 企业级服务器只要上点并发,一般是 16 或 32 核心 cpu,32 或 64G 内存,像企业级 mysql 加上固态硬盘能够支撑的并发,大概就是 4000 起~7000 左右,上万并发, 瞬间就会让 Mysql 服务器的 cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用 mysql 集群,同时为了进一步降低 Mysql 的压力,同时增加访问的性能,我们也会加入 Redis,同时使用 Redis 集群使得 Redis 对外提供更好的服务。

 1.3、导入后端项目

在资料中提供了一个项目源码:

1.4、导入前端工程

 

1.5、运行前端项目

 


二、基于 Session 实现登录流程

1、发送验证码:

  • 用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
  • 如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

2、短信验证码登录、注册:

        用户将验证码和手机号进行输入,后台从 session 中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到 session 中,方便后续获得当前登录信息

3、校验登录状态:

        用户在请求时候,会从 cookie 中携带者 JsessionId 到后台,后台通过 JsessionId 从 session 中拿到用户信息,如果没有 session 信息,则进行拦截,如果有 session 信息,则将用户信息保存到 threadLocal 中,并且放行

 4、实现发送短信验证码登录功能

4.1、页面流程

 

具体代码如下

贴心小提示:

具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。

4.2、验证码获取功能

UserController控制层 

控制层调用service层的接口 

/**
 * 发送手机验证码
 */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    return userService.sendCode(phone,session);
}

IUserService接口 

接口定义sendCode()方法 

public interface IUserService extends IService<User> {
 
    Result sendCode(String phone, HttpSession session);
}

 UserServiceImpl实现接口方法 

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService{
 
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1. 校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误");
        }
 
        //3. 符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
 
        //4. 保存验证码到session
        session.setAttribute("code",code);
 
        //5. 发送验证码
        log.debug("发送短信验证码成功,验证码:{}",code);
 
        //返回ok
        return Result.ok();
    }
}

注意:

上述代码中使用到了许多工具类,各个工具类的定义如下:

RegexUtils (校验手机号)

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

 RegexUtils 中调用了 RegexPatterns(正则校验) 

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}$";
}

我们重启服务,在前端页面输入手机号,点击获取验证码,在idea中的日志:

 说明该功能实现完成!

 4.3、短信验证码的登入

UserController控制层  

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    // 实现登录功能
    return userService.login(loginForm, session);
}

IUserService接口 

接口定义login()方法 

public interface IUserService extends IService<User> {
 
    Result login(LoginFormDTO loginForm, HttpSession session);
}

UserServiceImpl实现接口方法  

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
 
    //1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }
 
    //2. 校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)){
        //3. 不一致,报错
        return Result.fail("验证码错误");
    }
 
    //4.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
 
    //5. 判断用户是否存在
    if (user == null){
        //6. 不存在,创建新用户
        user = createUserWithPhone(phone);
    }
 
    //7.保存用户信息到session
    session.setAttribute("user",BeanUtil.copyProperties(user,UserDTO.class));
    return Result.ok();
}

 在login()方法中调用createUserWithPhone()方法创建用户 

private User createUserWithPhone(String phone) {
 
    // 1.创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
 
    // 2.保存用户
    save(user);
    return user;
}

 4.4、实现登录拦截功能

 

拦截器 LoginInterceptor 

此处使用拦截器校验用户是否存在,存在就登入;反之return false 

public class LoginInterceptor implements HandlerInterceptor {
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取session
        HttpSession session = request.getSession();
        //2.获取session中的用户
        Object user = session.getAttribute("user");
        //3. 判断用户是否存在
        if (user == null){
            //4. 不存在,拦截
            response.setStatus(401);
            return false;
        }
 
        //5. 存在 保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        //6. 放行
        return true;
    }
 
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }
}

配置拦截器 MvcConfig

使拦截器LoginInterceptor 生效!并定义拦截的对象。

在SpringBoot中可以使用addPathPatterns配置需要拦截的路径、excludePathPatterns配置不要拦截的路径。

此处,表示放行的资源(即无需登入,就可以访问的页面)

@Configuration
public class MvcConfig implements WebMvcConfigurer {
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

注意:

上述的一系列代码,我们还做了安全性考虑,我们使用了UserDTO,并没有直接使用User,一来减少了数据传输的成本,二来避免了User中的敏感数据泄露!

UserDTO

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

 5、session 共享问题

5.1、核心思路分析:

        每个 tomcat 中都有一份属于自己的 session, 假设用户第一次访问第一台 tomcat,并且把自己的信息存放到第一台服务器的 session 中,但是第二次这个用户访问到了第二台 tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的 session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是 session 拷贝,就是说虽然每个 tomcat 上都有不同的 session,但是每当任意一台服务器的 session 修改时,都会同步给其他的 Tomcat 服务器的 session,这样的话,就可以实现 session 的共享了

但是这种方案具有两个大问题

  1. 每台服务器中都有完整的一份 session 数据,服务器压力过大。
  2. session 拷贝数据时,可能会出现延迟
  3. 所以咱们后来采用的方案都是基于 redis 来完成,我们把 session 换成 redis,redis 数据本身就是共享的,就可以避免 session 共享的问题了

5.2、Redis 代替 session 的业务流程

 1)设计 key 的结构

        首先我们要思考一下利用 redis 来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用 String,或者是使用哈希,如下图,如果使用 String,同学们注意他的 value,用多占用一点空间,如果使用哈希,则他的 value 中只会存储他数据本身,如果不是特别在意内存,其实使用 String 就可以啦。

 2)设计 key 的具体细节

        所以我们可以使用 String 结构,就是一个简单的 key,value 键值对的方式,但是关于 key 的处理,session 他是每个用户都有自己的 session,但是 redis 的 key 是共享的,咱们就不能使用 code 了。

在设计这个 key 的时候,我们之前讲过需要满足两点

  • 1、key 要具有唯一性
  • 2、key 要方便携带

        如果我们采用 phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到 redis 中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串 token,然后让前端带来这个 token 就能完成我们的整体逻辑了

5.3、整体访问流程

        当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到 redis,并且生成 token 作为 redis 的 key,当我们校验用户是否登录时,会去携带着 token 进行访问,从 redis 中取出 token 对应的 value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到 threadLocal 中,并且放行。


 

三、基于 Redis 实现短信登录

这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。

1、发送短信验证码

与之前Session的方式基本一致,只是在保存验证码的时候是保存到Redis中。

UserServiceImpl
@Override
public Result sendCode(String phone, HttpSession session) {
    //1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        //2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误");
    }
 
    //3. 符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
 
    //4. 保存验证码到Redis
    // 可以加上一个业务前缀来区分
    // 设置验证码的有效期 Redis的key有效期
    // ("login:code:" + phone,code,2, TimeUnit.MINUTES)
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
 
    //5. 发送验证码
    log.debug("发送短信验证码成功,验证码:{}",code);
 
    //返回ok
    return Result.ok();
}

上述代码使用到了一些常量,我们这里是用一个工具类RedisConstants 去声明一些常量。

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:";
}

 2、短信登入、注册

需要改动的东西较多。

  • 首先我们需要从Redis中获取验证码,并进行校验;
  • 然后要将用户信息与随机生成的token一起存放到redis中;
  • 最后再设置一下token的有效期。

UserServiceImpl

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
 
    //1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }
 
    //2. 从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)){
        //3. 不一致,报错
        return Result.fail("验证码错误");
    }
 
    //4.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
 
    //5. 判断用户是否存在
    if (user == null){
        //6. 不存在,创建新用户
        user = createUserWithPhone(phone);
    }
 
    //7.保存用户信息到redis
    //7.1 生成随机token作为登入令牌
    String token = UUID.randomUUID().toString(true);
 
    //7.2 将User对象作为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 注意!!!
    // 这里要是数据不处理会报错,UserDTO中有long类型的数据,
    //而后面stringRedisTempalte中的数据要求是必须string类型的才可以!!!
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
            CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fileName,fileValue) -> fileValue.toString()));
 
    //7.3 存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
 
    //7.4设置token的有效期
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
 
    //8.返回token
    return Result.ok(token);
}

创建用户

private User createUserWithPhone(String phone) {
 
    // 1.创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
 
    // 2.保存用户
    save(user);
    return user;
}

 3、解决状态登录刷新问题

3.1、初始方案思路总结

        在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录 token 令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

 3.2、 优化方案

        既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了 threadLocal 的数据,所以此时第二个拦截器只需要判断拦截器中的 user 对象是否存在即可,完成整体刷新功能

 3.3、RefreshTokenInterceptor

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(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获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

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

3.4、LoginInterceptor

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

 3.5、MvcConfig

package com.hmdp.config;

import com.hmdp.interceptor.LoginInterceptor;
import com.hmdp.interceptor.RefreshTokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@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);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值