在学习项目的过程中遇到了使用Session实现登录功能所带来的共享问题,此问题可以使用Redis来解决,也即是加上一层来解决问题。
接下来介绍一些Session的相关内容并且采用Session实现登录功能(并附上代码),进行分析其存在的问题,并使用Redis来解决这一问题。
一、什么是Session
可以参考一下这个博客的内容,比较详细:
Session详解
简而言之,session是服务器为了保存用户状态而创建的一个特殊的对象,服务器会为每一个浏览器(客户端)创建一个唯一的session,session有一个JSESSIONID,这个是session的唯一标识。
当浏览器(客户端)发送请求的时候,会在Cookie中携带JSESSIONID,以便服务器可以找到对应的Session,服务器就能知道客户端的状态了。
比如:在登陆一些网站成功之后,你再访问这个网站的其他内容的时候就不需要重新登陆了,因为此时服务器可以基于浏览器Cookie中的JSESSIONID(或者其他校验技术)来判断你的状态。但是当一段时间未登录之后,你重新访问网站,往往需要重新登录,这是因为浏览器访问时没有携带JSESSIONID,浏览器携带的JSESSIONID对应的session不存在(或者失效)的原因。
二、基于Session实现登录(短信登录)
1.流程:
2.代码实现:
(1)Controller层:(接收请求)
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
(2)Service层:(处理业务逻辑)
/**
* 发送短信验证码并保存验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号是否合法
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
//2.若不合法,返回错误信息
if(!phoneInvalid){
return Result.fail("手机号不合法");
}
//3.生成验证码 6位
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session中
session.setAttribute("code",code);
//5.发送验证码 (这里可以结合阿里云提供的第三方服务来做,但是为了方便,输出一下就可以)
log.debug("发送短信验证码成功,验证码:{}",code);
//6.返回ok
return Result.ok();
}
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//1.校验手机号是否合法
if(!RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号不合法");
}
//2.校验验证码是否一致
String sessionCode = (String) session.getAttribute("code");
String userCode = loginForm.getCode();
//3.验证码不一致返回错误信息
if(sessionCode == null ||!sessionCode.equals(userCode)){
return Result.fail("验证码错误");
}
//4.根据手机号到数据库查询用户
User user = query().eq("phone", phone).one();
//5.若用户不存在则创建用户并保存到数据库
if(user == null){
user = createUserByPhone(phone);
}
//6.保存用户信息到session中
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
session.setAttribute("user",userDTO);
//7.返回ok
return Result.ok();
}
(3)LoginInterceptor:(拦截请求,进行登录校验)
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里面的user
User user = (User) session.getAttribute("user");
//3.用户不存在,则拦截
if(user == null){
//4.拦截,返回401
response.setStatus(401);
return false;
}
//5.存在,保存到ThreadLocal中
UserDTO userDTO = new UserDTO();
userDTO.setId(user.getId());
userDTO.setIcon(user.getIcon());
userDTO.setNickName(user.getNickName());
UserHolder.saveUser(userDTO);
//6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在页面渲染完成返回给用户前,删除用户信息,避免内存泄漏
UserHolder.removeUser();
}
}
(4)MVCconfig:(用于注册拦截器,使得拦截器生效)
@Configuration//由Spring创建的对象 可以使用 Autowired
public class MVCconfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login",
"/shop/**",
"/shop-type/**",
"/blog/hot",
"/upload/**",
"/voucher/**"
);
}
}
三、基于Session实现登录存在的共享问题
1.问题分析
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
也就是说一台浏览器可能会被负载均衡到不同的服务器上,但是服务器的Session是不共享的,那当浏览器先被负载均衡到服务器A,后负载均衡到服务器B时,浏览器所携带的JSESSIONID只在服务器A生效,而服务器B找不到其对应的Session,也就无法获知用户的状态。这就是基于Session实现登录存在的共享问题。
2.解决方案:
session的替代方案应该满足以下条件:
(1)数据共享:
首先必须满足数据可共享,否则还是会引发共享问题。这个时候就应该想到常用的思路“加一层”,加上一层中间件,上层都到这个中间件来存取数据,这样不就实现了数据共享吗。
(2)内存存储
由于每次请求的时候,服务器都需要对请求进行校验,这个操作是十分频繁的,如果可以将数据存储在内存中,就可以极大地提高服务器处理请求的效率。
(3)key、value结构
服务器应该根据服务器所携带key去找到其对应的value
通过以上分析,使用Redis来解决这个问题是再合适不过的了。
使用Redis实现登录功能总思路:
1.用户提交手机号,后端收到手机号之后生成验证码并以手机号为key,验证码为value,并设置过期时间,存储到redis中。存储完成之后发送验证码给用户。
2.用户填写验证码,浏览器发送协带用户填写的手机号和验证码的请求。后端接收请求,根据用户的手机号到redis中查找对应的验证码值,查找后将redis中的验证码和用户提交的验证码进行比较。
3.验证成功,则生成一个token,将token作为key,用户信息作为value(这里使用redis的hash结构存储),设置过期时间,存入redis中,并返回一个token。
4.此后,前端发送请求的时候,就携带这个token,服务器就可以根据这个token到redis中去获取用户的信息。
5.由于redis是单独的一层,所有浏览器拿到token之后都是去这个redis中查找数据,这就解决了用户被负载均衡到不同服务器时,session引发的共享问题。
四、基于Redis实现登录
1.流程:
简单来说就是把对session存取操作改为对redis的存取操作。
2.代码实现:
(1)Controller层
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
//发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
@GetMapping("/me")
public Result me(){
//获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
(2)Service层:(处理业务逻辑)
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 发送短信验证码并保存验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号是否合法
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
//2.若不合法,返回错误信息
if(!phoneInvalid){
return Result.fail("手机号不合法");
}
//3.生成验证码 6位
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到redis中
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY+phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.发送验证码 (这里可以结合阿里云提供的第三方服务来做,但是为了方便,输出一下就可以)
log.debug("发送短信验证码成功,验证码:{}",code);
//6.返回ok
return Result.ok();
}
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//1.校验手机号是否合法
if(!RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号不合法");
}
//2.TODO 从Redis获取验证码并校验验证码是否一致
String reidsCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+phone);
String userCode = loginForm.getCode();
//3.验证码不一致返回错误信息
if(reidsCode == null ||!reidsCode.equals(userCode)){
return Result.fail("验证码错误");
}
//4.根据手机号到数据库查询用户
User user = query().eq("phone", phone).one();
//5.若用户不存在则创建用户并保存到数据库
if(user == null){
user = createUserByPhone(phone);
}
//6.保存用户信息到redis中
//6.1 生成随机token 作为登录令牌
String token = UUID.randomUUID().toString(true);
//6.2 将UserDTO转为 HashMap
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
Map<String, Object>usertMap = new HashMap<>();
usertMap.put("id", userDTO.getId().toString());
usertMap.put("nickName", userDTO.getNickName());
usertMap.put("icon", userDTO.getIcon());
//6.3 存储到redis中
String tokenKey = RedisConstants.LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,usertMap);
//6.4 设置有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
//7.返回token
return Result.ok(token);
}
/**
* 根据手机号创建用户
* @param phone
* @return
*/
private User createUserByPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
save(user);
return user;
}
(3)LoginInterceptor:(拦截请求,进行登录校验)
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if(StringUtils.isEmpty(token)){
//token 为空 拦截,返回401
response.setStatus(401);
return false;
}
//2.通过token获取redis中的用户信息
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(tokenKey);
//3.用户不存在,则拦截
if(userMap.isEmpty()){
//4.拦截,返回401
response.setStatus(401);
return false;
}
//5. 将HashMap转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.存在,保存到ThreadLocal中
UserHolder.saveUser(userDTO);
//7.重置token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES)
//8.放行
return true;
}
(4)MVCconfig:(用于注册拦截器,使得拦截器生效)
@Configuration//由Spring创建的对象 可以使用 Autowired
public class MVCconfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login",
"/shop/**",
"/shop-type/**",
"/blog/hot",
"/upload/**",
"/voucher/**"
);
}
}