Spring 利用 AOP + 线程池 + 异步任务完成接口调用日志的记录
在公司级项目中,为了安全性,规范性考虑,我们常常会对接口调用做一些记录。
主要记录的是以下几个问题:
- 谁调用了接口?
- 调用了什么接口?
- 接口调用的方法是什么?
- 接口返回的信息是什么?
我们利用mysql对每一条接口调用,进行信息的记录。
1.创建sys_log 表
-- ----------------------------
-- Table structure for sys_oper_log
-- ----------------------------
CREATE TABLE `sys_oper_log`
(
`oper_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志主键',
`title` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '模块标题',
`business_type` int(11) NULL DEFAULT 0 COMMENT '业务类型(0其它 1新增 2修改 3删除)',
`method` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '方法名称',
`request_method` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '请求方式',
`operator_type` int(11) NULL DEFAULT 0 COMMENT '操作类别(0其它 1后台用户 2手机端用户)',
`oper_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '操作人员',
`dept_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '部门名称',
`oper_url` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '请求URL',
`oper_ip` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '主机地址',
`oper_location` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '操作地点',
`oper_param` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '请求参数',
`json_result` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '返回参数',
`status` int(11) NULL DEFAULT 0 COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '错误消息',
`oper_time` datetime(0) NULL DEFAULT NULL COMMENT '操作时间',
`work_num` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`oper_id`) USING BTREE
) ENGINE = InnoDB
AUTO_INCREMENT = 633
CHARACTER SET = utf8
COLLATE = utf8_general_ci COMMENT = '操作日志记录'
ROW_FORMAT = Dynamic;
表中记录了三方面
- 方法:模块标题,业务类型,方法名称
- 操作人:请求方式,操作类别,部门名称,请求URL,主机地址,操作地点
- 请求和返回:请求参数,返回参数,操作状态,操作时间
2.利用Spring AOP 切面进行处理
因为对于以上这些数据的记录是重复的,对此我们需要进行集中的处理。
定义注解
我们定义一个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;
}
定义切面
我们写一个LogAspect类来处理加上该注解以后记录日志的能力。
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
// 配置织入点
@Pointcut("@annotation(com.maiqu.mb.annotation.Log)")
public void logPointCut() {
}
}
然后在该类中添加业务逻辑取记录日志,该方法是,等方法调用结束后,任何加上Log注解的方法都会执行
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
handleLog(joinPoint, null, jsonResult);
}
我们来实现上图中的handleLog方法。这个方法的目的主要是为了获取以上数据库表定义的各个参数
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
handleLog(joinPoint, null, jsonResult);
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "logPointCut()", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
handleLog(joinPoint, e, null);
}
protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult) {
try {
// 获得注解
Log controllerLog = getAnnotationLog(joinPoint);
if (controllerLog == null) {
return;
}
// 获取当前的用户
String loginUserPhone = (String) StpUtil.getExtra(JwtConstants.JWT_PHONE);
String loginUserRealName = (String) StpUtil.getExtra(JwtConstants.JWT_REAL_NAME);
// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
// 请求的地址
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
operLog.setOperIp(ip);
// 返回参数
operLog.setJsonResult(JSON.toJSONString(jsonResult));
operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
if (StringUtils.isNotEmpty(loginUserRealName)) {
operLog.setOperName(loginUserRealName);
}else {
operLog.setOperName(loginUserPhone);
}
if (e != null) {
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog);
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
} catch (Exception exp) {
// 记录本地异常日志
log.error("==前置通知异常==");
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
}
}
/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param operLog 操作日志
* @throws Exception
*/
public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog) throws Exception {
// 设置action动作
operLog.setBusinessType(log.businessType().ordinal());
// 设置标题
operLog.setTitle(log.title());
// 设置操作人类别
operLog.setOperatorType(log.operatorType().ordinal());
// 是否需要保存request,参数和值
if (log.isSaveRequestData()) {
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, operLog);
}
}
/**
* 获取请求的参数,放到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<String, Object> paramsMap = (Map<String, Object>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
String queryString = ServletUtils.getRequest().getQueryString();
if (StringUtils.isNotEmpty(queryString)) {
String[] split = queryString.split("&");
for (int i = 0; i < split.length; i++) {
paramsMap.put(split[i].split("=")[0],split[i].split("=")[1]);
}
}
operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
}
}
/**
* 是否存在注解,如果存在就获取
*/
private Log getAnnotationLog(JoinPoint joinPoint) throws Exception {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method != null) {
return method.getAnnotation(Log.class);
}
return null;
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
String params = "";
if (paramsArray != null && paramsArray.length > 0) {
for (int i = 0; i < paramsArray.length; i++) {
if (!isFilterObject(paramsArray[i])) {
Object jsonObj = JSON.toJSON(paramsArray[i]);
params += jsonObj.toString() + " ";
}
}
}
return params.trim();
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
public boolean isFilterObject(final Object o) {
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
}
针对上述代码,我们写了很多工具类去获取,下面让我们来一一解释
3.如何获取请求的ip?
1.获取当前的请求对象。
首先,我们要获取当前的request.
首先我们利用spring的 org.springframework.web.context.request;包下面的 请求上下文对象 RequestContextHolder 去获取它的一些属性。
然后将获取到的属性 attributes 转化为它的一个实现类 ServletRequestAttributes.
之后根据ServletRequestAttributes的一个getRequest方法去获取当前的请求对象
我们利用ServletUtils 去包装一下
public class ServletUtils {
/**
* 获取request
*/
public static HttpServletRequest getRequest() {
return getRequestAttributes().getRequest();
}
/**
* 获取response
*/
public static HttpServletResponse getResponse() {
return getRequestAttributes().getResponse();
}
public static ServletRequestAttributes getRequestAttributes() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
}
}
2.获取当前的ip
我们同样写了一个IpUtil 工具类来封装.这个主要是对请求头进行解析,获取ip,有兴趣的同学可以深入研究,但如果是搞开发的同学,建议直接粘贴即可。
public class IpUtils {
public static String getIpAddr(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Forwarded-For");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
private static boolean internalIp(byte[] addr) {
if (StringUtils.isEmpty(Arrays.toString(addr)) || addr.length < 2) {
return true;
}
final byte b0 = addr[0];
final byte b1 = addr[1];
// 10.x.x.x/8
final byte SECTION_1 = 0x0A;
// 172.16.x.x/12
final byte SECTION_2 = (byte) 0xAC;
final byte SECTION_3 = (byte) 0x10;
final byte SECTION_4 = (byte) 0x1F;
// 192.168.x.x/16
final byte SECTION_5 = (byte) 0xC0;
final byte SECTION_6 = (byte) 0xA8;
switch (b0) {
case SECTION_1:
return true;
case SECTION_2:
if (b1 >= SECTION_3 && b1 <= SECTION_4) {
return true;
}
case SECTION_5:
switch (b1) {
case SECTION_6:
return true;
}
default:
return false;
}
}
/**
* 将IPv4地址转换成字节
*
* @param text IPv4地址
* @return byte 字节
*/
public static byte[] textToNumericFormatV4(String text) {
if (text.length() == 0) {
return null;
}
byte[] bytes = new byte[4];
String[] elements = text.split("\.", -1);
try {
long l;
int i;
switch (elements.length) {
case 1:
l = Long.parseLong(elements[0]);
if ((l < 0L) || (l > 4294967295L)) {
return null;
}
bytes[0] = (byte) (int) (l >> 24 & 0xFF);
bytes[1] = (byte) (int) ((l & 0xFFFFFF) >> 16 & 0xFF);
bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF);
bytes[3] = (byte) (int) (l & 0xFF);
break;
case 2:
l = Integer.parseInt(elements[0]);
if ((l < 0L) || (l > 255L)) {
return null;
}
bytes[0] = (byte) (int) (l & 0xFF);
l = Integer.parseInt(elements[1]);
if ((l < 0L) || (l > 16777215L)) {
return null;
}
bytes[1] = (byte) (int) (l >> 16 & 0xFF);
bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF);
bytes[3] = (byte) (int) (l & 0xFF);
break;
case 3:
for (i = 0; i < 2; ++i) {
l = Integer.parseInt(elements[i]);
if ((l < 0L) || (l > 255L)) {
return null;
}
bytes[i] = (byte) (int) (l & 0xFF);
}
l = Integer.parseInt(elements[2]);
if ((l < 0L) || (l > 65535L)) {
return null;
}
bytes[2] = (byte) (int) (l >> 8 & 0xFF);
bytes[3] = (byte) (int) (l & 0xFF);
break;
case 4:
for (i = 0; i < 4; ++i) {
l = Integer.parseInt(elements[i]);
if ((l < 0L) || (l > 255L)) {
return null;
}
bytes[i] = (byte) (int) (l & 0xFF);
}
break;
default:
return null;
}
} catch (NumberFormatException e) {
return null;
}
return bytes;
}
}
4.如何获取方法名称,方法类型和类名称?
获取类名称
String className = joinPoint.getTarget().getClass().getName();
他获取到被请求的类中的方法,然后利用反射获取它所在的目录
最后的结果可能是这样,包名 + 类名
com.maiqu.mb.controller.MbConsultantController
获取方法名
String methodName = joinPoint.getSignature().getName();
获取方法类型
这个util在上文写过,主要是返回request对象用的
String method = ServletUtils.getRequest().getMethod()
5.如何利用异步的方式保存数据库?
为什么要异步?为了不影响正常业务逻辑,且它对响应没有要求。
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
这里我们封装了一个AcyncManager类
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);
}
}
我们只需要一个AsyncManager的实例去管理这个任务的执行。
那么这个executor又是怎么获取的呢?
我们自己定义一个线程池配置类,并在其中定义一个scheduledExecutorService的bean去执行周期性的任务
@Configuration
public class ThreadPoolConfig {
// 核心线程池大小
private int corePoolSize = 50;
/**
* 执行周期性或定时任务
*/
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
return new ScheduledThreadPoolExecutor(corePoolSize,
new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build()) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
Threads.printException(r, t);
}
};
}
}
我们可以根据name来获取该定义的实例bean
我们为啥用ScheduledExecutorService呢?我们知道,Java的定时调度可以用Timer或者TimerTask来实现,但它的实现是单线程,我们需要一个你多线程的执行方案,它解决了任务之间会影响,且性能较低的问题。
只要线程数足够,那么任务就很快执行,但如果不够,多任务会复用一个线程。它本身内部用的延迟队列实现,基于等待/唤醒机制实现,因此CPU 不会一直繁忙,从而也提升了性能
回到正题,这个executor又是怎么获取的呢?
我们利用封装了一个SpringUtil去获取
@Component
public final class SpringUtils implements BeanFactoryPostProcessor, ApplicationContextAware {
/**
* Spring应用上下文环境
*/
private static ConfigurableListableBeanFactory beanFactory;
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
SpringUtils.beanFactory = beanFactory;
}
/**
* 获取对象
*
* @param name
* @return Object 一个以所给名字注册的bean的实例
* @throws BeansException
*/
@SuppressWarnings("unchecked")
public static <T> T getBean(String name) throws BeansException {
return (T) beanFactory.getBean(name);
}
}
这样,我们就可以利用自定义的getName()方法来获取自定义的bean(ScheduledExecutorService)了
这样我们就定义好了多线程,执行任务的定时执行器服务,之后我们要往里面传我们需要执行的任务
AsyncManager.me().execute()
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
我们定义一个异步工厂,进行返回异步任务
@Slf4j
public class AsyncFactory {
/**
* 操作日志记录
*
* @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(SysOperLogService.class).insertOperlog(operLog);
}
};
}
}
执行异步多线程任务小结:
- 定义一个bean,去定义线程池,定义名字
- 用spring根据它的名字获取它的实例
- 利用该实例去执行任务
- 同样根据spring找到定义好的方法,传入要执行的方法(任务)
想了解该方面的同学建议去看这篇文章哟~