若依后台管理系统-v3.8.8-登录模块--个人笔记

目录

1、情景导入

2、登录模块代码分析

[3、调用Spring Security 相关接口分析](#3、调用Spring Security 相关接口分析)

4、异步方法同步日志

5、StringBuilder的使用


1、情景导入

各位编程爱好者们,你们好!今天让我们来聊聊若依系统在登录模块的一些业务逻辑,以及本人的一些简介和心得,那么废话不多说,让我们现在开始吧。

以下展示的这段代码,正是若依在业务层对应的登录代码(controller层)

java 复制代码
/**
     * 登录方法
     * 
     * @param loginBody 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody)
    {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
                loginBody.getUuid());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

(1)我们可以看到在参数校验方面,貌似是没有进行校验的,那么如果我在填写账号的时候,使用表情包呢,会怎么样呢,哈哈哈,结果会这样。

tips: 说明一个道理,大家在编写项目的时候呀,入参的这些地方还是得要加上必要的校验的,亦或者你自己使用AOP的方式来拦截,否则在没有想到可能会抛异常的地方没有对其可能抛出的异常进行处理的话,就会出现这种最原始的SQL异常了。这对我们的代码健壮性是不大好的。

(2)AjaxResult ,这个看起来是若依自行封装的一个通用的response响应体。那么他是继承了HashMap的,我们可以很好的获取到本次请求的信息,以及设置相关的信息。其中还提供了链式的方法,可以保障我们以匿名类的方式多次赋值。

2、登录模块代码分析

接下来让我们详细查看一下login方法,以下是主要的源代码(service层)

java 复制代码
/**
     * 登录验证
     * 
     * @param username 用户名
     * @param password 密码
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public String login(String username, String password, String code, String uuid)
    {
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        Authentication authentication = null;
        try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

这里就比较有意思了,让我们拆解成为几个部分聊一下。

(1)validateCaptcha 验证码的校验部分,在这里主要是开启的验证码的校验方式,然后通过配置合适的key在redis容器中进行获取,验证通过也就是没有抛异常,那么我们就正常的在redis中进行删除即可。

java 复制代码
 /**
     * 校验验证码
     * 
     * @param username 用户名
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public void validateCaptcha(String username, String code, String uuid)
    {
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        if (captchaEnabled)
        {
            String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
            String captcha = redisCache.getCacheObject(verifyKey);
            if (captcha == null)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
                throw new CaptchaExpireException();
            }
            redisCache.deleteObject(verifyKey);
            if (!code.equalsIgnoreCase(captcha))
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
                throw new CaptchaException();
            }
        }
    }

(2) loginPreCheck 登录前置校验部分,在登录部分,我们可以看到,这里考虑了蛮多的部分,包括用户密码和账号是均为空,或者密码和账号的长度的问题,以及当前用户登录的Ip是否被禁用等等。但是唯独没有校验用户使用表情包为用户名进行登录,然后没有拦截,在数据库(估计是版本的问题,无法处理emoj的查询),然后出现了上面的那种情况。

java 复制代码
/**
     * 登录前置校验
     * @param username 用户名
     * @param password 用户密码
     */
    public void loginPreCheck(String username, String password)
    {
        // 用户名或密码为空 错误
        if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("not.null")));
            throw new UserNotExistsException();
        }
        // 密码如果不在指定范围内 错误
        if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
                || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        }
        // 用户名不在指定范围内 错误
        if (username.length() < UserConstants.USERNAME_MIN_LENGTH
                || username.length() > UserConstants.USERNAME_MAX_LENGTH)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        }
        // IP黑名单校验
        String blackStr = configService.selectConfigByKey("sys.login.blackIPList");
        if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr()))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("login.blocked")));
            throw new BlackListException();
        }
    }

3、调用Spring Security 相关接口分析

(1)现在让我们把目光聚焦在try的代码块中,这一块值得我们的学习和分析。在这里,若依使用了spring security 的方法,让我们一步一步来进行分析:

(2)首先,我们为每一个用户都new了一个对象,用来管理用户的信息

复制代码
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

(3)在这里我们可以发现,spring的源码对于pricipal是设置了final的关键字的,而他现在是Object类型,那么可以知道,他是想要每一个用户都有一个其独立的标识,当然如果用户传递的是一个对象的话,那么我们只需要保证这个对象的引用地址不被修改,当然的,我们可以修改这个对象里面的属性内容。 好的,那我们在来看看为啥他需要进行保存和清除呢?

我们知道,在这里进行保存无非就是为了在这套校验逻辑的内部中进行二次使用,以下是他在另外的一个地方的代码中被使用到。

通俗的来说就是我们在调用authenticationManager.authenticate()时, 会触发spring security的机制,然后会自动调用到这个方法,下面我将一步步来进行说明。


首先,我们选择到Providermanager的类中,因为这里默认是走它的。

塔默认提供了遍历循环获取到合适的provider,然后再进行调用 authenticate()方法进行下一步的调用。

这时我们就会来到了 AbstractUserDetailsAuthenticationProvider这个类中,但是实际上我们是到DaoAuthenticationProvider这个类中的,但是他在这里并没有重写这个方法,看得出来这里只是直接拿父类的来直接使用了。

那么我们就可以很直观的看到了,那就是相当于封住多了一层用来校验用户是否有创建一个类来实现这个方法,就是来判断用户是否有正常的实现UserDatailsService这个接口,如果没有找到实例,那么将会通过断言的方式抛出异常。从而无法进入到DaoAuthenticcationProvider,进行下一步的操作。

好的下面再让我们把目光放到实现了这个UserDatailsService的类中,我们可以看到passwordService中的validate()中就是我们上面提及到的登录校验中的内部获取上下文的逻辑,至此实现完美闭环。

4、异步方法同步日志

看到这里,是否我刚刚有一个方法是否是不是一直都没有提及,没有,就是他AsyncManager.me().execute()

这个东西在我提及中出现了不少,下面我们就来认识一下他, 其实他的主要作用是:异步记录用户登录成功的日志信息,从而避免登录过程变得十分缓慢。下面让我们来认识一下这个类吧。

java 复制代码
package com.ruoyi.framework.manager;

import java.util.TimerTask;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.ruoyi.common.utils.Threads;
import com.ruoyi.common.utils.spring.SpringUtils;

/**
 * 异步任务管理器
 * 
 * @author ruoyi
 */
public class AsyncManager
{
    /**
     * 操作延迟10毫秒
     */
    private final int OPERATE_DELAY_TIME = 10;

    /**
     * 异步操作任务调度线程池
     */
    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

    /**
     * 单例模式
     */
    private AsyncManager(){}

    private static AsyncManager me = new AsyncManager();

    public static AsyncManager me()
    {
        return me;
    }

    /**
     * 执行任务
     * 
     * @param task 任务
     */
    public void execute(TimerTask task)
    {
        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    }

    /**
     * 停止任务线程池
     */
    public void shutdown()
    {
        Threads.shutdownAndAwaitTermination(executor);
    }
}

(1)在这里使用了SpringUtils.getBean()方法,用来获取名称为"scheduledExecutorService"的实例对象。那么到这里,相信大家就十分好奇了,这个待被注入的对象究竟做了什么呢,让我们来仔细分析一下看看。

(2)好的,我们这里不难发现,若依的作者在这块使用的匿名类的方式来编写代码,我们看到作者在这里使用了 ScheduledThreadPoolExecutor作为实际的返回对象,然后里面的参数分别是初始化的核心线程池大小、以及初始化基础线程工厂,并初始化其未来线程构造的名字和创建守护进程。

在 Java 里,线程分成两种:

  • 用户线程(User Thread):正常的程序工作线程,比如处理请求、运算任务等。

  • 守护线程(Daemon Thread) :一种辅助性质的线程 ,通常用于后台服务,比如监控、垃圾回收(GC 线程就是守护线程)。

  • 关键区别是:

  • 当所有用户线程都结束 时,JVM会自动退出 ,不管还有没有守护线程活着。

  • 也就是说:守护线程不会阻止 JVM 退出 ,用户线程会阻止。

所以在这里,我们使用了守护进程来处理异步日志同步的问题。

(3) 第三个是初始化线程池的拒绝策略,也就是说,当线程池满了,在新的任务无法提交时,线程池会让提交任务的线程去执行这个任务,那么这样以后可以降低任务提交速度,防止过载。

你可能会很好奇就是为啥会降低我们任务的提交速度呢,其实在实际的线程执行过程中,会有一定量的线程,然后主线程是命令和控制其他的属于线程池内的线程去执行对应的指令,那么当其他线程都被占据时,这时能用的线程可能就无了,那么有新的提交任务谁去完成呢,没错,那就是主线程自己干,哪怕我这时整个系统很慢,也不会至于停止的状态。如果你在这里使用了拒绝策略的话,往往可能会导致雪崩式的后果,像那些正常的支付模块,将会可能导致无法避免的后果。

然后这里还写了一个匿名内部类来重写了afterExecute方法,那么afterExecute实际上是ThreadPoolExecutor的一个回调方法,也就是说每当线程池执行了一个任务后,都会执行这个方法,然后这里就会统一处理异常信息,也就是不管是否抛出异常,都会进行检查一遍。正常来说,如果线程池里的子线程出异常(比如RuntimeException),不会自动抛出来,主线程也收不到。

而这里通过重写 afterExecute,在每个任务执行完后,主动检查有没有异常,然后打印或处理。不然子线程偷偷挂了,你都不知道,排查问题非常痛苦。

好的,然后让我们再回到AsyncManager这个类中,他提供了私有化的构造器,其意无非就是不想用户自己构建对象,然后这里初始化了静态的实例化对象,并且主要通过me()这个方法进行显示,然后我们拿到对象后就可以调用这个类里面的方法了。

在 recordLogininfor() 方法内部,由于定义了一个匿名内部类(TimerTask 的实现),因此这里使用 final 修饰参数其实大有来头。我们知道,方法参数本质上是局部变量,一般存放在栈内存中,而内部类的实例(对象)则通常存储在堆内存中。这样就会出现一种情况:主方法 recordLogininfor() 执行完毕,局部变量(参数)随栈帧销毁。但内部类 TimerTask 可能还未执行完毕,甚至是延迟很久才执行。如果内部类要继续访问主方法的局部变量,但局部变量早已经销毁,就会存在悬空引用(dangling reference)的问题。为了避免这种隐患,Java 在编译时要求:传入内部类使用的外部变量**必须是 final 或 "effectively final"(实际上没有被修改过的变量)。这样,Java 编译器可以拷贝一份局部变量的副本到内部类对象中,确保即使外部栈帧销毁,内部类仍然可以安全访问这个值。而且 final 保证这个值不会在内部类执行前被修改,副本是可靠的。当然,即使你不显式写 final,只要你不修改这个变量,Java 编译器也会默认当成 effectively final 来处理,允许内部类引用。所以,这里显式加上 final,是为了提醒开发者注意变量不可变性的重要性,同时也体现了良好的编码规范。

tips:当 Java 编译器拷贝一份局部变量的副本给内部类时,这个副本是存放在内部类对象的堆内存里的,而不是栈。因为内部类(比如 TimerTask)本身就是一个对象实例,对象实例是存储在堆(heap)上的。副本跟着对象的生命周期走,因此也在堆上。栈(stack)是线程私有的,每次方法调用都会有一个栈帧,方法一结束,栈帧就被销毁了。如果副本还放在栈里,当方法退出后,副本也跟着栈帧销毁了,那内部类引用到的副本就成了野指针(dangling pointer),这就是严重的安全问题。把副本存在堆内存中,能保证即使方法退出,内部类对象依然可以正常使用这份副本数据。

5、StringBuilder的使用

此外这个方法内部还有一个使用了StringBuilder类的这个可以大量避免了直接创建String来进行字符串的拼接,导致大量的浪费不必有的内存空间。

java 复制代码
package com.ruoyi.framework.manager.factory;

import java.util.TimerTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.LogUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.AddressUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.system.domain.SysLogininfor;
import com.ruoyi.system.domain.SysOperLog;
import com.ruoyi.system.service.ISysLogininforService;
import com.ruoyi.system.service.ISysOperLogService;
import eu.bitwalker.useragentutils.UserAgent;

/**
 * 异步工厂(产生任务用)
 * 
 * @author ruoyi
 */
public class AsyncFactory
{
    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");

    /**
     * 记录登录信息
     * 
     * @param username 用户名
     * @param status 状态
     * @param message 消息
     * @param args 列表
     * @return 任务task
     */
    public static TimerTask recordLogininfor(final String username, final String status, final String message,
            final Object... args)
    {
        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
        final String ip = IpUtils.getIpAddr();
        return new TimerTask()
        {
            @Override
            public void run()
            {
                String address = AddressUtils.getRealAddressByIP(ip);
                StringBuilder s = new StringBuilder();
                s.append(LogUtils.getBlock(ip));
                s.append(address);
                s.append(LogUtils.getBlock(username));
                s.append(LogUtils.getBlock(status));
                s.append(LogUtils.getBlock(message));
                // 打印信息到日志
                sys_user_logger.info(s.toString(), args);
                // 获取客户端操作系统
                String os = userAgent.getOperatingSystem().getName();
                // 获取客户端浏览器
                String browser = userAgent.getBrowser().getName();
                // 封装对象
                SysLogininfor logininfor = new SysLogininfor();
                logininfor.setUserName(username);
                logininfor.setIpaddr(ip);
                logininfor.setLoginLocation(address);
                logininfor.setBrowser(browser);
                logininfor.setOs(os);
                logininfor.setMsg(message);
                // 日志状态
                if (StringUtils.equalsAny(status, Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER))
                {
                    logininfor.setStatus(Constants.SUCCESS);
                }
                else if (Constants.LOGIN_FAIL.equals(status))
                {
                    logininfor.setStatus(Constants.FAIL);
                }
                // 插入数据
                SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);
            }
        };
    }

    /**
     * 操作日志记录
     * 
     * @param operLog 操作日志信息
     * @return 任务task
     */
    public static TimerTask recordOper(final SysOperLog operLog)
    {
        return new TimerTask()
        {
            @Override
            public void run()
            {
                // 远程查询操作地点
                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
                SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
            }
        };
    }
}
相关推荐
蒸蒸yyyyzwd7 小时前
AI应用相关可能的面试问题
笔记
叼烟扛炮8 小时前
[Git 实战] 学习笔记:从生成 SSH 密钥到解决 TortoiseGit 推送报错全指南
linux·运维·笔记·git·学习
蒸蒸yyyyzwd8 小时前
学习笔记 八股手撕
笔记·学习
揣一尾游鱼yu10 小时前
Spring上(IOC)笔记
java·笔记·spring·ioc
小李不想当小白11 小时前
BKP备份寄存器与RTC实时时钟(STM32标准库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
瑞码空间13 小时前
JSP与Servlet核心开发与CRUD实战笔记
java·笔记·servlet·jsp
La Pulga14 小时前
【FreeRTOS源码学习笔记】链表的实现
数据结构·笔记·stm32·mcu·链表·嵌入式·freertos
wuyoutrans16 小时前
以色列专利OA答复翻译,重点要避开哪些坑?
笔记
宝绿特塑业再生科技16 小时前
HDPE再生颗粒户外家具表面粉化机理与改性技术全解析
笔记·户外家具·再生塑料·塑木桌椅
凉、介17 小时前
深入理解 ARMv7-A | Generic Timer
笔记·学习·嵌入式·arm