目录
1、为什么使用 Redis 实现而不使用 Session 实现?
1.1 如果使用的是 session存在以下问题:
1.2 为什么使用 Redis 进行存储?
2、手机短信登录流程
3、短信验证码登录、注册代码实现
4、校验当前用户的登录状态代码实现
5、拦截器的优化
6、Redis 代替 Session 的注意事项
session的数据是就是的变量,放在nodejs进程中
正式线上运行时多进程,进程之间的数据无法共享:比如,有三个进程都有个session,当我第一次登陆成功的时候命中的是第一个进程,他把我的登录信息放在自己session中去了,第二次登录命中的是第二个进程的话,结果登录失败了,这就是 session 中的共享问题
因为 redis 数据是存放在内存中的,不存在数据共享问题;同时,Redis 具备一定持久层的功能,也可以作为一种缓存工具。对于 NoSQL 数据库而言,作为持久层,它存储的数据是半结构化的,这就意味着计算机在读入内存中有更少的规则,读入速度更快。对于那些结构化、多范式规则的数据库系统而言,它更具性能优势。作为缓存,它可以支持大数据存入内存中,只要命中率高,它就能快速响应,因为在内存中的数据读/写比数据库读/写磁盘的速度快几十到上百倍
定义的常量配置类:
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; }
提交手机验证码,并且设置有效期,防止他人恶意盗刷验证码,导致 redis 数据存储量飙升
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)) {
//1.1.若不正确,则提示信息
return Result.fail("手机号验证有误,请重新输入!");
}
//2.正确,则生成对应手机号的验证码
String code = RandomUtil.randomNumbers(6); //表示随机生成六位验证码
//TODO 2.1将生成的验证码保存到 redis 中,并设置验证码有效期
stringRedisTemplate.opsForValue()
.set(LOGIN_CODE_KEY +phone,code,RedisConstants.LOGIN_CODE_TTL
, TimeUnit.MINUTES); //这里使用 String 类型进行存储
从 redis 中根据对应的 phone(key 值),获取到保存的验证码并与当前输入的验证码作比较
String resCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
if(resCode==null||!resCode.equals(code)){
//2.1若不正确,则提示信息
return Result.fail("验证码有误,请重新输入!");
}
若校验验证码通过,则根据手机号查询当前用户在数据库中是否存在,若不存在,则自动创建一个新用户保存到数据库中
//3.若一致,则根据手机号查询对应的用户是否存在
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
if(user==null){
//3.1若不存在,则自动创建一个新用户到数据库
user = createUserByPhone(phone);
}
private User createUserByPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX
+RandomUtil.randomString(10)); //这里使用 hutool 依赖进行生成随机字符串
userService.save(user);
return user;
}
若存在,则将用户的信息以 token - map 的 hash 形式保存到 redis 中,map 中为用户的信息,以 key-value 形式保存,并设置有效期
注意!!!若直接这样将 userDTO 对象转换为 map 类型可能会报 类型转换异常!!!因为这里 redis 是使用 StringRedisTemplate 类型进行存储的,所需要的字段类型都要求为 String ;而我这里的 userDTO 中的属性类型不全是 String 类型,其中的 id 就为 Long 类型,显然这样直接转换是不可取的
//4.1随机生成 token,作为登录令牌
String token = UUID.randomUUID().toString(true); //这里使用 hutool 来生成不带有 "-" 中划线的显示格式,作为 key
/4.2将 user 对象作为 hash 结构进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //这里进行类型的转换
Map map = BeanUtil.beanToMap(userDTO); //将 userDTO 对象转换为 hash 类型,作为 value
//4.3将信息存储到 redis 中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//4.4这里进行设置 token 的有效期
stringRedisTemplate.expire(token,CACHE_SHOP_TTL,TimeUnit.MINUTES); //这里将对应的 token 值设置 30 min 有效期
解决方法如下:
这里使用 CopyOptions 来解决此问题 ,相关用法请参考 https://blog.csdn.net/moshowgame/article/details/82826535
Map map = BeanUtil.beanToMap(userDTO,new HashMap<>(), //将 userDTO 对象转换为 hash 类型,作为 value
CopyOptions.create()
.setIgnoreNullValue(true) //忽略null值/只拷贝非null属性
.setFieldValueEditor((fieldName,fieldValue)->
fieldValue.toString())); //这里是字段值的修改器, 将字段类型转换为 String 类型
完整代码:
@Resource
private UserServiceImpl userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 这里是发送手机验证码的功能
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)) {
//1.1.若不正确,则提示信息
return Result.fail("手机号验证有误,请重新输入!");
}
//2.正确,则生成对应手机号的验证码
String code = RandomUtil.randomNumbers(6); //表示随机生成六位验证码
//TODO 2.1将生成的验证码保存到 redis 中,并设置验证码有效期
stringRedisTemplate.opsForValue()
.set(LOGIN_CODE_KEY +phone,code,RedisConstants.LOGIN_CODE_TTL
, TimeUnit.MINUTES); //这里使用 String 类型进行存储
// //2.1将生成的验证码保存到 session 中
// session.setAttribute("code",code);
//3.发送验证码
log.info("发送短信验证码成功!您的验证码为:{}",code);
return Result.ok();
}
/**
* 这里是登录的功能,并且返回 token 以用来验证当前用户是否存在 (将该 token 返回给前端)
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.由于不同的请求,这里需要重新校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//1.1若不正确,则提示信息
return Result.fail("手机号验证失败,请输入正确的格式!");
}
// //2.校验验证码(session 形式)
String code = loginForm.getCode();
// if(!code.equals(session.getAttribute("code").toString())||session.getAttribute("code").toString()==null){
// //2.1若不正确,则提示信息
// return Result.fail("验证码有误,请重新输入!");
// }
//TODO 2.校验验证码 (redis形式)
String resCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
if(resCode==null||!resCode.equals(code)){
//2.1若不正确,则提示信息
return Result.fail("验证码有误,请重新输入!");
}
//3.若一致,则根据手机号查询对应的用户是否存在
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
if(user==null){
//3.1若不存在,则自动创建一个新用户到数据库
user = createUserByPhone(phone);
}
// //4.将用户信息保存到 session 中
// session.setAttribute("user",user);
//TODO 4.若存在,则将用户信息保存到 redis 中,并设置有效期,避免恶意刷满数据
//4.1随机生成 token,作为登录令牌
String token = UUID.randomUUID().toString(true); //这里使用 hutool 来生成不带有 "-" 中划线的显示格式,作为 key
//4.2将 user 对象作为 hash 结构进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //这里进行类型的转换
Map map = BeanUtil.beanToMap(userDTO,new HashMap<>(), //将 userDTO 对象转换为 hash 类型,作为 value
CopyOptions.create()
.setIgnoreNullValue(true) //忽略 null 值,只传入非 null 值
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString())); //这里将字段类型全部转换为 String 类型
//4.3将信息存储到 redis 中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//4.4这里进行设置 token 的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,CACHE_SHOP_TTL,TimeUnit.MINUTES); //这里将对应的 token 值设置 30 min 有效期
return Result.ok(token);
}
这里使用 Interceptor 拦截器进行判断
由于登录方法中已经将 token 返回给了前端,这里需要从前端获取 token
//TODO 1.获取 redis 中的 token 键对应的值
String token = request.getHeader("authorization"); //获取前端请求头中的 token
if(StrUtil.isBlank(token)){
//1.1若不存在,则进行拦截,并给出提示信息
response.setStatus(403);
return false;
}
从 redis 中获取对应 token 的用户信息,若存在,则进行类型转换并进行存储
//TODO 2.从 redis 中根据 token 获取用户的信息
Map
问题产生:
同时,这里需要注意的是之前设置了对应用户的 token 有效期,只有之前的设置是远远不够的,因为它表示无论用户是否正在页面操作,只要过了设置的有效期,对应的 token 都会被删除,这样是很不友好的;
需求:当用户在页面没有进行任何操作时,过了规定的时间就会被删除,反之,若用户在页面上存在相应的操作时,对应的 token 会被刷新过期时间
解决方法:
需要在拦截器中,进行校验当前用户是否之前登录,并且 token 还处于有效期之内时,进行当前 token 有效期的刷新操作
//TODO 4.进行刷新 token 的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token,CACHE_SHOP_TTL, TimeUnit.MINUTES);
return true; //放行
原因:由于之前的 Interceptor 拦截器所拦截的是当前登录状态的路径,但是无需登录就可以访问的路径未被拦截,这样就导致用户在登录后,访问未被拦截的路径时,其 token 有效期突然过期,所带来的不友好的影响
解决方法:
使用两个拦截器,一个是拦截所有的路径;一个是拦截需要登录的路径,并且查看当前线程的用户是否存在来判断是否进行 放行/拦截
这里是拦截一切路径的拦截器:
package com.hmdp.MyInterceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
/**
* 这里进行拦截一切请求路径
*/
public class EveryInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate; //由于EveryInterceptor不属于 Spring 容器管理,所以 redis 类不能直接注入
public EveryInterceptor(StringRedisTemplate stringRedisTemplate) { //这里在 MvcConfig 类中进行 redis 对象注入,反向注入容器
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//TODO 1.获取 redis 中的 token 键对应的值
String token = request.getHeader("authorization"); //获取前端请求头中的 token
if(StrUtil.isBlank(token)){
return true; //这里进行放行不需要登录就可以访问的路径
}
//TODO 2.从 redis 中根据 token 获取用户的信息
Map
这里是进行拦截需要登录的路径的拦截器:
package com.hmdp.MyInterceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
* 这里是登录拦截器配置类,即拦截需要登录才能访问的路径
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.判断当前线程中是否存在用户信息
UserDTO user = UserHolder.getUser();
if(user==null){
response.setStatus(401); //设置状态码
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser(); //将用户信息从当前线程中移除
}
}
在拦截器配置类中,用 order 进行配置对应的拦截器的执行路径以及执行顺序:
package com.hmdp.config;
import com.hmdp.MyInterceptor.EveryInterceptor;
import com.hmdp.MyInterceptor.LoginInterceptor;
import org.springframework.context.annotation.Bean;
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) {
//1.这里是拦截一切路径的拦截器
registry.addInterceptor(new EveryInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(-1);
//2.这里是进行拦截需要登录才能访问的拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns( //这里进行配置不需要进行拦截的路径
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}