课程介绍
heima 点评Redis —— 项目
1️⃣ 短信登录 —— Redis 的共享 session 应用
2️⃣ 商户查询缓存 —— 企业的缓存使用技巧| 缓存雪崩、穿透等问题解决
3️⃣ 达人探店 —— 基于 List 点赞链表|基于 SortedSet 的点赞排行榜
4️⃣ 优惠券秒杀 —— Redis 的计数器| Lua 脚本 Redis | 分布式锁 | Redis 的三种消息队列 ⭐
5️⃣ 好友关注 —— 基于 Set 集合的关注|取关|共同关注|消息推送的功能
6️⃣ 附近的商户 —— Redis 的 GeoHash 的应用
7️⃣ 用户签到 —— Redis 的 BitMap 数据统计功能
8️⃣ UV 统计 —— Redis 的 HyperLogLog 的统计功能
步骤一:首先导入 SQL 文件到数据库当中
2022版Redis入门到精通_免费高速下载|百度网盘-分享无限制 (baidu.com)
其中表的数据结构说明
1️⃣ tb_user: 用户表
2️⃣ tb_user_info : 用户详情表
3️⃣ tb_shop : 商品信息表
4️⃣ tb_shop_type : 商户类型表
5️⃣ tb_blog : 用户日记表(达人探店日记)
6️⃣ tb_follow : 用户关注表
7️⃣ tb_voucher : 优惠券表
8️⃣ tb_voucher_order : 优惠券的订单表
注意点:MySQL 的版本采用 5.7 及其版本之上
项目架构
步骤二:将写好的半成品源码导入到 IDEA 中并测试
步骤三:改写 application.yaml 文件 改写成自己所对应的数据库
server:
port: 8081
spring:
application:
name: hmdp
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.56.1:3306/hmdp?useSSL=false&serverTimezone=UTC
username: root
password: root
redis:
host: 192.168.56.103
port: 6379
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s
jackson:
default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
level:
com.hmdp: debug
测试连接到数据库
步骤四:启动HmDianPingApplication并用浏览器进行访问测试
http://localhost:8081/shop-type/list
测试导入成功~
步骤一:在 Nginx 所在目录下打开一个 CMD 窗口,输入命令:
start nginx.exe
步骤二:打开 chrome 浏览器,在空白页面点击鼠标右键,选择检查,即可打开开发者工具——并开启手机模式
http://localhost:8080/
如果出现了能正常打开 前端项目 但是对应的图片以及数据并没有显示,则说明你的后端项目没有开启,前端无法通过请求刷新渲染数据到前端,所以务必保证后端应用程序已经启动。
如图所示大功告成啦~
首先用户会提交个人的手机号请求发送验证码
服务端会对手机号进行校验:
拿到生成好的验证码会将其保存到 Session 当中,随后执行 发送验证码 的业务服务
结束
步骤一:到对应的 UserController 层编写对应接口、并对对应接口服务的具体实现impl
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone , session);
}
IUserService
Result sendCode(String phone, HttpSession session);
UserServiceImpl
/**
* 功能描述
* 获取得到用户发送手机发送短信的获取验证码的请求
* @date 2022/7/4
* @author Alascanfu
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 判断当前用户提交的 手机是否符合正确的 格式
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果格式匹配失败 则放回错误信息给前端
return Result.fail("对不起,您输入的手机号格式有问题,请重试!");
}
// 如果格式匹配成功 则生成一串随机的验证码
String code = RandomUtil.randomNumbers(6);
// 将生成好的随机验证码 保存到 session 当中
session.setAttribute("code",code);
// 发送短信验证码给用户
log.info("验证码发送成功 , code => {}",code);
// 发送验证码成功 返回成功响应
return Result.ok();
}
步骤三:启动程序并测试是否能在后台查看到我们对应的验证码生成
首先用户会将自身收到的验证码和手机号以请求的方式提交
服务端对用户提交的校验码先进行校验:
根据手机号到数据库中去查找
步骤一:到 UserController 中编写好对应的登录服务接口
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return userService.login(loginForm , session);
}
步骤二:编写对应的服务接口以及服务接口的具体实现
Result login(LoginFormDTO loginForm, HttpSession session);
/**
* 功能描述
* 登录功能的具体实现类 登录参数,包含手机号、验证码;或者手机号、密码
* @date 2022/7/4
* @author Alascanfu
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 首先拿到用户请求发送过来的 验证码 进行和 session 中对应的验证码是否匹配
// 如果不匹配 则直接返回给前端 说明用户验证码 不正确
String cacheCode = (String) session.getAttribute(SystemConstants.SESSION_PHONE_CODE);
String cachePhone = (String) session.getAttribute(SystemConstants.SESSION_PHONE);
String code = loginForm.getCode();
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail("对不起,您输入的手机号格式有问题,请重试!");
}
if (!cachePhone.equals(phone)){
return Result.fail("验证码与手机号不匹配,请重试!");
}
if (cacheCode == null || !cacheCode.equals(code)){
return Result.fail("对不起,您输入的验证码有误,请重试!");
}
// 如果匹配则进行验证用户名输入的手机号是否是之前已经注册过的
User user = userMapper.selectUserByPhone(phone);
if (user == null){
// 反之如果没有注册过, 则快速帮用户注册一个 用户账户 填充一些默认信息
user = createUserWithPhone(phone);
}
// 将快速注册好的用户|对应登录成功的用户, 加入到当前 session 当中
session.setAttribute(SystemConstants.SESSION_USER,user);
// 最后登录成功
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
userMapper.insert(user);
return user;
}
步骤三:启动测试
登陆成功后会自动跳转会首页
数据库中也有对应的数据~
步骤一:编写拦截器、用于校验 Session 中的 user 信息
LoginInterceptor
/***
* @author: Alascanfu
* @date : Created in 2022/7/4 20:21
* @description: LoginInterceptor
* @modified By: Alascanfu
**/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 先通过 request 获取 session
HttpSession session = request.getSession();
// 然后通过 session 尝试获取 用户 信息
User user = (User) session.getAttribute(SystemConstants.SESSION_USER);
if (user == null){
// 如果 user 不存在 则返回错误信息 对请求拦截 设置返回的状态码为 未授权
response.setStatus(401);
return false ;
}
// 如果存在 user 将其保存到 threadLocal 当中
UserDTO userDTO = userToUserDTO(user);
UserHolder.saveUser(userDTO);
return true;
}
/** User 类型 转换为 UserDTO */
private UserDTO userToUserDTO(User user) {
UserDTO userDTO = new UserDTO();
userDTO.setId(user.getId());
userDTO.setIcon(user.getIcon());
userDTO.setNickName(user.getNickName());
return userDTO;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当请求完成之后 清除掉对应 threadLoacl 中的 user 数据即可
UserHolder.removeUser();
}
}
步骤二:创建 WebMvcConfig 类 客制化拦截器配置
/***
* @author: Alascanfu
* @date : Created in 2022/7/4 20:36
* @description: Web MVC configuration
* @modified By: Alascanfu
**/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor ;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
步骤三:改写 UserController 中的 /user/me 请求接口
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
步骤四:进行测试
按照用户登录的步骤进行登录,查看当前用户数据
分布式之session共享问题 4种解决方案及spring session的使用
需要查看前端代码逻辑 来了解后端是需要获取请求头中的哪个信息来获取 token 信息数据
Axios前后端异步请求库 后端人员这一篇就够了
// request拦截器,将用户token放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
config => {
if(token) config.headers['authorization'] = token
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)
很清楚的可以看到
let token = sessionStorage.getItem("token");
从浏览器内存中获取 token 数值
axios.interceptors.request.use
会在每次发送请求时经过该 axios 拦截器。
将请求头中的 authorization 携带 token 数值信息。
步骤一:改写 原 Session 存储验证码 => Redis 存储验证码
1️⃣ 获取用户提交的请求数据
i. 验证手机号是否格式正确
1.不正确 => 返回 错误信息
2.正确 => 进行下步逻辑判断
2️⃣ 手机号码格式正确则生成一串随机的验证码
3️⃣ 将生成好的 验证码 保存到 redis 当中
i. key => 业务简写:业务唯一属性: + phone
ii.value => 验证码
4️⃣ 返回请求成功信息
/**
* 功能描述
* 获取得到用户发送手机发送短信的获取验证码的请求
* @date 2022/7/4
* @author Alascanfu
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 判断当前用户提交的 手机是否符合正确的 格式
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果格式匹配失败 则放回错误信息给前端
return Result.fail("对不起,您输入的手机号格式有问题,请重试!");
}
// 如果格式匹配成功 则生成一串随机的验证码
String code = RandomUtil.randomNumbers(6);
// 将生成好的随机验证码 保存到 Redis 当中
// k : login:code:18584100561 v : code expireTime 2 min
stringRedisTemplate.opsForValue()
.set(RedisConstants.LOGIN_CODE_KEY + phone ,
code , RedisConstants.LOGIN_CODE_TTL , TimeUnit.MINUTES);
// 发送短信验证码给用户
log.info("验证码发送成功 , code => {}",code);
// 发送验证码成功 返回成功响应
return Result.ok();
}
步骤二:改写登录具体逻辑
1️⃣ 获取用户提交表单的信息 LoginForm
i. 匹配当前提交的手机号码格式是否正确 ? “继续下步逻辑” : “返回错误信息”
ii. 通过用户提交的 手机号码 与 Redis常量 进行拼接 获取得到 key 进行查找
1. 判断当前获取得到的value 是否不为空 ? “继续下步逻辑” : “返回错误信息”
2️⃣ 通过 用户 提交的手机号码 到 数据库中 查找对应数据
i. 如果用户不存在 则 进行快速创建一个用户
ii. 存在 执行下步逻辑
3️⃣ 随机生成 token 作为登录令牌 并将其作为 key 保存到 redis 当中
i. 将之前获取得到的 User 对象转换为不包含敏感数据的 UserDTO 然后再转换为 HashMap 进行存储
ii. 存储 以 Redis常量 + token 为 Key ,以 UserMap 作为数据存储
4️⃣ 防止大量 k-v 长时间占用内存空间 所以需要设置 token 有效期
5️⃣ 返回 token
/**
* 功能描述
* 登录功能的具体实现类 登录参数,包含手机号、验证码;或者手机号、密码
* @date 2022/7/4
* @author Alascanfu
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 首先拿到用户请求发送过来的 验证码 进行和 session 中对应的验证码是否匹配
String code = loginForm.getCode();
String phone = loginForm.getPhone();
// TODO 从 Redis 中获取验证码 并且进行校验
String cacheCode = stringRedisTemplate.opsForValue()
.get(RedisConstants.LOGIN_CODE_KEY + phone);
if (RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail("对不起,您输入的手机号格式有问题,请重试!");
}
if (cacheCode == null || !cacheCode.equals(code)){
return Result.fail("对不起,您输入的验证码有误,请重试!");
}
// 如果匹配则进行验证用户名输入的手机号是否是之前已经注册过的
User user = userMapper.selectUserByPhone(phone);
if (user == null){
// 反之如果没有注册过, 则快速帮用户注册一个 用户账户 填充一些默认信息
user = createUserWithPhone(phone);
}
// 将快速注册好的用户|对应登录成功的用户, 加入到当前 session 当中
// session.setAttribute(SystemConstants.SESSION_USER,user);
// TODO 将用户信息 保存到 Redis 中 1=> 随机生成 token
// 2=>将User对象转换为 Hash 进行存储
// 3=>存储数据
// 4=>设置 token 有效期
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL ,TimeUnit.SECONDS);
// 将 token 返回给浏览器
return Result.ok(token);
}
❓ 因为我们想要保证用户每次操作之后都会重新更新 token 凭证的时间,为避免出现定时剔除token凭证所以我们需要在拦截器中追加对应的逻辑,保证用户在每次请求之后都会更新 token凭证的存活时间
步骤三:改写登录校验逻辑
1️⃣ 首先从 请求头中获取用户携带的 token 数据信息
2️⃣ 通过获取得到的 token 信息与 Redis常量 拼接 从 Redis 中获取对应的用户数据
3️⃣ 将从 Redis 中获取得到的 Map 数据转换为 UserDTO 类型数据
4️⃣ 将 UserDTO 数据 添加到当前线程局部变量当中
5️⃣ 刷新 对应用户的 token 凭证过期时间
6️⃣ 放行
/***
* @author: Alascanfu
* @date : Created in 2022/7/4 20:21
* @description: LoginInterceptor
* @modified By: Alascanfu
**/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate ;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 先通过 request 获取 session
// 1.获取请求头中的 token
HttpSession session = request.getSession();
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
response.setStatus(401);
return false ;
}
// 然后通过 session 尝试获取 用户 信息
// 2.通过 token 从 Redis 中获取用户信息
// User user = (User) session.getAttribute(SystemConstants.SESSION_USER);
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
if (userMap.isEmpty()){
// 如果 user 不存在 则返回错误信息 对请求拦截 设置返回的状态码为 未授权
response.setStatus(401);
return false ;
}
// 3.将 查询得到的 Hash 数据类型转换为 UserDTO 对象
// 如果存在 user 将其保存到 threadLocal 当中
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
// 4. 保存用户信息到 ThreadLocal 当中
UserHolder.saveUser(userDTO);
// 7. 刷新 token 有效期
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token , RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8. 放行
return true;
}
/** User 类型 转换为 UserDTO */
private UserDTO userToUserDTO(User user) {
UserDTO userDTO = new UserDTO();
userDTO.setId(user.getId());
userDTO.setIcon(user.getIcon());
userDTO.setNickName(user.getNickName());
return userDTO;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当请求完成之后 清除掉对应 threadLoacl 中的 user 数据即可
UserHolder.removeUser();
}
}
步骤四:进行对应的测试
启动好程序之后我们来到用户登录界面,进行用户对应的登录,然后查看对应的 Redis 库中是否已经 有了 数据。
java.lang.Long cannot be cast to java.lang.String
2022-07-05 00:36:29.592 ERROR 24500 — [nio-8081-exec-5] com.hmdp.config.WebExceptionAdvice : java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.String
java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.String
at org.springframework.data.redis.serializer.StringRedisSerializer.serialize(StringRedisSerializer.java:36) ~[spring-data-redis-2.3.9.RELEASE.jar:2.3.9.RELEASE]
=> 这里报出了 类型转化错误,主要是 StringRedisSerializer 这个序列化对象时导致的。
=> 根据提示找到对应出错的代码错误处
解决方案
改写对应的转换逻辑
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->{
return fieldValue.toString();
}));
步骤一:编写新的全局放行拦截器 RefreshTokenInterceptor
/***
* @author: Alascanfu
* @date : Created in 2022/7/5 1:07
* @description: RefreshTokenInterceptor
* @modified By: Alascanfu
**/
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
@Autowired
private 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. 查询对应的 Redis 用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token ;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
if (userMap.isEmpty()){
return true ;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 3. 保存到 ThreadLocal
UserHolder.saveUser(userDTO);
// 4. 刷新 token 有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);
// 5. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
步骤二:改写优化 LoginInterceptor
/***
* @author: Alascanfu
* @date : Created in 2022/7/4 20:21
* @description: LoginInterceptor
* @modified By: Alascanfu
**/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO 判断是否需要拦截 判断 ThreadLocal 是否存在用户
if (UserHolder.getUser() == null){
response.setStatus(401);
return false ;
}
// 有用户就放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当请求完成之后 清除掉对应 threadLoacl 中的 user 数据即可
UserHolder.removeUser();
}
}
步骤三:改写 WebMvc 配置类进行对应的拦截器配置
/***
* @author: Alascanfu
* @date : Created in 2022/7/4 20:36
* @description: Web MVC configuration
* @modified By: Alascanfu
**/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor ;
@Autowired
private RefreshTokenInterceptor refreshTokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(refreshTokenInterceptor)
.addPathPatterns("/**");
registry.addInterceptor(loginInterceptor)
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
步骤四:启动应用程序并打开浏览器进行测试
刷新回到首页页面再来看是否刷新了 token 的凭证时间
一文搞懂Cookie + Session,Redis + Token,JWT 三者的区别
分布式之session共享问题 4种解决方案及spring session的使用