#黑马点评#(一)登录功能
目录
短信登录
1 发送验证码
2 登录功能
3 登录校验拦截器
*4 集群的session共享问题
1 实现拦截器的拦截功能(这里是借助token来对redis当中的数据进行验证)
2 发送验证码
3 登录功能
短信登录
实现功能展示
1 发送验证码
功能实现:
首先找到对应的控制层类文件userController(调用Service业务层接口方法)
/*** 发送手机验证码*/@PostMapping("code")public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {return userService.sendCode(phone, session);}
在Service业务层接口中补全
/*** 发送验证码** @param phone* @param session* @return*/Result sendCode(String phone, HttpSession session);
Service接口实现类补全业务逻辑(这里我们为了便捷不完善发送短信验证码的功能只在客户端以日志形式显示出来)
@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();}
2 登录功能
在Controller控制层当中调用方法
/*** 登录功能** @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码*/@PostMapping("/login")public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {return userService.login(loginForm, session);}
Service业务层接口
/*** 登录功能** @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码* @param session* @return 登录结果*/Result login(LoginFormDTO loginForm, HttpSession session);
Service接口实现类
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1校验手机号String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {// 2不符合,返回错误信息return Result.fail("手机号格式错误!");}// 3从session获取验证码校验(校验验证码)String cacheCode = (String) session.getAttribute("code");String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) {// 4不一致,返回错误信息return Result.fail("验证码错误!");}// 5根据手机号查询用户User user = query().eq("phone", phone).one();if (user == null) {// 6不存在,创建新用户并保存user = createUserWithPhone(phone);}// 7保存用户到sessionsession.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_" + RandomUtil.randomString(10));save(user);return user;}
3 登录校验拦截器
拦截器的书写
package com.hmdp.interceptor;import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
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 {// 获取sessionHttpSession session = request.getSession();// 获取session中的用户Object user = session.getAttribute("user");// 判断用户是否存在if (user == null) {// 不存在,拦截,返回登录页面response.setStatus(401);return false;}// 存在,放行(将信息保存到ThreadLocal)UserHolder.saveUser((UserDTO) user);// 返回truereturn true;}/*** 在请求处理完成后,将线程变量中的用户信息移除,避免内存泄漏* @param request* @param response* @param handler* @param ex* @throws Exception*/@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户UserHolder.removeUser();}
}
配置类(指定拦截路径)
package com.hmdp.config;import com.hmdp.interceptor.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 {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**").excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**");}
}
返回登录对象方法的完善(Controller层)
/*** 获取当前登录用户信息** @return*/@GetMapping("/me")public Result me() {UserDTO user = UserHolder.getUser();return Result.ok(user);}
*4 集群的session共享问题
请求会进行负载均衡:拷贝方式过于繁琐并且存在延迟。为了改进
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value
具体流程:
传统的cookie方式:
核心逻辑:客户端向服务端发送请求后,后端服务器直接将用户敏感信息明文或者见到那加密后存储在cookie中,客户端后续每次请求都要携带这些信息,服务器解析Cookie以完成验证。
新的session方式:(Cookie+服务端存储)
核心逻辑:客户端向服务端发送请求,服务端生成唯一的Session_ID返回给客户端Cookie中,用户的实际数据借助session_ID储存在服务端。
集群的session共享方式
核心逻辑:客户端向服务端发送请求后,服务端生成唯一的Session_ID返回给客户端Cookie中,用户的实际信息借助Session_ID存储在Redis数据库当中。
代码实现:
1 实现拦截器的拦截功能(这里是借助token来对redis当中的数据进行验证)
我们定义了两个拦截器,将功能区分,第一个拦截器获取token并判断是否为空,空的话那就交给第二个拦截器进行拦截,不为空就向下获取redis中的用户,判断用户是否存在,不存在就放行交给第二个拦截器进行拦截,用户查询出来的话就将查询出来的用户对象转为UserDTO存储在ThreadLocal当中,同时刷新redis当中的token有效期,然后放行给第二个拦截器进行判断是否拦截。如果被拦截下来就要去登录,如果两次拦截都没有被拦截那就需要进行登录,执行登录操作才可进行后续操作。(核心也就是对登录状态的一种保存)
ThreadLocal
ThreadLocal是Java中的一个线程变量,它可以为每个线程提供一个独立的变量副本。ThreadLocal实例是共享的,但每个线程通过它访问的是自己的ThreadLocalMap中的值。
ThreadLocal的主要作用是在多线程的环境下提供线程安全的变量访问。它常用于解决线程间数据共享的问题,特别是在并发编程中,当多个线程需要使用同一个变量时,可以使用ThreadLocal确保每个线程访问的都是自己的变量副本,从而避免了线程安全问题。
ThreadLocal底层是通过ThreadLocalMap来实现的,每一个Thread(线程)对象中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
static修饰的ThreadLocal对象属于类级别,在JVM的整个生命周期中仅初始化一次,后续所有的线程通过BaseContext.threadLocal访问同一个ThreadLocal实例,但每个线程的变量副本独立存储,避免重复创建对象。
内存泄漏问题:当ThreadLocal对象使用完之后,应该将Entry对象(即key和value)回收。而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象。在Entry中,key是弱引用,会触发自动回收机制,但value是强引用不会自动回收,最终导致Entry整体无法被回收机制回收。最终导致线程池中的线程因ThreadLocalMap未清理而出现内存泄漏。解决方法是手动调用ThreadLocal的remove()方法,清除Entry对象。
package com.hmdp.utils;import com.hmdp.dto.UserDTO;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();}
}
第一个拦截器
package com.hmdp.interceptor;import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.RedisConstants;
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;public class RefreshTokenInterceptor implements HandlerInterceptor {private final 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");if (token == null) {return true;}// 2基于token获取redis中的用户Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);// 3判断用户是否存在if (entries.isEmpty()) {return true;}//5将查询到的Hash数据转为UserDTO对象UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);//6存在,放行(将信息保存到ThreadLocal)UserHolder.saveUser(userDTO);//7刷新token有效期stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+ token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);//8返回true,放行return true;}/*** 在请求处理完成后,将线程变量中的用户信息移除,避免内存泄漏** @param request* @param response* @param handler* @param ex* @throws Exception*/@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户UserHolder.removeUser();}
}
第二个拦截器
package com.hmdp.interceptor;import com.hmdp.utils.UserHolder;
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 {//判断是否要拦截(ThreadLocal当中是否有我们的用户)if (UserHolder.getUser() == null) {//拦截,设置状态码response.setStatus(401);//返回false,拦截return false;}// 有用户,放行return true;}
}
拦截器的配置类(指定如何拦截,拦截器的先后顺序)
package com.hmdp.config;import com.hmdp.interceptor.LoginInterceptor;
import com.hmdp.interceptor.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;@Configuration
public class MvcConfig implements WebMvcConfigurer {@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 登录拦截器** @param registry*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**").excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}
大致流程图
2 发送验证码
Controller控制层实现(调用Service业务层的方法)
/*** 发送手机验证码*/@PostMapping("code")public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {return userService.sendCode(phone, session);}
Service业务层的实现(业务层接口)
/*** 发送验证码** @param phone* @param session* @return*/Result sendCode(String phone, HttpSession session);
Service业务层的实现(业务层实现类)
我们接受前端的手机号信息,先利用工具类判断是否是合格的手机号格式,符合的话我们随机生成验证码,并以key-value的格式保存到redis数据库当中,这里我们没有调用实际的发送验证码的接口利用日志显示在后端便于我们调试。
/*** 发送验证码** @param phone* @return*/@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();}
3 登录功能
Controller层的实现(调用Service业务层的接口)
/*** 登录功能** @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码*/@PostMapping("/login")public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {return userService.login(loginForm, session);}
Service业务层的实现(业务层接口)
/*** 登录功能** @param loginForm* @param session* @return*/Result login(LoginFormDTO loginForm, HttpSession session);
Service业务层的实现(业务层实现类)
首先获取前端请求当中给我们传递的DTO对象当中的手机号信息,我们先验证手机号的格式信息是否正确,接着再从redis数据库当中获取验证码,也将DTO对象当中传递的验证码取出来判断二者是否不为空且一致,接着我们再到数据库当中的用户表当中根据手机号查询用户的信息,判断是否存在,如果存在就直接将用户存储到redis当中便于后续的session的验证,如果不存在就先创建用户,再进行数据存储。
/*** 登录功能** @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码* @return 登录结果*/@Override@Transactionalpublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1校验手机号String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {// 2不符合,返回错误信息return Result.fail("手机号格式错误!");}// 从redis获取验证码校验(校验验证码)String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) {// 4不一致,返回错误信息return Result.fail("验证码错误!");}// 5根据手机号查询用户User user = query().eq("phone", phone).one();if (user == null) {// 6不存在,创建新用户并保存user = createUserWithPhone(phone);}// 7保存用户到redis// 随机生成token,作为登陆令牌String token = UUID.randomUUID().toString();// 将User转为HashMap去存储UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> stringObjectMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true)//忽略null值.setFieldValueEditor((s, o) -> o.toString()));//设置值//将token存入redisstringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, stringObjectMap);//设置token有效期stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);//返回tokenreturn Result.ok(token);}
创建用户的方法
/*** 根据手机号创建新用户** @param phone* @return*/private User createUserWithPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName("user_" + RandomUtil.randomString(10));save(user);return user;}