项目搭建

前期准备

导入SQL

CREATE TABLE `tb_user` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
  `password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '密码,加密存储',
  `nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '昵称,默认是用户id',
  `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '人物头像',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniqe_key_phone` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1011 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

创建项目

导入依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
   <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
        <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.3</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
        <version>8.0.33</version>
    </dependency>
    
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!--hutool-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.7.17</version>
    </dependency>
</dependencies>

编写启动类

@MapperScan("com.liang.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

编写配置文件

server:
  port: 8081
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: #配置自己的数据库url
    username: #配置自己的数据库用户名
    password: #配置自己的密码

编写实体类

/**
 * 登录信息
 */
@Data
public class LoginFormDTO {
    private String phone;
    private String code;

}

/**
 * 统一结果返回
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

/**
 * User实体类 对应数据库表tb_user
 */
@Data
@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;
}

/**
 * 存储用户非敏感信息
 */
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

编写controller层

/**
 * User对象前端控制器
 */
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    /**
     * 发送手机验证码
     * @param phone 手机号
     * @param session
     * @return
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        return userService.sendCode(phone, session)?Result.ok():Result.fail("手机号码不合规");
    }

    /**
     *  登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        return userService.login(loginForm, session) ? Result.ok() : Result.fail("手机号或验证码错误");
    }

编写service层

public interface IUserService extends IService<User> {

    boolean sendCode(String phone, HttpSession session);

    boolean login(LoginFormDTO loginForm, HttpSession session);
}

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public boolean sendCode(String phone, HttpSession session) {
     
        return true;
    }

    @Override
    public boolean login(LoginFormDTO loginForm, HttpSession session) {
   
        return true;
    }
}

Session实现登录

基于session实现登录流程

  • 发送验证码

    • 校验手机号是否合法

      • 合法,生成验证码,并保存到session中、发送验证码给用户
      • 不合法,提示用户手机号不合法

发送验证码流程

@Override
public boolean sendCode(String phone, HttpSession session) {
        //获取手机号,验证手机号是否合规
        boolean mobile = PhoneUtil.isMobile(phone);
        //不合规,则提示
        if (!mobile){
            return false;
        }
        //生成验证码
        String code = RandomUtil.randomNumbers(6);
        //将验证码保存到session中
        session.setAttribute("code",code);
        //发送验证码
        System.out.println("验证码:" + code);
        return true;
}

验证码登录、注册

  • 验证手机号是否合法,验证验证码是否正确

    • 手机号不合法或验证码不正确,提示用户
  • 验证成功后,查看该用户信息是否在数据库中

    • 该用户信息在数据库中,则表明该用户是登录

      • 用户信息保存到session中
    • 该用户信息不在数据库中,则表明该用户是注册

      • 在数据库中存储用户信息
      • 用户信息保存到session中

(将用户信息存储在session中,主要是方便后序获取当前登录信息)

验证码登录注册

@Override
 public boolean login(LoginFormDTO loginForm, HttpSession session) {
        //获取手机号
        String phone = loginForm.getPhone();
        //验证手机号是否合理
        boolean mobile = PhoneUtil.isMobile(phone);
        //如果不合理 提示
        if (!mobile){
            //提示用户手机号不合理
            return false;
        }
        //手机号合理 进行验证码验证
        String code = loginForm.getCode();
        String sessionCode = session.getAttribute("code").toString();
        //如果验证码输入的是错误的  提示
        if (!code.equals(sessionCode)){
            return false;
        }
        //如果验证码也正确 那么通过手机号进行查询
        User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
        // 数据库中没查询到用户信息
        if (ObjectUtil.isNull(user)){
            user = new User();
            user.setPhone(phone);
            user.setNickName("user_"+ RandomUtil.randomString(10));
            this.save(user);
        }
        // 将该用户信息存入session中
        // 简化user,只存储必要信息以及不重要的信息
        UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
        session.setAttribute("user", userDTO);
        return true;
 }

校验登录状态

  • 用户发送请求时,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中获取用户信息,

    • 没获取到用户信息 则拦截,需要拦截器
    • 获取到用户信息,则将用户信息保存到ThreadLocal中,再放行

校验登录状态

  • 自定义拦截器,实现HandlerInterceptor接口
public class LoginInterceptor implements HandlerInterceptor {

    /**
     * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        UserDTO user = (UserDTO) session.getAttribute("user");
        //判断是否在session中获取到了用户
        if (ObjectUtil.isNull(user)){
            return false;
        }
        UserHolder.saveUser(user);
        return true;
    }

    /**
     * postHandle方法在控制层方法执行后,视图解析前执行(可以在这里修改控制层返回的视图和模型)
     * @param request
     * @param response
     * @param handler
     * @param modelAndView
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    /**
     * fterCompletion方法在视图解析完成后执行,多用于释放资源
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}
  • 实现WebMvcConfigurer接口,通过重写addInterceptors方法添加自定义拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加拦截器
        registry.addInterceptor(new LoginInterceptor())
                //放行资源
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                // 设置拦截器优先级
                .order(1);
    }
}
注意隐藏用户敏感信息:
我们应当在返回用户信息之前,将用户敏感信息进行隐藏,采用的核心思路就是创建UserDTO类,该类没有用户敏感信息,在返回用户信息之前,将有用户敏感新的的User对象转换为没有敏感信息的UserDTO对象,就可以有效的避免用户信息被泄露的问题。

Session存在问题

  • 当单个tomcat服务器时,服务器崩溃,无法提供足够的处理能力时,系统可能不能使用,为了避免这些情况,提高系统的可用性、可伸缩性等,tomcat将会以集群的形式部署,集群部署的主要优势有:高可用性、可伸缩性、负载均衡、无中断升级。
  • 集群部署的tomcat又面临新的问题,即session共享问题,由于每个tomcat都有一份属于自己的session,某个用户第一次访问tomcat时,把自己的信息存放到了编号01的tomcat服务器的session中,当第二次访问时,没有访问01服务器,而是访问到了其他tomcat服务器,而其他tomcat服务器没有该用户存放的session,此时整个登录拦截都会出现问题。

    • 解决方式:

      • 早期方案是session拷贝,即每当任意一台服务器的session修改时,都会同步到其他的tomcat服务器的session中,实现session共享。但此方式存在问题:1、session数据拷贝时,可能会出现延时;2、每台服务器中都有完整的一份session数据,服务器压力较大
      • 现在方案是基于redis来完成,即把session换成redis,redis数据本身就是共享的,可以避免session共享问题。而且redis中数据是 key-value方式存储 和session一样便于操作,且都默认 存储在内存 中,响应速度快。

集群模型

客户端发送请求,通过nginx负载均衡到下游的tomcat服务器(一台4核8G的tomcat服务器,在优化和处理简单业务的加持下,处理的并发量很有限),经过nginx负载均衡分流后,利用集群支撑整个项目,同时nginx在部署了前端项目后,做到了动静分离,进一步降低tomcat的压力,如果让tomcat直接访问mysql,一般16、32核CPU、32/64G内存,并发量在4k~7K左右,在高并发场景下也是容易崩溃,所有一般会使用mysql集群,同时为了进一步降低mysql压力,增加访问性能,一般会加入redis集群,以提供更好地服务。

Redis代替Session

  • redis中设计key

    • 在使用session时,每个用户都会有自己的session,这样虽然验证码的键都是“code”,但是相互不影响,从而确保每个用户获取到的验证码只能够自己使用,当使用redis时,redis的key是共享的,不分用户,就要求在redis中存储验证码时,不能直接将验证码的键设置为"code",这样无法保证其唯一性。
  • redis中设计value

    • String结构:以Json字符串来存储,比较直观
    • Hash结构:,每个对象中每个字段独立存储,可以针对单个字段做CRUD
    • 到底该使用redis中什么数据类型存储数据,主要需要看数据样式和使用方式,一般会考虑使用String、Hash,String存储时,会多占一点内存空间,则相对来说Hash存储时,会少占用一点内存空间。

Redis实现登录

发送验证码

发送验证码-Redis

添加Redis

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

设置Redis的连接信息

spring:
  redis:
    host: 192.168.175.128
    port: 6379
    password: liang
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s

增加相关常量

/**
 * 保存验证码的redis中的key
 */
public static final String LOGIN_CODE_KEY = "login:code:";
/**
 * 验证码的过期时间
 */
public static final Long LOGIN_CODE_TTL = 2L;

修改Service层

@Autowired
StringRedisTemplate stringRedisTemplate;

@Override
public boolean sendCode(String phone, HttpSession session) {
    //获取手机号,验证手机号是否合规
    boolean mobile = PhoneUtil.isMobile(phone);
    //不合规,则提示
    if (!mobile){
        return false;
    }
    //生成验证码
    String code = RandomUtil.randomNumbers(6);
    //保存验证码到redis,并设置过期时间
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //发送验证码,这里就通过打印验证码模拟了下发送验证码
    System.out.println("验证码:" + code);
    return true;
}

修改Controller层

@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    String uuid = userService.sendCode(phone, session);
    return uuid.equals("") ? Result.fail("手机号码不合规"): Result.ok(uuid);
}

验证码登录、注册

验证码登录注册-Redis

增加相关常量

public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;

修改Service层

@Override
public String login(LoginFormDTO loginForm, HttpSession session) {
    //获取手机号
    String phone = loginForm.getPhone();
    //验证手机号是否合理
    boolean mobile = PhoneUtil.isMobile(phone);
    //如果不合理 提示
    if (!mobile){
        //提示用户手机号不合理
        return "";
    }
    //手机号合理 进行验证码验证
    String code = loginForm.getCode();
    //从redis中获取验证码
    String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    //如果验证码输入的是错误的  提示
    if (!code.equals(redisCode)){
        return "";
    }
    //如果验证码也正确 那么通过手机号进行查询
    User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
    // 数据库中没查询到用户信息
    if (ObjectUtil.isNull(user)){
        user = new User();
        user.setPhone(phone);
        user.setNickName("user_"+ RandomUtil.randomString(10));
        this.save(user);
    }
    // 将用户信息保存到Redis中,注意避免保存用户敏感信息
    UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
    // 设置UUID保存用户信息
    String uuid = IdUtil.fastSimpleUUID();
    // 将user对象转化为Map,同时将Map中的值存储为String类型的
    Map<String, Object> userDTOMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create().ignoreNullValue()
                    .setFieldValueEditor((key, value) -> value.toString()));
    stringRedisTemplate.opsForHash().putAll( LOGIN_USER_KEY + uuid, userDTOMap);
    //设置过期时间
    stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 通过UUID生成简单的token
    String token = uuid + userDTO.getId();
    return token;
}

修改Controller层

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    String token = userService.login(loginForm, session);
    return StrUtil.isNotBlank(token) ? Result.ok(token) : Result.fail("手机号或验证码错误");
}

校验登录状态

校验登录状态-Redis

修改LoginInterceptor拦截器

private StringRedisTemplate stringRedisTemplate;

/**
 * 构造函数
 * @param stringRedisTemplate
 */
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
}

/**
 * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
 * @param request
 * @param response
 * @param handler
 * @return
 * @throws Exception
 */
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //从请求头中获取token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)){
        return false;
    }

    String uuid = token.substring(0,token.lastIndexOf("-"));
    System.out.println(uuid);
    //从redis中获取值
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
    if (ObjectUtil.isNull(entries)){
        return false;
    }
    //将map转化为UserDTO对象
    UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
    //将用户信息保存到 ThreadLocal
    UserHolder.saveUser(userDTO);
    return true;
}


@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加拦截器
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                //放行资源
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                // 设置拦截器优先级
                .order(1);
    }
}

登录状态的刷新问题

因为设置了redis中存储的用户的有效期,所以在用户访问界面的时,需要更新token令牌的存活时间,例如修改LoginInterceptor拦截器,在此拦截器中刷新过期时间
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //从请求头中获取token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)){
        return false;
    }

    String uuid = token.substring(0,token.lastIndexOf("-"));
    System.out.println(uuid);
    //从redis中获取值
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
    if (ObjectUtil.isNull(entries)){
        return false;
    }
    //将map转化为UserDTO对象
    UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
    //将用户信息保存到 ThreadLocal
    UserHolder.saveUser(userDTO);
    //刷新token有效期
    stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
    return true;
}

但是需要注意的是,自定义的登录拦截器只是针对需要登录访问的请求进行了拦截,如果用户访问没被拦截的请求,该拦截器不会生效,则token令牌不能进行更新,当用户长时间访问不需要登录的页面,token令牌失效,再去访问被拦截的请求,则需要重新登录,这是不合理的。所有我们还需要在定义一个拦截器,进行token令牌刷新。

拦截器优化-Redis

刷新令牌的Interceptor

/**
 * 刷新令牌的拦截器
 * @author liang
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //从请求头中获取token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)){
            return false;
        }
        String uuid = token.substring(0, token.lastIndexOf("-"));
        //从Redis中获取值
        Map<Object, Object> userMap = redisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
        if (ObjectUtil.isNull(userMap)){
            return false;
        }
        //将map转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //将用户信息保存到 ThreadLocal
        UserHolder.saveUser(userDTO);
        //刷新token有效期
        redisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

修改登录的Interceptor

public class LoginInterceptor implements HandlerInterceptor {

    /**
     * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = UserHolder.getUser();
        return ObjectUtil.isNotNull(user);
    }
}    

修改WebMvcConfigurer配置类

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate));
        registry.addInterceptor(new LoginInterceptor())
                //放行资源
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                // 设置拦截器优先级
                .order(1);
    }
}
最后修改:2023 年 07 月 03 日
-