redis篇3-实战:短信登录(redis替代共享session应用)
以用户登录为例。用户登录时的信息保存在tomcat内存的session中(session都有一个sessionId,用户发送请求时的cookie携带sessionId帮助找到对应的session)。但是,这不适用于集群模式,会存在session共享问题。:多台tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时会导致数据丢失的问题。需要找到一个替代session的东西,
项目代码导入
1.mysql数据库表准备
执行下面的hmdp.sql文件:
结果如下:

2.项目架构
项目不采用微服务的模式,采用前后端分离的模式(前端部署在nginx服务器上,后端通过idea启动(使用tomcat))。用户请求通过nginx分发,再向服务端tomcat发起请求,服务端查询数据,可能是redis集群,也可能是mysql集群。
3.导入后端项目
用idea打开下面的项目:
项目修改前为:

记得修改application.yaml中的mysql和redis配置。
验证服务是否能正常启动:



访问http://localhost:8081/shop-type/list显示如下,则成功。
4.导入前端项目
复制nginx项目到无中文的路径:


输入指令:start nginx.exe
打开浏览器搜:
http://localhost:8080
一、问题解释
以用户登录为例。用户登录时的信息保存在tomcat内存的session中(session都有一个sessionId,用户发送请求时的cookie携带sessionId帮助找到对应的session)。但是,这不适用于集群模式,会存在session共享问题。
session共享问题:多台tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时会导致数据丢失的问题。
需要找到一个替代session的东西,session的替代方案应该满足:
(1)数据共享;
(2)内存存储;
(3)key、value结构。
可以看出,redis符合以上条件。
二、redis作为session集群替代方案
使用session时的流程:
使用redis时的流程:

上图中的token是用户的登录凭证,相当于session中的sessionId。
三、前端代码实现

上图是前端的登录请求,后台返回的数据data保存在前端session中的token中。
通过拦截器设置每次发送axios请求都携带token作为请求头authorization。
可以看出,登录凭证保存在前端浏览器。
四、后端代码实现
4.1 使用session
4.1.1 发送手机验证码

点击“发送验证码”按钮,可以看到请求地址为http://localhost:8080/api/user/code。
找到代码位置:
修改如下:
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}

public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession session);
}

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//3.符合,生成验证码
String code=RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//5.发送验证码(这里没有调用第三方平台进行发送,做了一个假的)
log.debug("发送短信验证码成功,验证码:{}"+ code);
return Result.ok();
}
}
重启服务,点击前端的“发送验证码”按钮。
4.1.2 短信登录和注册


根据http://localhost:8080/api/user/login找到代码位置:
请求参数LoginFormDTO为自定义类:
修改controller类:
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return userService.login(loginForm,session);
}
修改service接口:
Result login(LoginFormDTO loginForm, HttpSession session);
修改service实现类:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode==null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户
User user=query().eq("phone",phone).one();
//5.判断用户是否存在
if(user==null){
//6.不存在,创建新用户并保存
user=createUserWithPhone(phone);
}
//7.保存用户信息到session
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;
}
启动服务进行测试:
登录后还是回到这个界面(后面增加登录校验功能即拦截器):
数据库新增记录:
4.1.3 拦截器
登录验证请求如下:
拦截器是在每次请求前进行登录校验功能。
拦截器中获取到的信息保存到ThreadLocal中,便于后面Controller从ThreadLocal取出信息使用。


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;
import javax.xml.ws.handler.Handler;
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){
//4.不存在,拦截
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
//业务执行完毕执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
其中:
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
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();
}
}
配置拦截器:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())////配置拦截器
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"upload/**",
"voucher/**"
);////配置拦截路径
}
}
再编写通过拦截器校验后的处理:
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}
启动服务进行测试:

补充:隐藏用户敏感信息
上图中接口返回的信息过多,只需要id,昵称,图片icon就行。
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;
/**
* <p>
*
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@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;
}
之前是返回给前端User对象,现改为下面的UserDTO对象。
import lombok.Data;
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
修改UserController的登录注册接口,将UserDTO对象存到session:

修改用到的UserHolder:

启动服务验证:

4.2 使用redis
为解决一、问题解释中的session不共享问题,可以使用redis替代session。虽然session具有session拷贝的能力,但浪费空间且有延迟。
注意使用redis替代session实现登录注册的细节:
(1)浏览器有其独立的session,但是redis是共享的,存储时命名需要针对不同手机号来存储不同信息,即redis的key值得唯一性。
使用session时:
改成使用redis:
(2)session机制中会将sessionid保存在用户请求得cookies中,但是使用redis需要添加维护sessionid的机制,这里使用token,为每个用户随机生成token,并将token返回给前端,前端的请求需要携带token。
4.2.1 修改发送短信验证码

也可以对代码进行优化,将一些字段信息单独存储在一个类中:
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;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
修改如下:
4.2.2 修改短信登录和注册


@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//2.校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
String code = loginForm.getCode();
if(cacheCode==null || !cacheCode.toString().equals(code)){
//3.不一致,报错
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));
//随机生成token,作为登录令牌
String token= UUID.randomUUID().toString(true);
//将user对象转为hash存储
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()));
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userMap);
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
//返回token
return Result.ok(token);
}
4.2.3 修改拦截器
(除了获取用户信息方式修改外,还需要更新token有效期):
说明:上图中在MvcConfig.java中添加StringRedisTemplate对象是为了在自定义的拦截器工具类LoginInterceptor.java中实现用户再次访问时更新token有效期。之所以不在LoginInterceptor.java中直接添加StringRedisTemplate对象,是因为MvcConfig.java有@Configuration注解,由spring容器自动创建对象,可以注入StringRedisTemplate对象。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))////配置拦截器
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"upload/**",
"voucher/**"
);////配置拦截路径
}
}
再修改拦截器工具类:

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 {
//1.获取请求头中的token
String token=request.getHeader("authorization");
if(StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
//2.基于token获取redis中的用户
Map<Object,Object> userMap=stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY+token);
//3.判断用户是否存在
if(userMap.isEmpty()){
//4.不存在,拦截
response.setStatus(401);
return false;
}
//将查询到的Hash数据转为UserDTO对象
UserDTO userDTO= BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期(使用redis新增)
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;
}
//业务执行完毕执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
启动服务验证:




总结:Redis代替session需要考虑的问题
(1)选择合适的数据结构
(2)选择合适的key
(3)选择合适的存储粒度
补充:解决状态登录刷新问题
在4.2.3修改拦截器中有进行token有效期刷新的操作,但是存在问题,拦截器不是拦截一切请求(比如首页,商户信息页等不被拦截器拦截),这些请求不会去刷新token,存在问题。
解决方法是在原有拦截器上再新加一个拦截器:
改为:

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 {
//1.获取请求头中的token
String token=request.getHeader("authorization");
if(StrUtil.isBlank(token)){
return true;
}
//2.基于token获取redis中的用户
Map<Object,Object> userMap=stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY+token);
//3.判断用户是否存在
if(userMap.isEmpty()){
return true;
}
//将查询到的Hash数据转为UserDTO对象
UserDTO userDTO= BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期(使用redis新增)
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;
}
//业务执行完毕执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}

public class LoginInterceptor implements HandlerInterceptor {
//业务执行之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断是否需要拦截(ThreadLocal中是否有用户)
if(UserHolder.getUser()==null){
response.setStatus(401);
//拦截
return false;
}
//放行
return true;
}
}
配置两个拦截器:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())////配置拦截器
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"upload/**",
"voucher/**"
).order(1);////配置拦截路径
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
这里就不展示效果了,可以自行尝试,登录和点击首页都会更新token有效期。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)