一、导入黑马点评项目
1、代码下载
视频资源链接:P25 实战篇-02.短信登录-导入黑马点评项目
代码可以直接去黑马微信公众号上搜索,或者从下面的网盘链接中下载:链接:
https://pan.baidu.com/s/1aWhWVn2Ai7AeuDm0KftSqw
提取码: snuw
2、数据库
2.1 SQL 脚本执行
上面的链接中有一个 hmdp.sql 文件,注意在执行该 SQL 脚本的时候不要批量执行,会报错,最好是一条一条执行。
2.2 数据库表介绍
- tb_user:用户表
- tb_user_info:用户详情表
- tb_shop:商户信息表
- tb_shop_type:商户类型表
- tb_blog:用户日记表(达人探店日记)
- tb_follow:用户关注表
- tb_voucher:优惠券表
- tb_voucher_order:优惠券的订单表
3、前端 Nginx 搭建
3.1 Mac OS 安装 Nginx
我自己用的是 Mac 电脑,原本是在 Mac 上安装了 Nginx,安装教程可以参考这位大佬写的 mac安装nginx
安装完成后,执行brew info nginx
注意这个服务目录,就是用来放置黑马点评项目的前端项目的。
配置目录中的nginx.conf,要替换成黑马点评前端项目中的 nginx.conf,当然如果不嫌麻烦或者自己对 Nginx 比较了解,也可以自己配置。
黑马点评前端项目 nginx.conf 的位置如下图所示:nginx-1.18.0/conf/nginx.conf
替换下面的文件,一般替换前最好是将源文件做个备份,此处可以不用备份,官方已经给我们备份好了:nginx.conf.default
3.2 虚拟机安装 Nginx
但是吧,这样有个问题,就是我每次关机重启后,nginx 都需要重新启动一遍,所以,我就又在虚拟机上安装了一下 Nginx,安装教程可以参考 centOS7安装nginx及nginx配置。当然也可以上网搜一搜如何将 Nginx 设置为开机自启动,这样这一步也就不用了(我已经尝试了各种方式,均以失败告终,我放弃了)。
安装完成后,也需要将黑马点评前端项目放置到服务目录,将配置目录中的 nginx.conf 替换成黑马点评前端项目的 nginx.conf。
替换过程如下:
1、先将黑马点评前端项目压缩成 hmdp.tar.gz
我是直接放在了桌面,放在哪了,下面的目录就替换成哪
tar -zcvf hmdp.tar.gz [指定压缩文件存放位置] /Users/Mac用户名/Desktop/资料/nginx-1.18.0/html/hmdp
2、将压缩包上传至虚拟机
要注意一点,如果在上述命令中未指定压缩文件存放位置,那么文件压缩后存放的位置就是自己当前所在目录,由于我没指定,最终文件生成的位置,如下图所示,就是 scp 命令后面的那个地址。
知道文件压缩后在哪了,就将文件上传至虚拟机。通过 scp 命令,Mac 可以通过终端命令直接将文件上传至虚拟机(或者服务器)。
再来是配置文件的替换:
3、登录虚拟机,解压上传的压缩包
进入到压缩包所在的目录,执行解压命令:
cd /usr/local/nginx/html ## 进入到压缩包所在的目录
ll ## 查看当前目录下的所有文件
tar -zxvf hmdp.tar.gz ## 解压
不知道是不是我压缩的有问题,我的压缩文件将其父目录也压缩进去了
执行下面的命令:
mv hmdp /usr/local/nginx/html ## 将前端项目移动到/usr/local/nginx/html下
rm -rf Users ## 删除 Users 文件夹
3.3 修改 Nginx 的配置文件 nginx.conf
由于视频中 Nginx 和 Java 代码都是在同一个服务器上,所以 nginx.conf 中的服务器地址用的是 127.0.0.1,但是现在由于我将 Nginx 和 Java 代码分开来放了,就需要修改服务器地址了,将其修改成 Java 代码所部署的服务器地址,即 Mac 本身的 IP 地址。
3.4 启动 Nginx
cd /usr/local/nginx/sbin
./nginx
3.5 防火墙开启 8080 端口
firewall-cmd --permanent --add-port=8080/tcp ## 添加端口
service firewalld restart ## 重启防火墙
四、项目结构介绍
DTO(data transfer object):数据传输对象
是一种设计模式之间传输数据的软件应用系统,数据传输目标往往是数据访问对象从数据库中检索数据
数据传输对象与数据交互对象或数据访问对象之间的差异是一个以不具任何行为除了存储和检索的数据(访问和存取器)
简而言之,就是接口之间传递的数据封装
表里面有十几个字段:id,name,gender(M/F),age……
页面需要展示三个字段:name,gender(男/女),age
DTO由此产生,一是能提高数据传输的速度(减少了传输字段),二能隐藏后端表结构
具体可以查看这篇文章关于JAVA Bean实体类对象pojo,vo,po,dto,entity之间的区别,了解entity 和 DTO 之间的具体区别。
五、基础代码
基础代码不再过多介绍了
六、基于 session 实现短信登录
6.1 登录流程分析
6.2 实现发送验证码
UserController 类
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Resourceprivate IUserService userService;/*** 发送手机验证码*/@PostMapping("code")public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {// 发送短信验证码并保存验证码return userService.sendCode(phone, session);}
}
UserService 接口
public interface IUserService extends IService<User> {Result sendCode(String phone, HttpSession session);
}
UserServiceImpl 实现类
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result sendCode(String phone, HttpSession session) {// 1、校验手机号if (RegexUtils.isPhoneInvalid(phone)) {// 2、如果不符合,返回错误信息return Result.fail("手机号格式错误!");}// 3、符合,生成验证码String code = RandomUtil.randomNumbers(6);// 4、保存验证码到 sessionsession.setAttribute("code", code);// 5、发送验证码log.debug("发送短信验证码成功,验证码:{}", code);return Result.ok();}
}
6.3 实现短信验证码登录和注册功能
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1、校验手机号String phone = loginForm.getPhone();String code = loginForm.getCode();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2、校验验证码String cacheCode = (String) session.getAttribute("code");// 3、不一致,报错if(cacheCode == null || !cacheCode.equals(code)){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();}/*** 根据手机号创建用户* */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;}
}
此处登录后会跳转到主页,而不是个人信息页面,这个是因为前端页面在登录后将跳转页面写成了 index.html,手动改一下即可。
6.4 登录校验拦截器
登录方法虽然已经创建完成,但是通过实际验证,此时依然无法登录,会直接跳转回登录页面。打开 info.html 页面,我们来分析下:
可以看到在 script 代码块中,当 Vue 实例化时,调用了一个 created() 函数,该函数又调用了 queryUser() 方法。
来看下 created() 函数的作用:
vue.js中created方法是一个生命周期钩子函数,一个vue实例被生成后会调用这个函数。一个vue实例被生成后还要绑定到某个html元素上,之后还要进行编译,然后再插入到document中。每一个阶段都会有一个钩子函数,方便开发者在不同阶段处理不同逻辑。
原文Vue进阶(三十六):created() 详解
也就是说在 Vue 实例化后,其实就直接调用了 queryUser() 方法,该方法调用了后台接口 /user/me,如果该接口没有获取到用户信息,则直接跳转至登录页面。该接口我们目前并没有进行实现,所以也就获取不到用户信息。现在来思考下如何实现这个接口。首先,这个接口没有任何的入参,也就是说我们没法根据用户的 id 或者手机号去数据库中获取,那我们要如何获取当前用户的信息?我们在登录时,其实已经向 session 中保存了用户信息,当然可以从 session 中直接获取。我们现在考虑另一种实现方式。
项目中部分模块的部分页面在用户未登录的状态是可以进行浏览的,但是还有部分模块的部分页面是必须在登录后才可以查看,比如个人信息页面。我们不可能对所有的模块都单独添加判断登录状态逻辑,那可不可以将登录状态的判断逻辑抽取出来形成一个公共方法,所有页面在访问时都需要先进性判断登录状态,符合条件的则放行,不符合条件的则跳转至登录页面。有一种方式可以实现,就是拦截器。我们通过拦截器拦截用户的所有请求,判断用户是否已经登录,如果已经完成登录,则将用户信息放到 ThreadLocal 中,保证每个请求可以获取到专属于它自己的用户信息,保证不同请求以及不同用户之间不会相互干扰。当用户已经处于登录状态,在我们访问 /user/me 接口时,自然也要进行判断,然后获取用户信息时,则可以直接从 ThreadLocal 中获取,而不是从 session 中获取。
知识点补充:
Session 是如何知道当前登录用户是哪一个用户的?
HTTP 是无状态的协议。所谓无状态,是指当一个浏览器客户程序与服务器之间多次进行基于 HTTP 请求/响应模式的通信时,HTTP 协议本身没有提供服务器连续跟踪特定浏览器端状态的规范。即 HTTP 协议本身是不会记录当前登录用户的信息的,所以当大量的用户去访问同一个页面时是无法通过 HTTP 协议去判断每个请求是由哪一个用户发出的。但是,我们又必须要区分出是哪一用户操作的。在 Web 开发领域,会话机制是用于跟踪客户状态的普遍解决方案。会话是指在一段时间内,单个客户与 Web 应用的一连串相关的交互过程。在一个会话中,客户可能会多次请求访问 Web 应用的同一个网页,也有可能请求访问同一个 Web 应用中的多个网页。
HTTP 会话为跟踪客户状态提供了统一的解决方案,SessionID 就是 HTTP 请求中用于跟踪客户状态的额外数据。当浏览器第一次请求访问应用中的任意一个支持会话的网页时,Servlet 容器试图寻找 HTTP 请求中表示 Session ID 的 Cookie,由于还不存在这样的 Cookie,因此就认为一个新的会话开始了,于是创建一个 HttpSession 对象,为它分配唯一的 Session ID,然后把 Session ID 作为 Cookie 添加到 HTTP 响应结果中。浏览器接收到 HTTP 响应结果后,会把其中表示 Session ID 的 Cookie 保存在客户端。当浏览器继续访问应用中的任意一个支持会话的网页时,在本次的 HTTP 请求中会包含表示 Session ID 的 Cookie。Servlet 容器视图寻找 HTTP 请求中表示 Session ID 的 Cookie,由于此时能获得这样的 Cookie,因此认为本次请求已经处于一个会话中,Servlet 容器不再创建新的 HttpSession 对象,而是从 Cookie 中获取 Session ID,然后根据 SessionID 找到内存中对应的 HttpSession 对象。
总结一下:
Session 是记录在服务器端的,用于跟踪用户状态的数据,而 Cookie 则是保存在客户端浏览器的用来保存用户信息的数据,属于 Session 的一种实现方式。Cookie 会记录每一个 Session 的 Session ID,该 Session ID 是唯一的,服务器可以根据该 Session ID 拿到对应的 Session。
代码实现
LoginInterceptor
package com.hmdp.utils;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1、获取 sessionHttpSession session = request.getSession();// 2、通过 session 获取用户信息、Object user = session.getAttribute("user");// 3、判断用户是否存在if(user == null){// 不存在报401response.setStatus(401);return false;}// 存在,将其保存到 ThreadLocal 中UserHolder.saveUser((User) user);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户,避免内存泄露// 视图渲染完成后执行 afterCompletion 方法,也就是说一次请求获取一次用户信息,用完立即释放UserHolder.removeUser();}
}
UserHolder
package com.hmdp.utils;import com.hmdp.entity.User;public class UserHolder {private static final ThreadLocal<User> tl = new ThreadLocal<>();public static void saveUser(User user){tl.set(user);}public static User getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}
MvcConfig
拦截器创建完成后并没有生效,还需要进行配置,MvcConfig 主要就是来让拦截器生效。
SpringMVC 中的拦截器有三个抽象方法:
-
preHandle:控制器方法执行之前执行 predHandle(),其 boolean 类型的返回值表示是否拦截或放行,返回 true 为放行,即调用控制器方法;返回 false 表示拦截,即不调用控制器方法。
-
postHandle:控制器方法执行之后执行 postHandle()
-
afterCompletion:处理完视图和模型数据,渲染视图完毕之后执行 afterCompletion()
原文:Spring MVC 之拦截器
package com.hmdp.config;import com.hmdp.utils.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration
public class MvcConfig implements WebMvcConfigurer {// addInterceptors 添加拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/user/login","/user/code","/shop-type/**","/upload/**");}
}
UserController
package com.hmdp.controller;import com.hmdp.dto.Result;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@GetMapping("/me")public Result me(){// 获取当前登录的用户并返回UserDTO user = UserHolder.getUser();return Result.ok(user);}
}
6.4 隐藏用户敏感信息
/user/me 接口获取到的用户信息,其实是一个完整的用户信息,当中包含有一些敏感信息,以及一些我们用不到的信息,这样一方面会暴露用户的隐私,产生风险,比如密码;另外一方面多余的信息会给系统增加压力。有没有什么好的办法解决这个问题呢?使用 UserDTO,将一些不需要的信息排除,只保留 User 类中我们需要用到的用户信息。
UserDTO:
package com.hmdp.dto;import lombok.Data;@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
来跟 User 实体类做个对比
package com.hmdp.entity;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;import java.io.Serializable;
import java.time.LocalDateTime;@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_user")
public class User implements Serializable {private static final long serialVersionUID = 1L;/*** 主键*/@TableId(value = "id", type = IdType.AUTO)private Long id;/*** 手机号码*/private String phone;/*** 密码,加密存储*/private String password;/*** 昵称,默认是随机字符*/private String nickName;/*** 用户头像*/private String icon = "";/*** 创建时间*/private LocalDateTime createTime;/*** 更新时间*/private LocalDateTime updateTime;}
可以看到 UserDTO 中去除了大量的用不到以及部分敏感的信息。
LoginInterceptor 中将 User 替换成 UserDTO
package com.hmdp.utils;import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1、获取 sessionHttpSession session = request.getSession();// 2、通过 session 获取用户信息、Object user = session.getAttribute("user");// 3、判断用户是否存在if(user == null){// 不存在报401response.setStatus(401);return false;}// 存在,将其保存到 ThreadLocal 中UserHolder.saveUser((UserDTO) user);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
将 UserHolder 中的 User 替换成 UserDTO
package com.hmdp.utils;import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;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();}
}
将 /user/me 接口中的 User 替换成 UserDTO
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@GetMapping("/me")public Result me(){// 获取当前登录的用户并返回UserDTO user = UserHolder.getUser();return Result.ok(user);}
}
UserServiceImpl 中的 login 方法
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1、校验手机号String phone = loginForm.getPhone();String code = loginForm.getCode();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2、校验验证码String cacheCode = (String) session.getAttribute("code");// 3、不一致,报错if(cacheCode == null || !cacheCode.equals(code)){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();}
}
6.5 集群的 Session 共享问题
多台 Tomcat 之间并不共享 Session 存储空间,当请求切换到不同的 Tomcat 服务时就会产生数据丢失问题。
当我们搭建负载均衡集群时,请求会在每个 Tomcat 之间进行轮询,假设请求第一次被负载均衡到了 Tomcat1,相关的一些用户信息就会被保存到 Tomcat1 中,那么当用户第二次请求时,如果被负载均衡到了 Tomcat2 上,此时 Tomcat2 上并没有保存用户信息,这时就会产生问题。其实,Tomcat 之间可以实现 Session 共享,只是该种方案会有以下两个问题:
1、Tomcat 之间实现 Session 共享会进行数据拷贝,多份相同的数据浪费内存空间
2、数据拷贝需要一定的时间,会产生延时,如果正好在拷贝期间进行了请求,那么同样也会访问不到,产生问题。
Session 替代方案应该满足以下三个条件:
- 数据共享,无需进行数据拷贝
- 内存存储,因为 Session 是基于内存,效率高,那么替代方案也应当满足该条件
- key、value 结构,Session中的数据保存为 key、value 格式,那么替代方案也应当满足
Redis 可以满足上述要求。
6.6 基于 Redis 实现共享 Session 登录
6.6.1 分析
由 Session 改为 Redis 存储验证码,整体流程上没有太多变化。但是,由于 Redis 是以键值对存储的,其值存储有多种形式,那么当前情况下,哪种形式的值存储比较合适呢?由于我们需要存储的是一个 6 位数字类型的值,而 String 类型的数据结构就完全可以满足要求了。但是,键值该如何存储呢?原先 Session 存储我们以 “user" 作为 key,由于 Session 的特性(每个浏览器在发出请求时都有独立的 Session),虽然不同的用户有着相同的 key,但是不同 Session 之间相互不干扰,这就使得服务器端可以根据不同的 Session 识别出是哪个用户做出的操作。而 Redis 中的数据是共享的,如果再以 ”user“ 作为 key,不同的用户在向 Redis 中存储 value 时会产生覆盖问题,我们需要保证每个不同的手机号在做验证时保存的 key 都是不一样的,那我们就可以直接以手机号作为 key 来存储验证码,这样就可以保证每一个不同的手机号都有自己唯一的 key。这样还有一个好处就是,我们在做短信验证码登录时,手机号方便客户端携带,服务器端收到请求后,可以直接以手机号为 key 来读取验证码。
根据上面的流程图,我们在登录验证时,如果从数据库中查询到用户,还需要将用户的信息保存到 Redis 中,此时该以何种 Redis 数据结构来存储用户数据呢?来看下 String 类型和 Hash 类型的区别:
可以看出,Hash 结构胜出。
那么又该选用什么形式的 key 来存储用户数据呢?
在这我们选择随机的 token 作为 key 来存储用户数据。在短信验证码登录时,我们还需要将这个随机 token 返回给客户端,这是因为后期我们在访问各个页面时都是需要校验登录状态,来判断哪些页面用户可以在未登录状态下访问,哪些页面需要登录后才能访问。
来看下前端是如何存储 token 的。
当我们访问接口 /user/login 时,如果访问成功,会将 token 返回给前端登录页面,而前端则会将该 token 保存到 session 中(通过 sessionStorage.setItem 方法)。
再来看下 common.js
可以看到,common.js 中将 token 数据保存到了请求头中,该请求头的名字叫做”authorization“,这样在后续所有的 Ajax 请求中,都会在请求头中携带该 token。
而在此处为什么没有使用手机号作为 token 呢?这是因为 token 需要保存在客户端,如果以手机号作为 token,会有泄露用户隐私的风险。
6.6.2 代码实现
package com.hmdp.service.impl;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import javax.servlet.http.HttpSession;import java.util.Map;
import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.*;
import static com.hmdp.utils.SystemConstants.USER_NICK_NAME_PREFIX;@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result sendCode(String phone, HttpSession session) {// 1、校验手机号if (RegexUtils.isPhoneInvalid(phone)) {// 2、如果不符合,返回错误信息return Result.fail("手机号格式错误!");}// 3、符合,生成验证码String code = RandomUtil.randomNumbers(6);// 4、保存验证码到 Redis 中stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);// 5、发送验证码log.debug("发送短信验证码成功,验证码:{}", code);return Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1、校验手机号String phone = loginForm.getPhone();String code = loginForm.getCode();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2、从 Redis 中获取验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);// 3、不一致,报错if(cacheCode == null || !cacheCode.equals(code)){return Result.fail("验证码错误!");}// 4、一致,根据手机号去查询用户User user = query().eq("phone", phone).one();// 5、判断用户是否存在if(user == null){// 6、不存在,创建新用户并保存user = createUserWithPhone(phone);}// 7、将用户信息保存到 Redis 中// 7.1 生成 token,此处使用 UUID 作为 tokenString tokenKey = UUID.randomUUID().toString(true);// 7.2 将 User 对象转为 HashMap 存储UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);// 7.3 将用户信息存储到 Redis 中stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);// 7.4 设置过期时间stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 将 token 返回给客户端return Result.ok(tokenKey);}/*** 根据手机号创建用户* */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;}
}
代码实现分析:
1、sendCode 方法,将 session 存储验证码改为 Redis 存储,同时设置过期时间
2、login 方法中,将 session 存储用户信息,修改为 Redis 存储,value 值采用 Hash 类型,同时设置过期时间,模拟 session 过期时间。但是 session 过期是在用户未作任何操作的情况下,而 Redis 则是从用户登录开始计时,到指定时间后自动过期。我们应当保证只要用户在不断访问,就不断更新 Redis 中的 token 过期时间。那我们如何知道用户什么时候访问,有没有访问呢?我们所有的请求都要经过拦截器进行校验,只要同过了拦截器的校验就说明用户是已经登录的且在活跃的状态。那么我们就可以在拦截器中对 token 的过期时间进行刷新操作。只有什么都不操作的情况下,才不会走拦截器的校验,也就不会刷新 token 的过期时间。
修改拦截器
这里要注意一点就是,LoginInterceptor 是我们自定义的一个类,并非 Spring 进行管理的类,所以在使用 StringRedisTemplate 的时候,无法使用 @Autowired 或者 @Resource 进行注入。但是,MvcConfig 是由 Spring 进行管理的,可以由 Spring 注入 StringRedisTemplate 的实例。
package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
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;public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1、从请求头中获取 tokenString token = request.getHeader("authorization");// 2、判断token是否为空if (StrUtil.isBlank(token)) {// 不存在报401response.setStatus(401);return false;}// 3、根据 token 从 redis 中获取用户信息String tokenKey = RedisConstants.LOGIN_USER_KEY + token;// 使用 entries 方法获取所有的 field-valueMap<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);// 4、判断 userMap 是否为空if (userMap.isEmpty()) {// 不存在报401response.setStatus(401);return false;}// 4、将 userMap 转换为 UserDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5、将 user 保存到 ThreadLocal 中UserHolder.saveUser(user);// 6、刷新 token 过期时间stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
修改 MvcConfig 配置类
在 MvcConfig 中注入 StringRedisTemplate,同时在添加拦截器时,将 StringRedisTemplate 的实例通过 LoginInterceptor 的构造器传入。
package com.hmdp.config;import com.hmdp.utils.LoginInterceptor;
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;@Configuration
public class MvcConfig implements WebMvcConfigurer {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/shop/**","/voucher/**","/user/login","/user/code","/shop-type/**","/upload/**");}
}
6.6.3 解决登录时类型转换异常bug
重启项目后点击登录时,发现后台报了如下的错误。
这是因为 userMap 中的 id 为 Long 类型,但是 Redis 中存储的都是 String 类型。
login 方法改进,将 UserDTO 实例转换为 HashMap 时,将每一个属性转换为 String 类型。
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1、校验手机号String phone = loginForm.getPhone();String code = loginForm.getCode();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2、从 Redis 中获取验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);// 3、不一致,报错if(cacheCode == null || !cacheCode.equals(code)){return Result.fail("验证码错误!");}// 4、一致,根据手机号去查询用户User user = query().eq("phone", phone).one();// 5、判断用户是否存在if(user == null){// 6、不存在,创建新用户并保存user = createUserWithPhone(phone);}// 7、将用户信息保存到 Redis 中// 7.1 生成 token,此处使用 UUID 作为 tokenString tokenKey = UUID.randomUUID().toString(true);// 7.2 将 User 对象转为 HashMap 存储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()));// 7.3 将用户信息存储到 Redis 中stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);// 7.4 设置过期时间stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 将 token 返回给客户端return Result.ok(tokenKey);
}
总结
Redis 代替 session 需要考虑的问题:
- 选择合适的数据结构
- 选择合适的 key
- 选择合适的存储粒度
6.7 登录拦截器的优化
6.7.1 分析
当前拦截器:
登录功能是基于拦截器做的校验功能,但是当前拦截器拦截的并不是所有的路径,而是拦截的需要登录的路径,如果用户登录后,一直访问的是首页这种不需要拦截的路径,那么拦截器就会一直不执行,token 的过期时间就不会刷新,那么当 token 过期后,用户访问例如像个人主页时就会出现问题,很不友好。那如何解决?可以在当前拦截器的基础再添加一个拦截器,让新的拦截器拦截一切路径,在该拦截内做 token 的刷新动作。
优化后的拦截器:
6.7.2 创建 RefreshTokenInterceptor 拦截器
该拦截器用于拦截所有请求,并刷新 token 过期时间
package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
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;public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1、从请求头中获取 tokenString token = request.getHeader("authorization");// 2、判断token是否为空if (StrUtil.isBlank(token)) {return true;}// 3、根据 token 从 redis 中获取用户信息String tokenKey = RedisConstants.LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);// 4、判断 userMap 是否为空if (userMap.isEmpty()) {return true;}// 4、将 userMap 转换为 UserDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5、将 user 保存到 ThreadLocal 中UserHolder.saveUser(user);// 6、刷新 token 过期时间stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
6.7.2 修改 LoginInterceptor 拦截器
LoginInterceptor 则只需要判断当前访问用户是否已经登录,已经登录则放行,未登录则拦截
package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 判断当前用户是否登录if (UserHolder.getUser() == null) {// 未登录,设置状态码response.setStatus(401);// 拦截return false;}return true;}}
MvcConfig 设置拦截规则
拦截器执行顺序应当是先执行 RefreshTokenInterceptor,而后再执行 LoginInterceptor,通过 orde 方法设置拦截器执行顺序,值越小,则执行顺序越优先。
package com.hmdp.config;import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.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 {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/user/login","/user/code","/shop-type/**","/upload/**").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}