Redis学习Day3——项目工程开发

扩展阅读推荐:

黑马程序员Redis入门到实战教程_哔哩哔哩_bilibili

使用git命令行将本地仓库代码上传到gitee/github远程仓库-CSDN博客

一、项目介绍及其初始化

学习Redis的过程,我们还将遇到各种实际问题,例如缓存击穿、雪崩、热Key等问题,只有在实际的项目实践中解决这些问题,才能更好的掌握和理解Redis的企业开发思维。

以下是本次【黑马点评】项目的主要内容:
本项目的功能概述 本项目的搭建结构

1.1 数据库连接配置

在yml配置文件中,配置好自己的数据库连接信息


数据库表结构

1.2 工程启动演示

想要成功启动该项目,需要以下步骤:

  1. 打开VM虚拟机,激活Linux系统中事先配置好的Redis数据库

  2. 启动nignx服务器(注意nignx服务器必须放在无中文的目录下)

  3. 启动后端程序(观察端口,访问初始工程)

  4. 访问请求地址,验证工程启动正确http://localhost:8081/shop-type/list
    前端网页端口:8080 后端数据端口:8081

二、短信登录功能实现

2.1 基于传统Session实现的短信登录及其校验

2.1.1 基于Session登录校验的流程设计

2.1.2 实现短信验证码发送功能

|------|------------|
| 请求接口 | /user/code |
| 请求类型 | post |
| 请求参数 | phone |
| 返回值 | 无 |

java 复制代码
    /**
     * 发送手机验证码
     */
    @PostMapping("/code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        log.info("发送验证码, 手机号:{}", phone);
        return userService.sendCode(phone, session);
    }

    /**
     * 发送验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1. 校验手机号码
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号码格式错误!");
        }
        // 2. 生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 3. 将验证码保存到Session中
        session.setAttribute("code", code);
        //TODO 4. 调用阿里云 将短信信息发送到指定手机
        log.info("发送短信验证码成功,验证码:{}", code);
        return Result.ok();
    }

2.1.3 实现登录、注册功能

|------|----------------------------------------|
| 请求接口 | /user/login |
| 请求类型 | post |
| 请求参数 | LoginForm---> phone,code,[password] |
| 返回值 | 无 |

java 复制代码
    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        log.info("用户登录, 参数:{}", loginForm);
        return userService.login(loginForm, session);
    }


/**
     * 登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @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)){
            return Result.fail("验证码错误!");
        }
        // 3. 根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();
        //    if 0 :创建新用户,保存数据库,将用户信息存储到Session
        //
        if(user == null){
            user = createUserWithPhone(phone);
        }
        //else: 登录成功,将用户信息存储到Session
        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
        return Result.ok();
    }


/**
     * 根据手机号创建用户
     * @param phone
     * @return
     */
    private User createUserWithPhone(String phone) {
        //创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        //保存用户
        save(user);
        // 返回
        return user;
    }

2.1.4 实现登录状态校验拦截器

由于日后项目功能会越来越多,需要登录才能进行访问的界面也会越来越多,我们必须想办法将登录状态校验抽离出来形成一个前置校验的条件,再放行到后续逻辑。

1. 封装TreadLocal工具类

将用户信息保存到 TreadLocal中 并封装TreadLocal工具类用于 保存用户、获取用户、移除用户

在 urils / UserHolder

java 复制代码
/**
 * TreadLocal工具类
 */
public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
    // 保存用户
    public static void saveUser(UserDTO user){
        tl.set(user);
    }
    // 获取ThreadLocal中的用户
    public static UserDTO getUser(){
        return tl.get();
    }
    // 清空ThreadLocal
    public static void removeUser(){
        tl.remove();
    }
}
2. 创建登录拦截器

在 urils / LoginInterceptor

java 复制代码
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){
            response.setStatus(401);
            return false;
        }
        // 4. 如果存在,用户信息保存到 ThreadLocal 并放行
        UserHolder.saveUser((UserDTO) user);
        return true;
    }

    /**
     * 后置拦截器(移除用户)
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}
3. 添加配置,生效拦截器,并配置放行路径

在 config/ MvcConfig

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    /**
     * 添加拦截器
     * @param registry
     */
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/host",
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**"
                );
    }
}

2.1.5 实现获取用户请求

前端点击我的,发送请求到后端,获取当前登录状态,方能进入个人中心

java 复制代码
    /**
     * 获取当前登录的用户
     * @return
     */
    @GetMapping("/me")
    public Result me(){
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

2.1.6 (附加)用户信息脱敏处理

为防止出现以下这种情况(将用户隐私信息暴露过多),我们采用UserDTO对象对用户信息脱敏处理:

java 复制代码
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

并借助拷贝工具 进行对象拷贝

java 复制代码
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

2.2 传统Session在集群环境下的弊端

Session共享问题

多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

解决策略

1. 让Session可以共享

Tomcat提供了Session拷贝功能,但是这会增加服务器的额外内存开销,并且带来数据一致性问题

2. 【推荐】使用Redis进行替代

数据共享、内存存储(快)、key-value结构

2.3 基于Redis实现短信登录功能

2.3.1 基于Redis实现短信登录流程设计

发送短信验证码逻辑 校验登录状态逻辑

对于验证码 ,使用 手机号码作为KEY,确保了正确的手机对应着正确的短信验证码。

对于用户信息唯一标识使用 UUID生成的Token作为 KEY,而不使用手机号码,从而提高了用户数据安全性。

2.3.2 修改发送短信验证码功能

只需要在Session的基础上,将第三步保存到Redis中

格式:

|---------------------|---------|------|
| key | value | TTL |
| login:code:[手机号码] | [验证码] | 120S |

java 复制代码
//        // 3. 将验证码保存到Session中
//        session.setAttribute("code", code);

        // 3. 将验证码保存到Redis中
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);

2.3.3 修改登录、注册功能

  1. 手机号校验

2. 从Redis中取出验证码进行校验

3.查询用户信息

4. 将用户信息存储到Redis ---> 需要以Hash结构进行存储 ----> 需要将user对象转成 Map对象

5. 将token返回给客户端 ,充当Session的标识作用

java 复制代码
/**
     * 登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1. 校验手机号
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号码格式错误!");
        }
        // 2. 校验验证码 REDIS
//        Object cacheCode = session.getAttribute("code");
        // 2.1 从Redis中获取验证码
        String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        // 2.2 校验验证码
        String code = loginForm.getCode();
        if(redisCode==null || !redisCode.equals(code)){
            return Result.fail("验证码错误!");
        }
        // 3. 根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();
        //    if 0 :创建新用户,保存数据库,将用户信息存储到Session
        if(user == null){
            user = createUserWithPhone(phone);
        }
//        //else: 登录成功,将用户信息存储到Session
//        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

        // 4. 将用户信息存储到Redis中
         // 1. 随机生成token,作为登录令牌 ---> UUID导入工具包中的方法,不要导入java自带的
        String token = UUID.randomUUID().toString(true);
         // 2. 以hash结构进行存储
        UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
        //TODO 这里报错了,因为UserDTO中有个id属性,不是字符串,在Redis序列化下报错
//        Map<String,Object> userMap = BeanUtil.beanToMap(userDTO);
        Map<String,Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName,fieldValue)-> fieldValue.toString()));
        // 3. 存储到Redis中
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token,userMap);

        // 给token设置有效期
        // 超过30分钟不访问任何界面就会剔除,所以还需要设置在访问过程中不断更新token的有效期
        // 实现方式: 在登录拦截器中进行处理
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 5. 返回token到客户端,客户端保存到浏览器中
        return Result.ok(token);
    }

2.3.4 添加刷新Token拦截器逻辑 (只做判断,不做拦截)

首先,由于需要在自定义的拦截器中使用StringRedisTemplate对象,由于不是交由spring管理的,所以我们需要自己写构造函数进行导入。同时在MvcConfig中直接交给Spring管理

其次,这里选择了新建一个专门负责刷新Token的"拦截器",只做判断不做拦截。确保请求在经过登录校验拦截器之前,会统一先被该"拦截器"获取,并对Token进行判断,如果没有Token,则会被接下来的登录拦截器进行拦截

java 复制代码
package com.hmdp.config;

import com.hmdp.Interceptor.LoginInterceptor;
import com.hmdp.Interceptor.RefreshTokenInterceptor;
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;

    /**
     * 添加拦截器
     * @param registry
     */
    public void addInterceptors(InterceptorRegistry registry) {
        // 刷新token拦截器 全部拦截 只做判断
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/host",
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**"
                );

    }
}
java 复制代码
package com.hmdp.Interceptor;

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.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;

/**
 * Token缓存刷新拦截器 只会放行不会拦截
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    /**
     * 手动创建的对象,需要手动注入,所以需要构造方法
     * @param 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();
    }
}

2.3.5 【故障排查】关于一登陆后前端立马闪退回登录界面的问题

在跟着视频练习的过程中,在所以代码开发完成后,我发现了每当自己点击登录校验成功后,前端又会重复的闪退回登录界面。对此我进行了以下排除手段:

  1. 检查Redis是否 将 短信验证码及其用户token信息保存成功,有则证明这两个大环节没有问题

2. 猜测是拦截器问题:

一开始,我们模仿老师将刷新Token的逻辑一并写到了登录校验拦截器上。但是!登录校验拦截器在开发的过程中,有一些需要Token的接口并没有被拦截器拦截下来,导致前端认为该用户操作并未携带Token,从而误判为未登录状态,从而剔除该用户,强制跳转到登录界面。

为此,秉承单一职责原则,对于Token,我们需要新建一个将所有界面都"拦截"的拦截器,这样子可以保证在进入后续拦截器的请求,不会再有Token被误判的情况出现。

而且,也确保了不管用户进行了什么操作,Token都能刷新时长

(该故障经过拆分拦截器已正常解决,但是题主并没有深刻去揪到底是拿一些请求被误判了,这个故障原因也是我分析,如有高见请分享一下)

2.4 (TODO) 基于阿里云完善验证码功能

TODO

三、商户查询缓存系列功能实现

3.1 缓存的理解

我们的程序如果想要用户有一个比较良好的使用体验,在请求数据速度上必然要有所突出。因此我们一般会在项目中运用缓存策略,从而提高我们程序的响应速度。与此同时,添加缓存策略,数据一致性、缓存击穿问题、雪崩问题、维护成本提高等相继出翔。如何平衡好这种关系,成为了我们学习Redis的重要所在。

3.2 查询商户店铺---添加Redis缓存

3.2.1 添加缓存逻辑理解

由于Redis访问快的优点,我们在客户端与数据库之间添加一层 Redis数据层。用户发送的请求首先打到Redis进行查询,

  • 如果在Redis上查询命中,则直接返回给用户,从而减轻了底层数据库服务器数据压力。

  • 如果没能命中,则请求打到数据库进行查询,查询未命中则说明此次请求为 错误请求

  • 数据库命中的话,就将数据写入Redis中 ,接着返回给用户。
    缓存作用模型

3.2.2 添加缓存逻辑实现

java 复制代码
    /**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }


 /**
     * 根据id查询店铺(添加Redis缓存版)
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        //1. 根据id到Redis中查询用户信息
        //2. Redis命中 ----------------> 返回商户信息 -------> 结束
        //3. Redis未命中 查询数据库
        //4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
        //5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
        String stopJson =  stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(stopJson)){
            // 存在直接返回
            Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
            return Result.ok(shop);
        }
//        query().eq("shop_id",id);
        // 查询数据库
        Shop shop = getById(id);
        if(shop == null){
            Result.fail("店铺不存在");
        }
        stringRedisTemplate.opsForValue().set(
                RedisConstants.CACHE_SHOP_KEY + id,
                  JSONUtil.toJsonStr(shop),
                  RedisConstants.CACHE_SHOP_TTL,
                  TimeUnit.MINUTES);
        return Result.ok(shop);
    }

3.2.3 缓存功能效果展示

第一次查询商户店铺,未缓存Redis,后台查询数据库

第二次再次查询商户店铺,已缓存Redis,后台没有查询店铺的数据库语句

3.3(课后作业)查询商户分类列表---添加Redis缓存

3.3.1 使用String存储类型实现

java 复制代码
    /**
     * 查询店铺类型 (添加Redis版)
     * @return
     */
    @GetMapping("list")
    public Result queryTypeList() {
//        List<ShopType> typeList = typeService
//                .query().orderByAsc("sort").list();
        return typeService.queryTypeList();
    }


/**
     * 查询店铺类型列表(添加Redis版)
     * String 实现版
     * @return
     */
    @Override
    public Result queryTypeList() {
        // 1. 在Redis中查询店铺类型列表
        // 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
        // 3. Redis未命中, 查询数据库
        // 4. 数据库未命中 -------> 返回报错信息 --------> 结束
        // 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
        String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
        String shopTypeJSON = stringRedisTemplate.opsForValue().get(Key);
        // 将字符串转换为对象
        List<ShopType> shopTypeList = null;
        if(StrUtil.isNotBlank(shopTypeJSON)){
            shopTypeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
            return Result.ok(shopTypeList); // 返回店铺类型数据
        }
        // 查询数据库
        shopTypeList = query().orderByAsc("sort").list();
        // 将对象转换为字符串
        shopTypeJSON = JSONUtil.toJsonStr(shopTypeList);
        // 将数据存入Redis
        stringRedisTemplate.opsForValue().set(Key, shopTypeJSON);
        return Result.ok(shopTypeList);
    }

3.3.2 使用List存储类型实现

java 复制代码
/**
     * 查询店铺类型列表(添加Redis版)
     * List 实现版
     * @return
     */
    @Override
    public Result queryTypeList() {
        // 1. 在Redis中查询店铺类型列表
        // 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
        // 3. Redis未命中, 查询数据库
        // 4. 数据库未命中 -------> 返回报错信息 --------> 结束
        // 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
        String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
        // 获取列表中所有元素(字符串格式)
        List<String> shopTypeJSON = stringRedisTemplate.opsForList().range(Key, 0, -1);   // 获取列表中所有元素

        if(shopTypeJSON != null && !shopTypeJSON.isEmpty()){
            // Redis中存在数据,需要将所有的Value转换成 ShopType对象
            // 将字符串转换为对象
            List<ShopType> shopTypeList = new ArrayList<>();
            for(String str : shopTypeJSON){
                shopTypeList.add(JSONUtil.toBean(str, ShopType.class));
            }
            return Result.ok(shopTypeList); // 返回店铺类型数据
        }
        // 查询数据库
        List<ShopType> shopTypeList = query().orderByAsc("sort").list();
        if(shopTypeList == null || shopTypeList.isEmpty()){
            return Result.fail("店铺类型不存在");
        }
        // 将对象转换为字符串(每一项都是)
        for (ShopType shopType : shopTypeList) {
            stringRedisTemplate.opsForList().rightPushAll(Key, JSONUtil.toJsonStr(shopType));
        }
        // 设置过期时间
        stringRedisTemplate.expire(Key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shopTypeList);
    }

3.3.3 缓存功能效果展示

分类列表数据成功存储到Redis中 查询效率从1秒提升到45毫秒

3.4 (TODO)缓存更新策略---实现数据一致性

相关推荐
码农郁郁久居人下16 分钟前
Redis的配置与优化
数据库·redis·缓存
架构文摘JGWZ19 分钟前
Java 23 的12 个新特性!!
java·开发语言·学习
小齿轮lsl24 分钟前
PFC理论基础与Matlab仿真模型学习笔记(1)--PFC电路概述
笔记·学习·matlab
Aic山鱼1 小时前
【如何高效学习数据结构:构建编程的坚实基石】
数据结构·学习·算法
qq11561487071 小时前
Java学习第八天
学习
天玑y1 小时前
算法设计与分析(背包问题
c++·经验分享·笔记·学习·算法·leetcode·蓝桥杯
MuseLss1 小时前
Mycat搭建分库分表
数据库·mycat
2301_789985941 小时前
Java语言程序设计基础篇_编程练习题*18.29(某个目录下的文件数目)
java·开发语言·学习
橄榄熊1 小时前
Windows电脑A远程连接电脑B
学习·kind
Hsu_kk2 小时前
Redis 主从复制配置教程
数据库·redis·缓存