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来调用主系统中的日志服务接口来实现日志的保存。由于若依的日志系统结构清晰明确,代码简单,可以很好的融入到其他系统中,也可以在原有的基础上,进行二次开发。