ruoyi-cloud-log源码分析

1. ruoyi-cloud-log概述

若依cloud的日志系统比较简单,分为两部分,分别是操作日志和登录日志。操作日志记录了用户在各菜单页的行为,登录日志记录了用户的登录情况,密码输入错误次数等内容。

2. 实现原理

ruoyi的日志系统是一个独立的模块,可以作为一个jar包独立引入。日志模块的内容并不多,主要分成几个部分:

  • Annotation

  • Aspect

  • Enums

  • Filter

  • Service

2.1 源码分析

2.1.1  Annotation

核心注解就一个——@Log

该注解提供了多个参数,包括模块,功能,操作人类别,是否保存请求的参数,是否保存响应的参数

该注解可以声明在参数上,方法上。

@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log
{
    /**
     * 模块
     */
    public String title() default "";

    /**
     * 功能
     */
    public BusinessType businessType() default BusinessType.OTHER;

    /**
     * 操作人类别
     */
    public OperatorType operatorType() default OperatorType.MANAGE;

    /**
     * 是否保存请求的参数
     */
    public boolean isSaveRequestData() default true;

    /**
     * 是否保存响应的参数
     */
    public boolean isSaveResponseData() default true;
}

看一个注解的使用实例:

/**
 * 新增菜单
 */
@RequiresPermissions("system:menu:add")
@Log(title = "菜单管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu)
{
    if (UserConstants.NOT_UNIQUE.equals(menuService.checkMenuNameUnique(menu)))
    {
        return error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
    }
    else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
    {
        return error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
    }
    menu.setCreateBy(SecurityUtils.getUsername());
    return toAjax(menuService.insertMenu(menu));
}

2.1.2  Aspect

利用spring-aop实现的日志记录,就一个切面——LogAspect,负责操作日志记录处理

以下是源码详解

@Aspect
@Component
public class LogAspect
{
    //从工厂中获取日志操作对象,注入LogAspect对象,相当于使用了@Slf4j
    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);

    /** 排除敏感属性字段 */
    public static final String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" };

    //openFeign远程调用日志服务
    @Autowired
    private AsyncLogService asyncLogService;

    /**
     * 处理完请求后执行
     *
     * @param joinPoint 切点
     */
    @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
    public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult)
    {
        handleLog(joinPoint, controllerLog, null, jsonResult);
    }

    /**
     * 拦截异常操作
     * 
     * @param joinPoint 切点
     * @param e 异常
     */
    @AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
    public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e)
    {
        handleLog(joinPoint, controllerLog, e, null);
    }
    
    
    /**
     * 切面处理逻辑
     * 
     * @param joinPoint 切点
     * @param controllerLog 注解
     * @param e 异常
     * @param jsonResult json格式的respoonce参数
     */
    //这里是真正的日志处理切面的操作逻辑
    protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult)
    {
        try
        {
            //这里的代码很好理解,新建一个日志对象,填入本次操作的用户信息,操作结果,请求ip等信息
            // *========数据库日志=========*//
            SysOperLog operLog = new SysOperLog();
            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
            // 请求的地址
            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
            operLog.setOperIp(ip);
            operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
            String username = SecurityUtils.getUsername();
            if (StringUtils.isNotBlank(username))
            {
                operLog.setOperName(username);
            }

            // 如果请求出现了异常,这里填入异常信息
            if (e != null)
            {
                operLog.setStatus(BusinessStatus.FAIL.ordinal());
                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
            }
            // 这里出现joinPoint的使用
            // 利用JoinPoint,可以把被代理方法的信息带进来
            // 设置方法名称
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = joinPoint.getSignature().getName();
            operLog.setMethod(className + "." + methodName + "()");
            // 设置请求方式
            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
            // 处理设置注解上的参数
            getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
            // 保存数据库
            asyncLogService.saveSysLog(operLog);
        }
        catch (Exception exp)
        {
            // 记录本地异常日志
            log.error("异常信息:{}", exp.getMessage());
            exp.printStackTrace();
        }
    }

    /**
     * 获取注解中对方法的描述信息 用于Controller层注解
     * 
     * @param log 日志 即标注在类或方法上的Log注解
     * @param operLog 操作日志
     * @throws Exception
     */
    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception
    {
        // 此方法的作用是获取Log注解中填入的参数
        // 例如 @Log(title = "菜单管理", businessType = BusinessType.INSERT)
        
        
        // 设置action动作
        operLog.setBusinessType(log.businessType().ordinal());
        // 设置标题
        operLog.setTitle(log.title());
        // 设置操作人类别
        operLog.setOperatorType(log.operatorType().ordinal());
        // 是否需要保存request,参数和值
        if (log.isSaveRequestData())
        {
            // 获取参数的信息,传入到数据库中。
            setRequestValue(joinPoint, operLog);
        }
        // 是否需要保存response,参数和值
        if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult))
        {
            operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000));
        }
    }

    /**
     * 获取请求的参数,放到log中
     * 
     * @param operLog 操作日志
     * @throws Exception 异常
     */
    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception
    {
        String requestMethod = operLog.getRequestMethod();
        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
        {
            String params = argsArrayToString(joinPoint.getArgs());
            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
        }
        else
        {
            Map<?, ?> paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest());
            operLog.setOperParam(StringUtils.substring(JSON.toJSONString(paramsMap, excludePropertyPreFilter()), 0, 2000));
        }
    }
    
    // 以下是一些辅助方法,不涉及核心的逻辑

    /**
     * 参数拼装
     */
    private String argsArrayToString(Object[] paramsArray)
    {
        String params = "";
        if (paramsArray != null && paramsArray.length > 0)
        {
            for (Object o : paramsArray)
            {
                if (StringUtils.isNotNull(o) && !isFilterObject(o))
                {
                    try
                    {
                        String jsonObj = JSON.toJSONString(o, excludePropertyPreFilter());
                        params += jsonObj.toString() + " ";
                    }
                    catch (Exception e)
                    {
                    }
                }
            }
        }
        return params.trim();
    }

    /**
     * 忽略敏感属性
     */
    public PropertyPreExcludeFilter excludePropertyPreFilter()
    {
        return new PropertyPreExcludeFilter().addExcludes(EXCLUDE_PROPERTIES);
    }

    /**
     * 判断是否需要过滤的对象。
     * 
     * @param o 对象信息。
     * @return 如果是需要过滤的对象,则返回true;否则返回false。
     */
    @SuppressWarnings("rawtypes")
    public boolean isFilterObject(final Object o)
    {
        Class<?> clazz = o.getClass();
        if (clazz.isArray())
        {
            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
        }
        else if (Collection.class.isAssignableFrom(clazz))
        {
            Collection collection = (Collection) o;
            for (Object value : collection)
            {
                return value instanceof MultipartFile;
            }
        }
        else if (Map.class.isAssignableFrom(clazz))
        {
            Map map = (Map) o;
            for (Object value : map.entrySet())
            {
                Map.Entry entry = (Map.Entry) value;
                return entry.getValue() instanceof MultipartFile;
            }
        }
        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
                || o instanceof BindingResult;
    }
}

通过一个注解和一个切面,通过spring-aop就可以实现日志的增加。注解应用在controller层的方法上,在方法执行结束后,将方法的执行记录及结果存入数据库。对于出现了异常的方法,可以在抛出异常后,将异常信息也存入数据库。

2.1.3 Enum

包括三个枚举类BusinessStatus(操作状态),BusinessType(业务操作类型),OperatorType(操作人类别)

/**
 * 业务操作类型
 * 
 * @author ruoyi
 */
public enum BusinessType
{
    /**
     * 其它
     */
    OTHER,

    /**
     * 新增
     */
    INSERT,

    /**
     * 修改
     */
    UPDATE,

    /**
     * 删除
     */
    DELETE,

    /**
     * 授权
     */
    GRANT,

    /**
     * 导出
     */
    EXPORT,

    /**
     * 导入
     */
    IMPORT,

    /**
     * 强退
     */
    FORCE,

    /**
     * 生成代码
     */
    GENCODE,

    /**
     * 清空数据
     */
    CLEAN,
}

2.1.4 Filter

有一个过滤器PropertyPreExcludeFilter,用于排除JSON敏感属性,该过滤器继承了SimplePropertyPreFilter,SimplePropertyPreFilter是fastjson提供的json过滤器,实现了PropertyPreFilter接口。用于过滤掉json字符串中不需要的键值对。

这里调用了SimplePropertyPreFilter中的getExcludes方法,获取需要排除的集合,随后批量过滤掉不需要的字段。

/**
 * 排除JSON敏感属性
 * 
 * @author ruoyi
 */
public class PropertyPreExcludeFilter extends SimplePropertyPreFilter
{
    public PropertyPreExcludeFilter()
    {
    }

    public PropertyPreExcludeFilter addExcludes(String... filters)
    {
        for (int i = 0; i < filters.length; i++)
        {
            this.getExcludes().add(filters[i]);
        }
        return this;
    }
}

2.1.5 Service

拥有一个AsyncLogService,该服务用于异步调用日志服务。由于日志的储存通常独立于业务,所以可以放心大胆地异步执行日志的保存。

/**
 * 异步调用日志服务
 * 
 * @author ruoyi
 */
@Service
public class AsyncLogService
{
    @Autowired
    private RemoteLogService remoteLogService;

    /**
     * 保存系统日志记录
     */
    @Async
    public void saveSysLog(SysOperLog sysOperLog)
    {
        //该方法添加了@Async注解,可以异步执行
        remoteLogService.saveLog(sysOperLog, SecurityConstants.INNER);
    }
}

2.2 操作日志

将@Log标记在controller的接口方法上,当用户触发接口方法时,会记录用户的操作。上文已有示例。

2.3 登录日志

ruoyi的登录模块在独立的微服务里面ruoyi-auth里面,这个模块并没有使用ruoyi-log模块作为依赖。只是在用户进行登录登出注册等操作的时候,通过ruoyi-api-system中的日志服务进行feign远程调用。当然,其实在ruoyi-log中,也是通过ruoyi-api-system中的RemoteLogService来保存日志的。

ruoyi的openfeign中使用了fallback来进行服务降级,即在服务不可用的时候自动调用fallback指定的处理办法。

RemoteLogService:

/**
 * 日志服务
 * 
 * @author ruoyi
 */
@FeignClient(contextId = "remoteLogService", value = ServiceNameConstants.SYSTEM_SERVICE, fallbackFactory = RemoteLogFallbackFactory.class)
public interface RemoteLogService
{
    /**
     * 保存系统日志
     *
     * @param sysOperLog 日志实体
     * @param source 请求来源
     * @return 结果
     */
    @PostMapping("/operlog")
    public R<Boolean> saveLog(@RequestBody SysOperLog sysOperLog, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);

    /**
     * 保存访问记录
     *
     * @param sysLogininfor 访问实体
     * @param source 请求来源
     * @return 结果
     */
    @PostMapping("/logininfor")
    public R<Boolean> saveLogininfor(@RequestBody SysLogininfor sysLogininfor, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
}

这里的两个远程调用方法是调用的ruoyi-system模块里面的新增日志和新增登录日志方法

@InnerAuth
@PostMapping
public AjaxResult add(@RequestBody SysOperLog operLog)
{
    return toAjax(operLogService.insertOperlog(operLog));
}
@InnerAuth
@PostMapping
public AjaxResult add(@RequestBody SysLogininfor logininfor)
{
    return toAjax(logininforService.insertLogininfor(logininfor));
}

回调工厂:当远程调用服务失败(例如被负载过高被熔断的时候),执行的处理逻辑

/**
 * 日志服务降级处理
 * 
 * @author ruoyi
 */
@Component
public class RemoteLogFallbackFactory implements FallbackFactory<RemoteLogService>
{
    private static final Logger log = LoggerFactory.getLogger(RemoteLogFallbackFactory.class);

    @Override
    public RemoteLogService create(Throwable throwable)
    {
        log.error("日志服务调用失败:{}", throwable.getMessage());
        return new RemoteLogService()
        {
            @Override
            public R<Boolean> saveLog(SysOperLog sysOperLog, String source)
            {
                return null;
            }

            @Override
            public R<Boolean> saveLogininfor(SysLogininfor sysLogininfor, String source)
            {
                return null;
            }
        };

    }
}

3. 总结

若依的日志系统其实并不复杂,最核心的部分就是一个注解和一个切面,以及通过openFeign来调用主系统中的日志服务接口来实现日志的保存。由于若依的日志系统结构清晰明确,代码简单,可以很好的融入到其他系统中,也可以在原有的基础上,进行二次开发。

### RuoYi-Cloud 中 `ruoyi-file` 模块的相关信息 #### 文件存储功能概述 `ruoyi-file` 是 RuoYi-Cloud 项目中的一个重要模块,主要用于处理文件上传、下载以及管理等功能。该模块支持多种文件存储方式,包括本地磁盘、阿里云OSS、腾讯云COS等。 #### 配置说明 为了使 `ruoyi-file` 正常工作,在项目的配置文件 application.yml 或者 application.properties 中需要设置相应的参数来指定使用的文件存储策略[^1]: 对于 YAML 格式的配置如下所示: ```yaml file: type: qiniu # 存储类型:local(本地),minio,qiniu,aliyun,tencent domain: http://cdn.domain.com/ # 域名前缀 path: /profile # 保存路径 ``` 如果采用 properties 方式,则应按照下面的形式书写: ```properties file.type=qiniu file.domain=http://cdn.domain.com/ file.path=/profile ``` #### 添加子模块流程 当向 RuoYi-Cloud 项目中增加新的子模块时,可以参照已有的 `ruoyi-modules-system` 的 pom 文件创建一个新的 POM 文件,并调整其中的 artifactId 和其他必要属性以适应新加入的功能需求。 另外需要注意的是,在根目录下的 ruoyi-module/pom.xml 文件里也要相应地注册这个新增加的 module 节点,例如 `<module>ruoyi-test</module>` 这样的形式[^2]。 #### 扩展与维护提示 针对某些特定场景下可能涉及到对第三方库版本更新的需求,比如更换 spring-boot-admin-server-ui 版本的情况,可以通过重新编译并替换旧版 JAR 包的方式来实现自定义化定制[^3]。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值