目录
需求背景:
管理后台一般会有一些比较敏感的操作,这些配置操作的修改会影响线上功能的表现,进而影响用户的使用体验。为了记录管理后台中的敏感操作记录,实现操作记录可追溯,数据可恢复等目的;对后台的操作进行记录是十分必要的。
此外,该日志系统需要满足易于接入、易于扩展的要求。
方案调研:
在调研日志系统的实现方案过程中,发现目前的日志记录方案主要有:通过拦截器实现记录接口请求日志;通过AOP+注解的方案实现对注解标注接口调用的日志记录等。经过对比,发现上述两种方案接入都比较简单;就扩展性而言,AOP+注解的方案的可扩展性更好一些。综合对比后,在本次的后台日志管理系统中采用了AOP+注解的方案来完成系统的设计和实现。
系统设计:
系统类图如下:
ZxbSysConfigLogService为系统日志记录实现类;ZXBToolsLog为知学宝工具箱后台日志注解,通过该类可以标注需要记录操作记录的方法,并通过注解方法对操作基本属性进行说明;ZXBToolLogAspect为知学宝工具箱日志切面,该类中定义了所有切点(一般定义通过ZXBToolsLog注解所标记的方法为切点),并定义了切点的通知方式以及通知处理逻辑(调用系统日志服务实现中的保存日志方法记录日志)。
系统实体图:
在该系统中主要有一个日志的实体对象,其实体图如下:
系统关键时序:
日志系统的关键时序主要为用户操作到日志切面处理再到调用底层服务插入日志对象。时序图如下:
系统实现:
首先在API中完成注解、日志处理接口、log领域对象及其他相关常量的定义。在本次知学宝后台日志系统中主要是完成如下领域或者接口的定义:ZxbSysConfigLog领域、ZxbSysConifgLogService接口定义了日志保存及查询的方法、ZxbToolsLog注解类以及相关的常量类或者枚举类的定义实现。
package com.example.annotation;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import com.example.consts.log.ConfigModuleConstant;
import com.example.consts.log.OperateTypeEnum;
/**
* 工具箱日志注解
*/
@Documented
@Retention(RUNTIME)
@Target({ METHOD, PARAMETER })
public @interface ZXBToolsLog {
/**
* 日志涉及模块的模块名称,可不限于常量类指定的类型
* @return 模块名
*/
public String moduleName() default ConfigModuleConstant.EMPTY;
/**
* 操作描述
* @return
*/
public String operateDesc() default "";
/**
* 操作类型
* @return
*/
public OperateTypeEnum operateType() default OperateTypeEnum.UNKNOWN;
}
其次在service中实现ZxbSysConfigLogService接口中定义的方法,实现基本的日志插入和查询逻辑,底层service服务使用dubbo+spring+mybtis的开发框架进行实现。底层日志服务实现后,通过zk注册为dubbo provider以供其他需要记录日志的服务调用相关服务方法,实现其他组件无需关注日志记录实现,只需关注服务中需要记录日志的业务方法,并在这些业务方法上加上ZxbToolsLog注解,然后实现相关的日志切面逻辑即可实现日志记录。
package com.example.tool.syslog.aspect;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.example.util.JSONUtils;
import com.example.annotation.ZXBToolsLog;
import com.example.model.config.ZxbSysConfigLog;
import com.example.service.config.ZxbSysConfigLogService;
import com.example.tool.common.dto.CurrentAdmin;
import com.example.tool.common.util.WebUtil;
/**
* tool后台日志切面
*/
@Aspect
@Component
public class ZXBToolLogAspect {
/**
* logger
*/
private Logger logger = LoggerFactory.getLogger(ZXBToolLogAspect.class);
@Autowired
private ZxbSysConfigLogService zxbSysConfigLogService;
/**
* localhost addr
*/
private static final String LOCAL_ADDR = "0:0:0:0:0:0:0:1";
/**
* localhost ip
*/
private static final String LOCAL_IP = "127.0.0.1";
/**
* 参数名discover
*/
private static LocalVariableTableParameterNameDiscoverer paramDiscoverer = new LocalVariableTableParameterNameDiscoverer();
/**
* 定义切点:所有被ZXBToolLog注解标注的方法
*/
@Pointcut("@annotation(com.example.annotation.ZXBToolsLog)")
public void defineLogPointCut(){}
/**
* 后置通知
* @param joinPoint
*/
@After("defineLogPointCut()")
public void doAfterAdviser(JoinPoint joinPoint){
ZxbSysConfigLog log = new ZxbSysConfigLog();
// 接收到请求,记录请求内容
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
log.setIp(LOCAL_ADDR.equals(request.getRemoteAddr()) ? LOCAL_IP : request.getRemoteAddr());
log.setUri(request.getRequestURI());
CurrentAdmin admin = WebUtil.getCurrentAdmin();
log.setAccountNo(admin.getUser().getUser().getId());
log.setAccountName(admin.getUser().getUser().getUserName());
// 插入操作日志
try {
String targetName = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
// 使用反射获取方法注解及参数名等信息
Object[] arguments = joinPoint.getArgs();
Class<?> targetClass = Class.forName(targetName);
Method[] methods = targetClass.getMethods();
String operateDesc = "";
String operateTypeCode = "";
String moduleName = "";
String[] paraNames = null;
for (Method method : methods) {
if (method.getName().equals(methodName)) {
Class<?>[] classes = method.getParameterTypes();
// 判断method是否为当前调用方法
if (classes.length == arguments.length && isMetched(classes, arguments)) {
paraNames = paramDiscoverer.getParameterNames(method);
operateDesc = method.getAnnotation(ZXBToolsLog.class).operateDesc();
operateTypeCode = method.getAnnotation(ZXBToolsLog.class).operateType().getCode();
moduleName = method.getAnnotation(ZXBToolsLog.class).moduleName();
}
}
}
log.setClassName(targetName);
log.setMethodName(methodName);
log.setModuleName(moduleName);
log.setOperateDesc(operateDesc);
log.setOperateType(operateTypeCode);
// 使用反射获取的参数名数组及参数数组构造参数map
Map<String, Object> argsMap = new HashMap<>(arguments.length);
for (int i = 0; i < arguments.length; i++) {
if (null != arguments && null != paraNames) {
argsMap.put(paraNames[i], arguments[i]);
}
}
log.setArgStr(JSONUtils.toJSONString(argsMap));
zxbSysConfigLogService.saveConfigLog(log);
// logger.warn(String.format(">>>>>>>> log : %s", JSONUtils.toJSONString(log)));
} catch (Exception e) {
logger.error(String.format("日志记录出现异常。 accountNo:%s, uri:%s", log.getAccountNo(), log.getUri()), e);
}
}
/**
* 判断通过反射拿到方法参数类型与连接点获取的参数对象的类型是否一致(index相同的类名是否一致)
* @param classes 参数类数组
* @param arguments 参数值对象数组
* @return
*/
private boolean isMetched(Class<?>[] classes , Object[] arguments){
boolean isMatched = true;
for (int i = 0; i < arguments.length; i++) {
if (!classes[i].getName().equalsIgnoreCase(arguments[i].getClass().getName())) {
isMatched = false;
break;
}
}
return isMatched;
}
}
对于需要记录日志的方法,只需要在相应的方法上加上如下注解即可:
@ZXBToolsLog(moduleName=ConfigModuleConstant.FEATURE_MNG,operateType=OperateTypeEnum.FIND,operateDesc="获取功能标签列表或者获取指定标签")。每个方法中记录到日志中的模块名称、操作类型可在常量类(或枚举类)中选取相应的模块和操作类型,并对方法的操作内容作简要说明即可。其他相关日志属性的提取记录全在切面的通知中实现,业务接入方可不关注。
在切面中需要通过反射拿到当前调用方法的注解,从而获取日志注解的内容完善日志记录属性。在该切面方法中需要注意通过反射拿到当前正确的调用方法的方式(使用isMatched(Class<?>[], Object[])方法判断当前调用的方法签名与通过反射拿到的某个方法签名相同),避免无法通过方法名及参数个数区分参数个数相同的重载方法。
以上,即为本次日志系统设计与实现的主要内容。
注意事项:
1.如需在controller层使用到切面进行日志的记录,需要在配置servlet-context.xml中设置开启切面代理:
<!-- 启动controller层接口切面的代理 -->
<aop:aspectj-autoproxy />
网上讲可以在上述配置中加入该属性proxy-target-class="true",以强制指定使用cglib进行代理而不是使用jdk的动态代理;其实从spring-aspect 3.0+版本开始,其内部默认代理已设置为cglib。因而上述配置项在spring 3.0+版本中可不用设置proxy-target-class属性。