1、前言
本文主要采用springboot+redis实现登录功能。持久层使用的是MyBatis Plus操作数据库。适合学完springboot,背过redis八股文的知识的实战练习。
效果图:
2、具体步骤
1.pom.xml :引入相关资源文件
<!-- redis数据 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- springboot web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>8.0.20</version>
</dependency>
<!-- lombok 实体注解-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- mybatis-plus 简化单表 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!-- hutool 工具类 json处理 字符串 日期处理 随机数 ....-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
2.application.yaml: 配置文件
# 服务端口
server:
port: 8081
spring:
# 服务名称
application:
name: hmdp
# mysql数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/hmdp?useSSL=false&serverTimezone=UTC&charactEncoding=UTF-8
username: root
password: root
# redis数据源配置
redis:
host: 192.168.171.132 # 自己的虚拟机ip地址
port: 6379
password: 123456
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s
jackson:
default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
level:
com.hmdp: debug # 日志打印
3.基于session的验证码登录
1)发送验证码业务流程
- controller/UserController
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
- service/UserServiceImpl
//发送手机验证码
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号 -- 正则表达式
if (RegexUtils.isPhoneInvalid(phone)){
//如果手机号不符合,就返回错误信息
return Result.fail("手机号格式有误, 请重新输入!");
}
//符合,生成验证码 hutool-all工具包
String code = RandomUtil.randomNumbers(6);
//验证码保存到session中
session.setAttribute("code", code);
//发送验证码 -- 调用第三方技术的短信服务(参考阿里云视频点播的案例) 模拟发送短信成功 控制台打印日志模拟由短信服务上发送的验证码
log.debug("短信验证码发送成功,【验证码】"+code);
return Result.ok();
}
2)用户登录业务流程
注意:校验登录功能存在的问题:User对象存到session造成内存泄露
解决:隐藏User对象的部分敏感属性
- controller/UserController
/**
* 登录功能
* @RequestBody:{phone: "19956570011", code: "112211"}前端数据是json格式
* 后端自动解析json格式的数据
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
- service/UserServiceImpl
//用户登录
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式有误, 请重新输入!");
}
//校验验证码
Object codeCache= session.getAttribute("code");
String code = loginForm.getCode();
if (codeCache == null || !codeCache.toString().equals(code)){
//验证码不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户user select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
//判断用户是否存在
if (user == null) {
//不存在,创建新用户。根据手机号创建用户
user = createUserWithPhone(phone);
}
//存在保存到session中
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX +RandomUtil.randomString(6));
//保存用户
save(user);
return user;
}
3)登录校验业务流程
intercepter/LoginIntercepter 拦截器对象
public class LoginIntercepter implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从cookie获取session
HttpSession session = request.getSession();
//从session中获取User对象
Object user = session.getAttribute("user");
//判断:用户是否存在
if (user == null){
// 如果不存在,拦截 状态码:401 30x 40x 50x
response.setStatus(401);
return false;
}
// 存在用户保存到ThreadLocal中
UserHolder.saveUser((UserDTO) user);
//放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//登录完成后移除用户
UserHolder.removeUser();
}
}
config/MyMvcConfig 配置登录拦截器
@Configuration
public class MyMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginIntercepter())
.excludePathPatterns(
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/user/code",
"/user/login"
);
}
}
controller/UserController
@GetMapping("/me")
public Result me(){
//获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
service/UserServiceImpl
//存在保存到session中 将user对象转为UserDto 避免传递敏感信息
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
4)用户退出登录
/**
* 登出功能
* @return 无
*/
@PostMapping("/logout")
public Result logout(HttpSession session){
// 实现登出功能
session.removeAttribute("user");
return Result.ok("退出登录");
}
- 敏感信息(手机号、密码、身份证号、特殊时间点)传递的问题
- 解决方案
若存在User对象,将User对象保存到session中
方法1:手动new UserDto对象封装id、nickName、icon属性
方法2:将user对象转为UserDto,BeanUtil.copyProperties(user, UserDTO.class避免传递敏感信息
基于session的登录存在的问题
session共享问题:用户请求的一个服务模块可能需要调用到服务器B,当用户发起请求的时候,此时的服务器B上并没有存储该用户的sessionID,所以就会再次让用户进行一个登陆操作。还有可能会导致用户本来就想完成一个下单操作,但是却还登陆了好几次的情况。
session不共享问题:请求切换到不同 服务器导致数据丢失。
4.基于redis共享session登录
- 将手机号生成的验证码保存到redis,以手机号作为key存储到redis,String类型的value是验证码。
- 验证码在redis设置过期时间。为了防止内存溢出。
- 用户对象保存到redis,以随机token作为key,value可以是String类型json字符串,可以hash类型。用token获取用户实现数据共享。
- redis的用户生成token设置过期时间
controller/UserController
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* 登录功能
* @RequestBody:{phone: "19956570011", code: "112211"}前端数据是json格式
* 后端自动解析json格式的数据
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
/**
* 校验登录功能
* @return 无
*/
@GetMapping("/me")
public Result me(){
//获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
service/UserServiceImpl
//发送手机验证码
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号 -- 正则表达式
if (RegexUtils.isPhoneInvalid(phone)){
//如果手机号不符合,就返回错误信息
return Result.fail("手机号格式有误, 请重新输入!");
}
//符合,生成验证码 hutool-all工具包
String code = RandomUtil.randomNumbers(6);
//验证码保存到redis中 验证码code选择 String类型
//设置 有效期 set key value ex
//session.setAttribute("code", code);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码 -- 调用第三方技术的短信服务(参考阿里云视频点播的案例) 模拟发送短信成功 控制台打印日志模拟由短信服务上发送的验证码
log.debug("短信验证码发送成功,【" + code + "】");
return Result.ok();
}
//用户登录
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式有误, 请重新输入!");
}
// 从redis获取验证码 校验验证码
//Object codeCache= session.getAttribute("code");
String codeCache = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (codeCache == null || !codeCache.toString().equals(code)){
//验证码不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户user select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
//判断用户是否存在
if (user == null) {
//不存在,创建新用户。根据手机号创建用户
user = createUserWithPhone(phone);
}
//若存在User对象,将User对象保存到session中
// 方法1:手动new UserDto对象封装id、nickName、icon属性
// 方法2:将user对象转为UserDto,BeanUtil.copyProperties(user, UserDTO.class避免传递敏感信息
//存在用户保存到redis
//生成token
String token = UUID.randomUUID().toString(true);
//将User对象转换为hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//UserDTO对象属性保证String类型
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(false)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//存储
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
//设置token的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX +RandomUtil.randomString(6));
//保存用户
save(user);
return user;
}
intercepter/LoginIntercepter 登录拦截器
private StringRedisTemplate stringRedisTemplate;
public LoginIntercepter(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头中获取token
//HttpSession session = request.getSession();
String token = request.getHeader("authorization");
// 基于token获取redis的User对象
//Object user = session.getAttribute("user");
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(key);
//判断:用户是否存在
if (userMap.isEmpty()){
// 如果不存在,拦截 状态码:401 30x 40x 50x
response.setStatus(401);
return false;
}
// 将查询hash数据转换成UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 存在用户保存到ThreadLocal中
UserHolder.saveUser(userDTO);
// 刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
登录信息存储到redis效果图
彩蛋:谈谈对ThreadLocal以及应用的理解。(面试中的回答)
1.实现线程间资源对象的线程隔离。
2. 实现线程内资源对象共享。
//新建一个用户资源的静态线程本地变量
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
tl.set(user); //把获取到的资源存入到当前线程
tl.get(); //到当前线程去获取资源
tl.remove(); //删除当前线程关联的资源
ThreadLocalMap map = getMap(t) //当前线程内ThreadLocalMap 成员变量用来存储资源对象
private final int threadLocalHashCode = nextHashCode();//每次创建一个ThreadLocal对象nextHashCode计算资源对象存储的索引位置
ThreadLocalMap // 发生扩容时采用开放寻址法解决HashCode值冲突问题
//问题:ThreadLocalMap的key是为什么是弱引用?
static class Entry extends WeakReference<ThreadLocal<?>>
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
//为了释放内存中的共享资源,线程池中线程长时间的运行占用的内存得不到释放导致内存不足GC需要回收不再使用的ThreadLocal的key。
1.get key 发现值是null, GC回收把key存到对应位置。
2.set key 使用启发式扫描,清除临近的key=null, 启发次数和元素个数是否和发现key=null有关。
3.ThreadLocal是静态成员变量是强引用,GC无法回收。调用remove
至此完成系统登录功能,还有优化的地方欢迎大家在评论区留言!我们一起学习~~~