黑马的实战篇教程是用一个点评项目来运用Redis,这里记录一下
1. 导入后端项目
导入黑马点评项目,先用sql文件建表,然后复制idea项目到工作目录,然后在IDEA里打开项目,在配置文件中修改MySQL和Redis的相关配置
再把资料里的nginx文件夹拷贝到工作目录,在nginx所在目录下打开一个CMD窗口,输入命令:
打开chrome浏览器,访问: http://127.0.0.1:8080 ,即可看到页面。在空白页面点击鼠标右键,选择检查,即可打开开发者工具,然后打开手机模式
2. 基于Session实现登录
2.1 发送短信验证码
功能展示

功能逻辑

功能代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Override public Result sendCode(String phone, HttpSession session) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!"); } String code = RandomUtil.randomNumbers(6);
session.setAttribute("code",code);
log.debug("发送短信验证码成功,验证码:{}", code); return Result.ok(); } }
|
其中发送验证码只是简单的后台打印下,没有真正的发送
2.2 短信验证码登录
功能展示

功能逻辑

功能代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!"); } Object cacheCode = session.getAttribute("code"); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { return Result.fail("验证码错误"); }
User user = query().eq("phone", phone).one();
if (user == null) { user = createUserWithPhone(phone); }
session.setAttribute("user",user); 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; }
|
2.3 拦截器登录验证
用户登录成功之后,以后每次请求资源自然不能都要登录,而是改为每次请求之前都要校验登录状态。而我们自然不希望在每个Controller里都编写校验登录的代码,耦合性太强,这里需要使用拦截器进行登录验证。
功能逻辑

值得一提的是,为了确保获取当前访问的用户,拦截器需要将每个用户信息传递到Controller里,这里选择将用户保存到当前线程的 ThreadLocal 里,保证线程的安全问题。(每收到一个http请求,服务端就会新开一个线程来处理)
拦截器代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| package com.hmdp.utils; ... public class LoginInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("user"); if (user == null) { response.setStatus(401); return false; } UserHolder.saveUser((UserDTO) user); return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
|
配置拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package com.hmdp.config; ... @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" ); } }
|
这里黑马给的nginx代码 login.html 跳转返回的是index.html首页,视频演示的是跳转到info.html详情页,如果要和视频保持一致,就去nginx文件夹里找到 login.html 把跳转代码改了
1 2 3 4 5 6 7 8
| axios.post("/user/login", this.form) .then(({data}) => { if(data){ // 保存用户信息到session sessionStorage.setItem("token", data); } // 跳转到首页 location.href = "/info.html"
|
如果还是有问题,建议继续往后面看视频,还有一些数据类型转换的东西没有讲完,可能是那一部分原因导致的跳转失败。并且此处应该更关注后端尤其是数据库里的现象,不用太在意前端现象
2.4 隐藏用户敏感信息
之前Session里存取的都是完整的User,包括密码等属性,一方面这是敏感信息,不应该在HTTP里传输,另一方面多余的信息也会带来额外的内存消耗,因此可以建立UserDTO,仅使用必要的信息完成数据传输
1 2 3 4 5 6 7 8 9 10
| package com.hmdp.dto;
import lombok.Data;
@Data public class UserDTO { private Long id; private String nickName; private String icon; }
|
随后可以将Session里存取的User也换成UserDTO
3. 集群的session共享问题
session共享问题:多台Tomcat并不共享session存储空间,由于负载均衡,集群会把请求切换到不同的tomcat服务,这时其他的tomcat没有之前的session,导致数据丢失。
tomcat也有过一些解决方案,比如在tomcat集群里拷贝所有的Session,但这会导致内存和时间上的浪费

session的替代方案应该满足:
所以可以使用Redis作为替代方案
4. 基于Redis实现共享session登录
Redis代替session需要考虑的问题:
- 为value选择合适的Redis数据结构
- 选择合适的key方便存取
- 选择合适的存储粒度
4.1 发送短信验证码
之前是将验证码保存到Session里,现在要将验证码保存到Redis里

代码:更新 sendCode() 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Resource private StringRedisTemplate stringRedisTemplate;
@Override public Result sendCode(String phone, HttpSession session) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!"); } String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.debug("发送短信验证码成功,验证码:{}", code); return Result.ok(); }
|
4.2 短信验证码登录
之前短信验证码登录有一个必不可缺的逻辑就是校验验证码之后总需要保存用户对象信息到session里,方便后续每次请求里对该信息进行处理,现在不用session了,可以将用户对象信息存在redis里,还是考虑两个问题:
- 为value选择合适的Redis数据结构:保存一个对象,Redis通常有两种数据结构可选,一是String结构,以JSON字符串来保存,比较直观。二是Hash结构,它可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少(JSON里有不必要的符号)。一般如果数据较少的话,两个都行,数据较多从优化的角度,Hash结构更适合。此处我们选择Hash结构
- 选择合适的key:这里也可以将手机号作为key,但不推荐,原因后面讲,而是用一个随机字符串 token,作为key,作为用户登录状态。也有两方面原因:
- 存:随机字符串能够保证key的唯一性,能够存数据
- 取:后续的功能里,即之前讲的2.3登录验证时,用户每次请求时,是携带cookie的,里面有sessionID,这样就能用session里的用户信息进行判断,这是tomcat帮我们维护的。现在不用session了,得去redis里取出用户信息,这里就可以根据 token 取出,即把 token 作为用户的登陆凭证,取代之前的cookie。那么如何让用户每次请求时都带上 token 呢,这里需要我们在保存用户信息到Redis之后,不能结束流程,而是要将 token 通过响应信息返回给客户端,以便于客户端以后每次请求时都带上这个token。这里也是不将手机号作为key的原因,在响应中放这种敏感信息是不合理的。

代码:更新 login() 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
|
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!"); } String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { return Result.fail("验证码错误"); }
User user = query().eq("phone", phone).one();
if (user == null) { user = createUserWithPhone(phone); }
String token = UUID.randomUUID().toString(true); 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())); String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token); }
|
这里关于token有效期有一个问题是,expire这个函数是设定死了30min之后失效,不管这期间有没有继续访问请求,但实际上的逻辑应该是如果用户继续不断访问,则应该不断更新有效期,只有用户完全不访问了,再等30min才失去登录状态。这里的逻辑写在后续的拦截器中
4.3 拦截器登录验证
拦截器的功能逻辑如下图所示
代码:更新 LoginInterceptor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| @Component public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { response.setStatus(401); return false; } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if (userMap.isEmpty()) { response.setStatus(401); return false; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES); return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
|
更新WebMvcConfigurer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Configuration public class MvcConfig implements WebMvcConfigurer {
@Resource private StringRedisTemplate stringRedisTemplate;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ); } }
|
测试:
redis 的内容

每次请求中登录验证的 token

注意,在拦截器类 LoginInterceptor 注入 StringRedisTemplate可能会失败,参考本站博客:《问题解决-springboot拦截器无法注入StringRedisTemplate》
4.4 优化拦截器登陆验证
在前面的拦截器思路中(如下图所示),拦截器负责更新 token 的有效期,但其实有些网页请求是不用拦截的,比如商户信息。现在假设一个用户登录了之后浏览了30分钟的商户信息,这30分钟没有用到拦截器,token就不会刷新,然后 token 就过期了,用户得重新登录,这显然是不合理的,因此我们需要对拦截器做出优化。

优化方法:
可以新增加一个拦截器拦截一切信息,在这个拦截器里负责获取、查询、更新 token,原先的 LoginInterceptor 拦截器只负责查询用户是否存在,逻辑图如下,这样就可以解决前面提到的问题。

代码:
新添加拦截器 RefreshTokenInterceptor,注意此处是拦截一切请求,所以查不到 token 也没事,放行就可以。查到用户就放入ThreadLocal,查不到就放行。需要登录的路径放到原先的 LoginInterceptor 拦截器去判断ThreadLocal是否有用户
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| 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 { String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { return true; } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if (userMap.isEmpty()) { return true; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES); return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
|
更新 LoginInterceptor,只需要去ThreadLocal中查询是否有用户
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() == null) { response.setStatus(401); return false; } return true; } }
|
更新拦截器配置类,注意添加拦截器 registry.addInterceptor() 时,用 order() 方法指定顺序,确保 token刷新拦截器比登录验证拦截器先执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Configuration public class MvcConfig implements WebMvcConfigurer {
@Resource private StringRedisTemplate stringRedisTemplate;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0); registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ).order(1); } }
|
测试:
登录之后不断访问 个人主页 和 首页,去查看Redis里 token 的TTL ,应该是不断被刷新重置的。这样就能够解决登录状态刷新的问题