我想有必要描述下我需要解决什么样的问题,这样更有助于理解我所写的代码。
现在我们的日志需要记录操作模块,操作名称和操作描述;操作模块和操作名称在编译期就能确定,代码看起来像这样:
{
... 业务逻辑代码
... 获取组装操作描述所需要的数据:方法参数,或者调用其它服务查询啊
... 组装操作描述,记录系统日志
}
或者这样:
{
... 获取组装操作描述所需要的数据:方法参数,或者调用其它服务查询啊
... 业务逻辑代码
... 组装操作描述,记录系统日志
}
这是因为有些业务逻辑代码执行了删除,我们需要提前查询日志所需信息。当然,日志查询出来的数据也可能被业务逻辑代码复用,反过来也一样。
我们现在面临的问题复杂了,涉及到了动态的数据;一开始,我有想过用切面的方式,并用注解来作为切入点,但当时面临的也是这个问题,不过,现在这个问题,我能解决了,灵感来源于学习 Spring cloud hystrix
时,HystrixCommand
注解的 fallback
属性。简单的说,就是将动态数据的获取提取为一个方法,通过注解属性指定这个方法。
我的代码更多的是切合了我们的系统,这个需要特别注意。
注解类代码如下:
/**
* app 日志记录器
* @author duofei
* @date 2019/11/4
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AppLogging{
/**
* 是否注入用户基础信息:默认为true,将获取到参数中UserBasicInfo类型的实例化对象注入
*/
boolean injectUserBasicInfo() default true;
/**
* 模块信息: 枚举类定义的模块信息
*/
AppLogModule module() default AppLogModule.NOTHING;
/**
* 功能信息:枚举类定义的功能信息
*/
AppLogFunc func();
/**
* 模式:定义了如何获取功能信息,
*/
String mode() default FuncMode.CUSTOM;
/**
* 根据入参位置获取动态数据:该数据处于根据入参名称获取的数据之前(如果它存在),顺序从0开始
* 仅支持指定位置的参数类型为 String
* 注:如果需要自定义排序,请使用 @LogParam 或 @LogParams 在形参上注解
*/
int[] dataIndex() default -1;
/**
* 根据入参名称获取动态数据:该数据处于根据方法获取的动态数据之前(如果它存在),顺序从 dataIndex().length 开始
* 入参名称只限于复杂对象内的属性名,如果需要获取简单的入参,请使用 dataIndex()
* 注:如果需要自定义排序,请使用 @LogParam 或 @LogParams 在形参上注解
*/
String[] dataNames() default "";
/**
* 根据指定方法获取动态数据:该数据处于动态数据获取的最后阶段,顺序为 dataIndex().length + dataNames.length()
* 注:如果需要自定义排序,请使用 sortedDataMethod()
* 指定的方法需要保证与被该注解注释的方法具有相同的参数,并返回一个 LIST<String> ;
*
*/
String dataMethod() default "";
/**
* 当指定方法获取动态数据需要顺序时,即获取到的数据顺序为 sort 值,如果sort 值与已存在的顺序值冲突,将替代(这需要使用者考虑,我并不觉得自动往后延续是合理的)
* LogParam name 代表方法名称,依然需要保证与被该注解注释的方法具有相同的参数,并返回一个 LIST<String> ;
* LogParam sort 代表方法返回的 List 处于整个动态数据构造的哪个位置
*/
LogParam sortedDataMethod() default @LogParam(available = false);
/**
* 自定义文本,这将作为动态数据被传入,顺序在最后
*/
String[] dataText() default "";
}
该注解支持注解在类上和方法上,属性我都说明,可能比较难理解;但想想我要解决的问题以及基本思路,应该能理解的。还有如下两个注解,是用在方法入参上的:
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogParam {
/**
* 构建操作说明时,该动态数据所处位置, 如果为 0 将使用默认数据
*/
int pos() default 0;
/**
* 默认为当前修饰的参数名或者指定特定名称
* 它将基于它所处的位置,为名称赋予特定的含义(参数名或者方法名)
*/
String name() default "";
/**
* 是否可用
*/
boolean available() default true;
}
可以看见,这里有个 pos ,其实是源于我们的操作描述组装时,需要知道数据的顺序,但使用 @AppLoging
的默认顺序是不够的,所以我们需要自定义的排序。(注意:我们需要的动态数据是要有顺序的)
好了,下面是重点,切面的代码:
@Around("@annotation(com.gysoft.utils.logging.annotation.AppLogging)")
public Object appLogging(ProceedingJoinPoint joinPoint) throws Throwable{
final Object result = joinPoint.proceed();
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
// 获取真正的方法(实现类的而不是接口定义的)
final Method targetMethod = joinPoint.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
// 记录日志,保证没有异常抛出
try{
final SaveSysLogParam saveMsgParam = new SaveSysLogParam();
final AppLogging appLogging = targetMethod.getAnnotation(AppLogging.class);
// 用户基本信息注入:系统需要的
final Object[] args = joinPoint.getArgs();
if(appLogging.injectUserBasicInfo()){
for (Object arg : args) {
if(arg instanceof UserBasicInfo){
saveMsgParam.setUserBasicInfo((UserBasicInfo)arg);
break;
}
}
}
// 获取日志的模块信息,如果没有尝试从类的注解上获取
final Class<?> declaringClass = targetMethod.getDeclaringClass();
if(AppLogModule.NOTHING != appLogging.module()){
//TODO 名称错误,版本遗留问题,本应该为操作模块
saveMsgParam.setOperFunction(appLogging.module().getOperModule());
}else{
if(declaringClass.isAnnotationPresent(AppLogging.class)){
final AppLogging clzzAppLogging = declaringClass.getAnnotation(AppLogging.class);
if(clzzAppLogging == null || clzzAppLogging.module() == AppLogModule.NOTHING){
throw new DataNotFoundException("需要指定模块");
}
//TODO 名称错误,版本遗留问题,本应该为操作模块
saveMsgParam.setOperFunction(clzzAppLogging.module().getOperModule());
}
}
// 获取日志的功能信息
final AppLogFunc func = appLogging.func();
//TODO 名称错误,版本遗留问题,本应该为操作功能
saveMsgParam.setOperName(func.getOperFunc());
// 获取日志的描述信息
Map<Integer, Object> descDataMap = new HashMap<>();
int sort = 0;
if(!(appLogging.dataIndex().length == 1 && appLogging.dataIndex()[0] == -1)){
// 通过位置获取
for (int i : appLogging.dataIndex()) {
if(i >= args.length){
throw new ParamInvalidException("无效的位置值:" + i);
}
if(!(args[i] instanceof String)){
throw new ParamInvalidException("指定位置存在不支持的类型:" + i);
}
descDataMap.put(sort++, args[i]);
}
}
if(!(appLogging.dataNames().length == 1 && appLogging.dataNames()[0].equals(""))){
// 通过名称获取
for (String name : appLogging.dataNames()) {
String value = null;
for (Object arg : args) {
if (!arg.toString().contains("java.lang")) {
try{
value = ClassUtils.getPublicMethod(arg.getClass(), "get" + Strman.upperFirst(name)).invoke(arg, new Object[0]).toString();
break;
}catch (NoSuchMethodException e){
// 吞掉该异常,接着执行
}
}
}
if(value == null){
throw new ParamInvalidException("指定名称的值不存在:" + name);
}
descDataMap.put(sort++, value);
}
}
if(!appLogging.dataMethod().equals("")){
// 通过方法获取
try {
final Method tempMethod = declaringClass.getMethod(appLogging.dataMethod(), targetMethod.getParameterTypes());
// TODO 还不明确可否能够使用
descDataMap.put(sort++, tempMethod.invoke(joinPoint.getTarget(), args));
}catch (NoSuchMethodException e){
throw new ParamInvalidException("指定的方法不存在:" + appLogging.dataMethod());
}
}
// 将会覆盖顺序
if(appLogging.sortedDataMethod().available()){
// 通过方法获取(可指定顺序)
try {
final Method tempMethod = declaringClass.getMethod(appLogging.sortedDataMethod().name(), targetMethod.getParameterTypes());
// TODO 还不明确可否能够使用
descDataMap.put(appLogging.sortedDataMethod().pos(), tempMethod.invoke(joinPoint.getTarget(), args));
}catch (NoSuchMethodException e){
throw new ParamInvalidException("指定的方法不存在:" + appLogging.sortedDataMethod().name());
}
}
// 通过参数上的注解获取
final Annotation[][] parameterAnnotations = targetMethod.getParameterAnnotations();
if(parameterAnnotations != null && parameterAnnotations.length != 0){
for (int i = 0; i < parameterAnnotations.length; i++) {
Annotation[] parameterAnnotation = parameterAnnotations[i];
for (Annotation annotation : parameterAnnotation) {
// 判断参数上的注解实例获取数据
if(annotation instanceof LogParam){
LogParam logParam = (LogParam) annotation;
try{
final String value = ClassUtils.getPublicMethod(args[i].getClass(), "get" + Strman.upperFirst(logParam.name()))
.invoke(args[i], new Object[0]).toString();
descDataMap.put(logParam.pos()==0 ? sort++ : logParam.pos(), value);
}catch (NoSuchMethodException e){
throw new ParamInvalidException("指定名称的值不存在:" + logParam.name());
}
}else if(annotation instanceof LogParams){
LogParams logParams = (LogParams) annotation;
final LogParam[] logPars = logParams.value();
for (LogParam logPar : logPars) {
try{
final String value = ClassUtils.getPublicMethod(args[i].getClass(), "get" + Strman.upperFirst(logPar.name()))
.invoke(args[i], new Object[0]).toString();
descDataMap.put(logPar.pos()==0 ? sort++ : logPar.pos(), value);
}catch (NoSuchMethodException e){
throw new ParamInvalidException("指定名称的值不存在:" + logPar.name());
}
}
}
}
}
}
// 获取纯文本的描述信息
if(!(appLogging.dataText().length == 1 && appLogging.dataText()[0].equals(""))){
descDataMap.put(sort++, )
}
// 组装日志的描述信息:根据不同模式调用 AppLogFunc 的不同方法
saveMsgParam.setOperDesc(buildOperDesc(appLogging.func(), appLogging.mode(), saveMsgParam.getOperName(), descDataMap));
saveMsgParam.setOperIp(saveMsgParam.getUserBasicInfo().getIp());
saveMsgParam.setOperTime(saveMsgParam.getUserBasicInfo().getCurrentMills());
saveMsgParam.setOperator(saveMsgParam.getUserBasicInfo().getUserName());
// 记录日志 TODO 该方法可以不使用 supplier 了
sendSysLogMsgService.sendMsg(()->saveMsgParam);
}catch (Exception e){
logger.error("logging error! className={}, methodName={}",targetMethod.getDeclaringClass().getName(), targetMethod.getName(),e);
}
return result;
}
/**
* 组装日志的描述信息
* @author duofei
* @date 2019/11/6
* @param logFuncInfoGetting 日志功能信息获取:功能枚举类实现了该接口
* @param mode FuncMode 定义的常量值
* @param operName 操作名
* @param data 动态数据
* @return String 日志描述信息
* @throws Exception 数据与指定的组装模式不匹配
*/
private String buildOperDesc(LogFuncInfoGetting logFuncInfoGetting, String mode,String operName, Map<Integer, Object> data) throws Exception{
switch (mode){
case FuncMode.CUSTOM:
return logFuncInfoGetting.getCustomDesc(operName, buildList(data));
case FuncMode.ADD:
return logFuncInfoGetting.getAddOperDesc(operName, buildList(data));
case FuncMode.DEL:
return logFuncInfoGetting.getDelOperDesc(operName, buildList(data));
case FuncMode.MOD:
// TODO 如此处理方式有待斟酌
return logFuncInfoGetting.getModOperDesc(operName, buildList(data).get(0));
case FuncMode.MODDETAIL:
// TODO 如此处理方式有待斟酌
return logFuncInfoGetting.getModDesc(operName, buildList(data).get(0), buildList(data).get(1));
default:
// TODO 如此处理方式有待斟酌
return null;
}
}
/**
* 根据 Map中的数据和顺序组装成 List<String>
* @author duofei
* @date 2019/11/6
* @param data 数据源
* @return List<String> 结果值
* @throws Exception
*/
private List<String> buildList(Map<Integer, Object> data) throws Exception{
List<String> result = new ArrayList<>();
final List<Integer> sorts = data.keySet().stream().sorted((i1, i2) -> {
if (i1 > i2) {
return 1;
} else if (i1.equals(i2)) {
return 0;
} else {
return -1;
}
}).collect(Collectors.toList());
sorts.forEach(sort -> {
final Object value = data.get(sort);
if(value instanceof String){
result.add((String) value);
}
if(value instanceof List){
result.addAll((List) value);
}
// TODO 获取到不匹配的类型是否应该抛出异常
});
return result;
}
上面有些对象是与我们系统相关的,我就不给出了,思路就是这样了。有点需要提到:最终动态数据,我是组装为一个顺序的 List<String>
。本来想用 Map 的,我甚至觉得 Map 更好,但由于在真正进行组装时的问题,所以只能用 List。
第二个问题,关于方法的提取,指定的方法需要与原方法具有同样的参数,返回值需要为 List<String>
。我想这是有必要的,大家也可以定义适合自己的。
后续的优化策略应该是支持异步,哦,对了,还有一个需求没有实现,那就是文章开始描述的第二个场景,我想这个不难吧,切面我使用的是 Around
,在方法执行前,去实现就好了(依然可以采用提取方法)。我们甚至可以将获取到的数据,作为参数传递给当前需要记录日志的方法,代码看起来像这样:
// 切面处理方法(Around)
... 获取到动态数据
joinpoint 可设置参数的处理方法。
...任然可以获取动态数据,记录日志等操作
// 需要记录日志的方法
public void ...(定义一个形参来接收动态数据)
这不就解决了因为记录日志导致代码重复执行的问题嘛?当然,这只能处理在方法执行的动态数据。方法处理后的动态数据重复获取,我们可以使用异步来规避掉这部分损失。我想,整个切面解决日志的思路就是这样了。
最好的日志记录看起来像这样:
@AppLogging(func= AppLogFunc.ADD_MEETIONG, module = AppLogModule.MEETIONGLOG, dataNames = "theme", mode = FuncMode.ADD)
public void addClass(AddClassParam addClassParam, UserBasicInfo userBasicInfo) throws Exception {}
AddClassParam
对象中有个 theme
属性,我需要动态获取该属性的值。当然还有更复杂的哦。
总结下技术上的问题:
-
如何获取在切入点方法上的注解?
因为是在 service 层,该类实现了 接口。尽管 我的切入点是在实现类上,但在代码执行时,我发现这段代码:
Signature signature = joinPoint.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod();
获取到的方法是抽象方法(抽象方法上并没有注解),所以才有了接下来的这段代码:
// 获取真正的方法(实现类的而不是接口定义的) final Method targetMethod = joinPoint.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
这样我们就能获取到实现类上方法的注解了。
-
如何获取方法入参自定义 pojo 对象的属性对应值?
这里用到了一个工具类:
value = ClassUtils.getPublicMethod(arg.getClass(), "get" + Strman.upperFirst(name)).invoke(arg, new Object[0]).toString();
该工具类是
commons-lang3-3.7
下的,有兴趣的可以研究下。 -
如何执行注解指定的方法?
这个很简单,通过反射来执行,因为我们能够获取到当前实现类对象,方法定义以及方法参数,这也是为什么我们要求指定方法的入参必须是和注解的方法入参一样。
final Method tempMethod = declaringClass.getMethod(appLogging.dataMethod(), targetMethod.getParameterTypes()); // TODO 还不明确可否能够使用 descDataMap.put(sort++, tempMethod.invoke(joinPoint.getTarget(), args));
注:代码我已测试过,但未能上生产环境,很遗憾,而且我又进行了新一轮抽象,提供了代码的复用性,但这已经不重要了;思路已经有了,最适合的才是最好的,不是嘛
如果你觉得我的文章对你有所帮助的话,欢迎关注我的公众号。赞!
认认真真学习,做思想的产出者,而不是文字的搬运工。错误之处,还望指出!