前言
从9.24-9.29,每天大概抽一两个小时,也总算是完成了人生第一个项目的第一个模块。感觉在接触企业级的业务的途中确实对自己的思路和能力有所提升,下面我会梳理一下开发思路和对一些技术进行整理总结。
正片开始
我将短信登录模块再细分为三个阶段,分别为:基于session登录->session缺点->优化为基于Redis登录。
基于session登录
实现发送短信验证码功能
实现思路:
要实现用户输入手机号发送短信,我们必须先校验用户提交的手机号是否正确,这里我们调用工具类RegexUtils里的方法进行判断,如果用户输入手机号违规,我们则返回错误信息,手机号正确便通过工具类RandomUtil中的随机生成任意长度字符串的方法生成一个六位验证码并将验证码存在session中以便后续与用户输入验证码进行比对,最后通过短信将验证码发给用户(这里是模拟的,并没有发送)。
代码:
@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);
// 返回ok
return Result.ok();}
实现用户登录/注册功能
实现思路:
首先我们要再次校验用户手机号,因为可能有调皮的用户在发送验证码并获取验证码后修改手机号,这种行为显然是非常危险的,所以我们在不同的功能里必须分开进行校验,确保程序的安全性。校验无误以后我们需要从session中拿到先前生成的验证码与用户输入的验证码做对比,如果输入正确,我们便进行下一步,通过mp语句在数据库中用手机号搜索这个用户,如果存在,便将用户存储到session中,不存在则通过手机号创建这个用户。
代码:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
session.setAttribute("user",user);
return Result.ok();
}
重点解析:
1.User user = query().eq("phone", phone).one();
整行代码的整体功能是:
依靠MP提供的API,
使用 query()
方法获取一个查询构造器。
利用 eq("phone", phone)
方法设置查询条件,查找 phone
字段等于传入参数 phone
的用户。
使用 one()
方法执行查询并获取符合条件的用户对象,如果没有找到匹配的用户则结果为 null
。
实现登录拦截功能
实现思路:
我们可以发现,如今前端的请求是直接访问到我们的控制器中的。但随着业务的扩展,我们会有越来越多的控制器。若前端请求直接到达各个控制器,那我们必须在每个控制器里都实现登录校验的功能,这显然是非常麻烦且不现实的。所以我们想到了使用springMVC里提供的拦截器来为我们进行登录的校验,并且用户如果没进行登录我们也可以防止用户进行一些需要登录权限的行为(买单,发动态等等)。那么第二个问题又来了,如果我们在所有控制器前先把登录校验做了,那后续的控制器需要的用户信息便也拿不到了,所以我们还需要在拦截器里用Threadlocal对用户信息进行线程安全的存储,方便后续使用。
拦截器代码:
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.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
添加拦截器代码:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器,order表示执行顺序,越小越先执行
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
隐藏用户私密信息
实现思路:
在实现以上功能后,通过查看请求返回信息可以返现,当我们返回user对象的时候,将许多私密信息也返回了(密码等),这样非常不安全。并且user对象储存于session内,session是Tomcat的内存空间,存储的不必要信息越多,Tomcat的压力也越大。于是我们需要自己new一个UesrDTO类,这个类只包含用户的必要信息,这样既减轻了服务器的压力,也保护了用户的隐私信息。(还要在先前代码中,登录方法、拦截器、userholder处把user对象转为userDTO,这里就不一一列举了)
重点解析:
1.
//7.保存用户信息到session中 session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class))
使用 BeanUtils.copyProperties
可以方便地将数据从一个对象复制到另一个对象,特别是在需要将实体类转化为 DTO 以便传递给前端或存储在 Session 中时,这是一种非常有效的方法。
session缺点
小故事
在一个小镇上,有三家图书馆(图书馆 A、B 和 C)。每次顾客(用户)借书时,都需要在本馆登记借书记录(Session)。小明第一次去图书馆 A 借了一本书,登记了他的名字和书籍信息。几天后,他想去图书馆 B 借书,却发现那里没有他的借书记录,只能重新填写一遍,浪费了时间。为了避免所有图书馆都重复记录,小镇的书管理员决定在每次借书后,将信息拷贝到其他图书馆。
然而,这带来了两个问题:
- 每家图书馆都存有完整的借书记录,导致记录本拥挤,管理变得繁琐。
- 拷贝过程时常出现延迟,有时小明在图书馆 B 借书时依然找不到他的记录。
为了简化流程,小镇的图书馆决定建立一个中央借书档案库(Redis)。现在,每家图书馆只需把顾客的借书记录发送到这个中心,而不必拷贝到每家图书馆。通过采用中央借书档案库,所有图书馆(Tomcat 实例)不仅减轻了存储负担,还消除了拷贝延迟,让每位顾客都能享受更顺畅的借书体验。
session共享问题
从上面的小故事我们可以看出每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题。这便是session共享问题。
session拷贝问题
为了解决session的共享问题,早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了。
优化为基于Redis登录
登录模块
实现思路:
由于Redis是键值存储数据库,我们需要考虑key是什么,value是什么。
在发送短信的逻辑里我们使用code作为存入session的key,但是session是一个用户独一份的,这并不会干扰到其他用户获取code。可是当我们使用Redis时,我们的数据是共享的,所以我们需要使用独一无二的key,这里选择使用"login:code:"+phone作为我们储存验证码的key,然后value直接用生成的验证码就可以啦。
在存储用户信息时如果我们采用phone:手机号这样的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。所以我们选择 “login:token:”+生成的token作为我们用户的key,那么用户信息该如何存储在value里面呢,这里有两种方法:使用json字符串或使用HashMap,这两种方法各有优劣。
代码(仅展示修改片段):
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 4.保存验证码到 Redis,设置过期时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone ,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//2.校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
String code = loginForm.getCode();
if (code == null || !cacheCode.equals(code)) {
//3.不一致,报错
return Result.fail("验证码错误!");
}
//6.保存用户到Redis(重点)
//6.1 随机生成token
String token = UUID.randomUUID().toString();
//6.2 将User对象转为HashMap
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()));
//6.3存储到Redis
String userKey = LOGIN_USER_KEY+ token;
stringRedisTemplate.opsForHash().putAll(userKey,userMap);
//6.4 设置token有效期
stringRedisTemplate.expire(userKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
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;
}
}
重点解析:
在修改后的UserServiceImpl实现类中我们有几个地方是非常重要的!
1.为了方便与美观,我们会把"login:code:"、"login:token:"类似的key的前缀单独封装成一个RedisConstants类进行使用,代码中的LOGIN_CODE_KEY、LOGIN_USER_KEY便是封装好的常量。
2.StringRedisTemplate
是 Spring Framework 中的一部分,属于 Spring Data Redis 提供的一个模板类,专门用于处理 Redis 中的字符串类型数据。它简化了与 Redis 的交互,使得开发者能够更方便地执行基本的 CRUD 操作。需要注意的是它要求我们的所有key与vaule都是string结构。这便会导致一个问题—我们userDTO中的id是long类型,在代码执行过程中无法将userDTO入Redis里! 所以在这里我们需要进行一系列的转化
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
这是使用 BeanUtil
类中的 beanToMap
方法将 userDTO
转换为一个 Map
。这里传入的是一个新的 HashMap
作为目标映射。- CopyOptions
用于配置在转换过程中需要应用的各种选项。这可以帮助在转换时定制行为。- 这个选项指定在转换时忽略 userDTO
中的 null
值。这意味着如果 userDTO
中的某个属性为 null
,那么在生成的 Map
中将不会包含该属性。 .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
:这里使用了一个字段值编辑器,目的是在将 userDTO
的字段值放入 Map
时进行处理。具体来说,它会将每个字段的值都转换为字符串。这个转换确保了即便字段的原始类型是其他类型(如整数、布尔),最后也都会转化为字符串。
解决token刷新问题
实现思路:
由于我们在Redis里设置token的有效期为30分钟,那么如果不对token进行刷新,30分钟后用户就需要重新登录,这是不太友好的。所以我们需要在拦截器里加入token刷新的逻辑。但是问题又来了,拦截器设置了放行路径,当用户访问那些不需要登录便能使用的路径时,这时候token是不会刷新的。所以我们需要进行双重拦截,我们在先前的拦截器之前加一个新的拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可。
代码(注册拦截器的代码在上面那个拦截器已经一起写了):
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
如果token不存在,则不用刷新,直接放行
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
如果没有这个用户,则把用户放行到下个拦截器,
在下个拦截器拦截该用户没有权限的请求,让这个用户去登录来获取token
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
//这两个拦截器各自需要new一个类,这里图方便摆在一起
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户,则放行
return true;
}
}
以上便是黑马点评短信登录模块的详细解析,如有错漏请指出!共勉!