SpringBoot+AOP存储日志

     背景

        近期我们接到一个新需求,要给某些服务中用户所有的操作上都加上日志,用于操作留痕,知晓用户做了哪些事,也方便后续分析用户的行为习惯。因为这个需求要覆盖多个服务,且操作类的功能太多了,针对每个接口一个一个加肯定是不现实的,而且业内为了处理类似的问题,也有较为成熟的方案,如:

        1、上ELK;

        2、用切面AOP存日志。

        经过与客户和产品经理的沟通,我们一致认为用ELK太麻烦了。从成本,实现难度上看,好像AOP更简单,那咱们本期就先说说怎么用AOP实现无侵入性日志存储。

      一、数据库设计

         要对用户的操作日志进行存储,最基本的要求是存储操作人、操作的功能模块、操作类型(增/删/改/查)、客户端IP、服务路径、入参、操作结果(成功/失败)等,而且要考虑通用性,涉及到多个业务模块使用,所以字段方面既要有所涵盖,也要“去业务化”,不能包含各个业务的个性化字段。而且,这一类的表具有数据量大,数据增长快等特点,在设计之初就该考虑性能问题,查询效率问题。综合以上种种考虑,我整理出了数据库表的设计,如下:

drop table if exists sys_operation_log;
CREATE TABLE sys_operation_log(
                                  id VARCHAR2(32) NOT NULL,
                                  operatorId VARCHAR2(50),
                                  operatorName VARCHAR2(100),
                                  status INTEGER,
                                  orgCode VARCHAR2(50),
                                  operationCode VARCHAR2(50),
                                  operationName VARCHAR2(100),
                                  operationType VARCHAR2(50),
                                  dataId VARCHAR2(127),
                                  changedItems VARCHAR2(2047),
                                  params VARCHAR2(4095),
                                  tableName VARCHAR2(127),
                                  createTime Timestamp,
                                  requestAddr VARCHAR2(255),
                                  serviceAddr VARCHAR2(255),
                                  endTime Timestamp,
                                  operateNt VARCHAR2(1023),
                                  PRIMARY KEY (id)
)PARTITION BY RANGE (createTime) (
  PARTITION p1 VALUES LESS THAN (to_date('2024-01-01','yyyy-MM-dd')),
  PARTITION p2 VALUES LESS THAN (to_date('2025-01-01','yyyy-MM-dd')),
  PARTITION p3 VALUES LESS THAN (to_date('2026-01-01','yyyy-MM-dd')),
  PARTITION p4 VALUES LESS THAN (to_date('2027-01-01','yyyy-MM-dd')),
  PARTITION p5 VALUES LESS THAN (to_date('2028-01-01','yyyy-MM-dd')),
  PARTITION p6 VALUES LESS THAN (to_date('2029-01-01','yyyy-MM-dd')),
  PARTITION p7 VALUES LESS THAN (to_date('2030-01-01','yyyy-MM-dd')),
  PARTITION pn VALUES LESS THAN (MAXVALUE)
);

COMMENT ON TABLE sys_operation_log IS '系统日志信息表';
COMMENT ON COLUMN sys_operation_log.id IS '主键ID';
COMMENT ON COLUMN sys_operation_log.operatorId IS '操作人ID';
COMMENT ON COLUMN sys_operation_log.operatorName IS '操作人';
COMMENT ON COLUMN sys_operation_log.status IS '操作状态';
COMMENT ON COLUMN sys_operation_log.orgCode IS '组织机构代码';
COMMENT ON COLUMN sys_operation_log.operationCode IS '操作项编码/模块编码';
COMMENT ON COLUMN sys_operation_log.operationName IS '操作项名称/模块名称';
COMMENT ON COLUMN sys_operation_log.operationType IS '操作类型';
COMMENT ON COLUMN sys_operation_log.dataId IS '操作数据Id';
COMMENT ON COLUMN sys_operation_log.changedItems IS '变更数据';
COMMENT ON COLUMN sys_operation_log.params IS '传入参数';
COMMENT ON COLUMN sys_operation_log.tableName IS '操作表Id';
COMMENT ON COLUMN sys_operation_log.createTime IS '创建时间';
COMMENT ON COLUMN sys_operation_log.requestAddr IS '请求地址';
COMMENT ON COLUMN sys_operation_log.serviceAddr IS '服务地址';
COMMENT ON COLUMN sys_operation_log.endTime IS '结束时间';
COMMENT ON COLUMN sys_operation_log.operateNt IS '操作备注';

CREATE INDEX idx_operation_log_code_dataId ON sys_operation_log(operationCode, dataId);
CREATE INDEX idx_operation_log_code_operId ON sys_operation_log(operationCode, operatorId);

       二、功能实现

          1、添加maven依赖,如下

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>3.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.83</version>
        </dependency>

        <dependency>
            <groupId>dm.jdbc</groupId>
            <artifactId>DmJdbcDriver18</artifactId>
            <version>1.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        2、根据数据库表创建实体类,dao,mapper,service等,这个不用多说,一个插件即可,推荐用CodeGneration。

       3、定义注解,切面,日志存储的基本类,如下:

//注解类
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String value() default LogConstant.DEFAULT_LOG_CODE;
}

//日志存储基本类
public class BasicLogService {

    @Autowired
    protected SysOperationLogService logService;

    /**
     * 用于存储日志信息
     *
     * @param log 日志基本信息
     * @param params 传入参数
     */
    public void saveOperationLog(SysOperationLog log, Map<String,Object> params) {
    }
}

//默认的日志存储实现类
@Service("DEFAULT_LOG")
@Slf4j
public class DefaultLogService extends BasicLogService {

    @Override
    public void saveOperationLog(SysOperationLog operationLog, Map<String,Object> params) {
        fillDefaultInfoToLog(operationLog);
        operationLog.setParams(LogUtils.transParamsToStr(params));
        logService.saveOperationLog(operationLog);
    }
    private void fillDefaultInfoToLog(SysOperationLog log) {
        log.setId(UUID.randomUUID().toString().replaceAll("-", ""));
        log.setCreateTime(new Date());
        log.setOperationName(LogConstant.DEFAULT_LOG_NAME);
        log.setOperationCode(LogConstant.DEFAULT_LOG_CODE);
    }
}

//切面类
@Aspect
@Component
@Slf4j
public class OperationLogAspect {

    @Autowired
    HttpServletRequest request;

    @Autowired
    Map<String, BasicLogService> logServiceMap;

    @Around(value = "@annotation(operationLog)")
    public Object aroundMethod(ProceedingJoinPoint point, OperationLog operationLog) {
        SysOperationLog sysLog = new SysOperationLog();
        sysLog.setServiceAddr(request.getRequestURI());
        sysLog.setOperationCode(operationLog.value());
        sysLog.setRequestAddr(getRequestAddr(request));

        Map<String, Object> params = getParamsByPoint(point);
        Object result = null;
        try{
            result = point.proceed();
            sysLog.setStatus(OperationStatusEnum.SUCCESS.value());
            sysLog.setOperateNt(LogUtils.transObjectToString(result, LogConstant.NT_LENGTH));
            logServiceMap.get(operationLog.value()).saveOperationLog(sysLog, params);
        } catch (Throwable throwable) {
            log.error("OperationLogAspect around Error", throwable);
            sysLog.setStatus(OperationStatusEnum.FAIL.value());
            sysLog.setOperateNt(LogUtils.getExceptionMessage(throwable, LogConstant.NT_LENGTH));
            logServiceMap.get(operationLog.value()).saveOperationLog(sysLog, params);
            throw new OperationLogException(LogConstant.FAIL,throwable.getMessage());
        }
        return result;
    }

    /**
     * 查询客户端地址
     *
     * @param request
     * @return
     */
    private String getRequestAddr(HttpServletRequest request) {
        String addr = request.getHeader("x-forwarded-for");
        if (StringUtils.isEmpty(addr)) {
            addr = request.getHeader("x-real-ip");
        }
        if (StringUtils.isEmpty(addr)) {
            addr = request.getRemoteAddr();
        }
        return addr;
    }

    /**
     * 从切入点中获取方法的参数及值
     *
     * @param point 切入点
     * @return 参数的键值对
     */
    private static Map<String, Object> getParamsByPoint(ProceedingJoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Parameter[] params = method.getParameters();
        Object[] args = point.getArgs();
        Map<String, Object> paramMap = new HashMap<>();
        for (int i=0;i<params.length;i++) {
            paramMap.put(params[i].getName(),args[i]);
        }
        return paramMap;
    }
}

        之所以要设计日志存储基本类,扩展类,就是因为各种服务和功能模块对于日志的存储需求不尽相同,比如有的模块需要存储变更数据, 有的模块需要存储批量操作数据等,这样的设计方式类似于策略模式,易于扩展。当需要新的模式时,只需创建一个service继承BasicLogService即可,这个在后面会有案例说明。     

        3、添加其他基本功能。完成了切面,接下来咱们来丰富下日志服务的相关功能。方向如下:

        1)上面实现的切面式的日志存储只能满足同步类的操作,如果遇到异步类的操作,则不能用切面了,需要其他方法来存储日志,修改日志的状态和结束时间,

        2)一个日志服务不应仅包含存储服务,还需要有查询服务。

        3)我需要有一个表初始化的功能,启动服务后,如果数据库里没有表,则执行代码包里的建表语句来建表。

        综上,我对服务类进行了相关调整,如下:

/**
 * <B>系统名称:雷袭月启的组件库</B><BR>
 * <B>模块名称:日志管理模块</B><BR>
 * <B>中文类名:</B><BR>
 * <B>概要说明:</B><BR>
 *
 * @author leixiyueqi
 * @since 2023/12/13 9:25
 */
@Service
public class SysOperationLogServiceImpl extends ServiceImpl<SysOperationLogMapper, SysOperationLog> implements SysOperationLogService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private ResourceLoader resourceLoader;

    @PostConstruct
    private void checkAndCreateTable() {
       List<String> tableNameList = this.baseMapper.searchTableExists();
       // 如果已经有表,就不用管理
       if (!CollectionUtils.isEmpty(tableNameList)) {
           return;
       }
       executeSqlScript("classpath:/sql/createTable.sql");
    }

    public void executeSqlScript(String scriptPath) {
        Resource resource = resourceLoader.getResource(scriptPath);
        try (InputStream inputStream = resource.getInputStream();
                Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8.name())) {
            scanner.useDelimiter(";\\s*");
            List<String> sqlStatements = new ArrayList<>();
            while (scanner.hasNext()) {
                String sqlStatement = scanner.next();
                if (!sqlStatement.trim().isEmpty()) {
                    sqlStatements.add(sqlStatement);
                }
            }
            jdbcTemplate.batchUpdate(sqlStatements.toArray(new String[0]));
        } catch (IOException e) {
            throw new OperationLogException(LogConstant.FAIL, "初始化系统日志表报错,请检查数据库配置是否正确,当前数据库是否为DM数据库");
        }
    }

    /**
     * 存储日志信息
     *
     * @param operateName 操作项名称
     * @param dataId 数据Id
     * @return 日志id
     */
    @Override
    public String saveOperationLog(String operateName, String dataId, Object params) {
        SysOperationLog log = new SysOperationLog();
        log.setId(UUID.randomUUID().toString());
        log.setCreateTime(new Date());
        log.setStatus(OperationStatusEnum.WORKING.value());
        if (Objects.nonNull(params)) {
            String jsonStr = JSON.toJSONString(params);
            if (jsonStr.length() > LogConstant.PARAM_LENGTH) {
                log.setParams(jsonStr.substring(0, LogConstant.PARAM_LENGTH));
            } else {
                log.setParams(jsonStr);
            }
        }
        log.setOperationName(operateName);
        log.setDataId(dataId);
        this.baseMapper.insert(log);
        return log.getId();
    }


    @Override
    public String saveOperationLog(SysOperationLog log) {
        log.setCreateTime(new Date());
        log.setEndTime(new Date());
        this.save(log);
        return log.getId();
    }

    @Override
    public void saveBatchOperationLogs(List<SysOperationLog> list) {
        if (CollectionUtils.isEmpty(list)) {
            return;
        }
        list.forEach(log -> {
            log.setCreateTime(new Date());
            log.setEndTime(new Date());
        });
        this.saveBatch(list);
    }

    @Override
    public void updateOperationLog(String logId, Integer status, String operateNt) {
        if (StringUtils.isEmpty(logId)) {
            return;
        }
        this.baseMapper.updateOperationLog(logId, status, operateNt);
    }

    @Override
    public List<SysOperationLog> getOperationLogList(SysOperationLogReq req) {
        LambdaQueryWrapper<SysOperationLog> queryWrapper = new LambdaQueryWrapper<>();
        if (StringUtil.isNotEmpty(req.getOperationCode())) {
            queryWrapper.eq(SysOperationLog::getOperationCode, req.getOperationCode());
        }
        if (StringUtil.isNotEmpty(req.getDataId())) {
            queryWrapper.eq(SysOperationLog::getDataId, req.getDataId());
        }
        if (!CollectionUtils.isEmpty(req.getDataIdList())) {
            queryWrapper.in(SysOperationLog::getDataId, req.getDataIdList());
        }
        if (!StringUtil.isEmpty(req.getStartTime())) {
            queryWrapper.ge(SysOperationLog::getCreateTime, LogUtils.toDate(req.getStartTime(), LogConstant.DATE_YYYYMMDD_STR));
        }
        if (!StringUtils.isEmpty(req.getEndTime())) {
            queryWrapper.lt(SysOperationLog::getCreateTime,
                    LogUtils.addDate(LogUtils.toDate(req.getEndTime(),
                    LogConstant.DATE_YYYYMMDD_STR), 1));
        }
        if (!StringUtils.isEmpty(req.getOrgCode())) {
            queryWrapper.eq(SysOperationLog::getOrgCode, req.getOrgCode());
        }
        if (StringUtil.isNotEmpty(req.getLogId())) {
            queryWrapper.eq(SysOperationLog::getId, req.getLogId());
        }
        if (StringUtil.isNotEmpty(req.getOperatorName())) {
            queryWrapper.like(SysOperationLog::getOperatorName, req.getOperatorName());
        }
        if (StringUtil.isNotEmpty(req.getOperatorId())) {
            queryWrapper.eq(SysOperationLog::getOperatorId, req.getOperatorId());
        }
        if (StringUtil.isNotEmpty(req.getChangedItems())) {
            queryWrapper.like(SysOperationLog::getChangedItems, req.getChangedItems());
        }
        if(StringUtil.isNotEmpty(req.getOperationType())){
            queryWrapper.eq(SysOperationLog::getOperationType,req.getOperationType());
        }
        if (req.getStatus() != null) {
            queryWrapper.eq(SysOperationLog::getStatus, req.getStatus());
        }
        queryWrapper.select(SysOperationLog::getId,
                SysOperationLog::getParams,
                SysOperationLog::getCreateTime,
                SysOperationLog::getChangedItems,
                SysOperationLog::getOperatorName,
                SysOperationLog::getStatus,
                SysOperationLog::getOperationName,
                SysOperationLog::getOperatorId,
                SysOperationLog::getDataId,
                SysOperationLog::getOperationType,
                SysOperationLog::getOperateNt,
                SysOperationLog::getOrgCode
        );
        queryWrapper.orderByDesc(SysOperationLog::getCreateTime);
        return this.list(queryWrapper);
    }
}

        4、 执行一次查询,也为了测试我们的注解是否生效,创建Controller类,用postman测试如下:

/**
 * <B>系统名称:雷袭月启的组件库</B><BR>
 * <B>模块名称:日志管理模块</B><BR>
 * <B>中文类名:日志查询Controller</B><BR>
 * <B>概要说明:提供查询日志的信息</B><BR>
 *
 * @author leixiyueqi
 * @since 2023/12/13 9:25
 */
@Api(tags = "操作日志记录")
@RestController
@RequestMapping("/sysLogOperation")
public class SysOperationLogController {
    @Autowired
    private SysOperationLogService logService;
    @ApiOperation(value = "查询日志列表")
    @PostMapping(value = "/queryLogList")
    @OperationLog()
    public Object queryPageList(@RequestBody SysOperationLogReq req) {
        return logService.getOperationLogList(req);
    }
}

        至此,一个日志服务基本搭建完成。

     三、生成依赖包

        设计该服务之初,就要求它能支持多个服务。所以,我需要把它打成一个依赖包,让其他服务可以引入,而当前的项目却是一个SpringBoot应用程序,需要调整pom.xml里<build></build>的插件来修改其打包配置,下面顺带介绍一下SpringBoot中打包的常用插件:

	<!-- Spring Boot 官方提供的 Maven 插件,用于打包 Spring Boot 应用程序,打应用程序包用这个 -->
    <plugin>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-maven-plugin</artifactId>
	</plugin>
	
	<!-- Maven 中的一个默认插件,用于执行单元测试。该插件可以在 Maven 构建过程中自动运行指定目录下的测试用例,并生成测试报告 -->

	<plugin>
		<groupId>org.apache.maven.plugins</groupId>
		<artifactId>maven-surefire-plugin</artifactId>
		<version>2.22.2</version>
		<configuration>
			<skipTests>true</skipTests>
		</configuration>
	</plugin>

    <!--Maven 中的一个默认插件,用于编译 Java 代码。该插件可以配置编译器版本、源代码目录、输出目录等参数。 打依赖包就用这个 -->

	<plugin>
		<groupId>org.apache.maven.plugins</groupId>
		<artifactId>maven-compiler-plugin</artifactId>
		<configuration>
			<source>1.8</source>
			<target>1.8</target>
			<encoding>UTF-8</encoding>
		</configuration>
	</plugin>

         打包(mvn install)之前,需要调整一下项目的groupId,artifactId等,比如我的这个项目是用于达梦数据库的日志存储和查询,所以取名为operation-log-dm。打完包后,可以在本地私服里查看生成的jar包

     四、其他服务调用依赖包

        1、在其他服务的pom中添加该依赖文件:

        <dependency>
            <groupId>com.leixi.common.operationlog</groupId>
            <artifactId>operation-log-dm</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

         2、在其他服务的启动类里添加日志相关的服务,Mapper位置,启动类如下,注意,我的这个测试服务的groupId是com.leixi.test,所以在ComponentScan里要配上这个,不然这个服务里的Controller、service方法均不会生效。

/**
 *
 * @author leixiyueqi
 * @since 2023/12/3 22:00
 */
@SpringBootApplication
@MapperScan(basePackages = {"com.leixi.common.operationlog.dao"})
@ComponentScan(basePackages = {"com.leixi.common","com.leixi.test"})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

         3、添加日志存储服务,比如我有一个示例模块,需要在批量操作时批量存储日志信息,我们可以编写一个BasicLogService的子类,如下:


/**
 * <B>系统名称:雷袭月启的组件库</B><BR>
 * <B>模块名称:日志管理模块</B><BR>
 * <B>中文类名:</B><BR>
 * <B>概要说明:</B><BR>
 *
 * @author leixiyueqi
 * @since 2023/12/13 9:25
 */
@Service(Constant.BATCH_DEMO_LOG)
@Slf4j
public class TestOperationLogService extends BasicLogService {

    public void saveOperationLog(SysOperationLog operationLog, Map<String, Object> params) {
        DemoRequest req = (DemoRequest)params.get("req");
        this.fillDefaultInfoToLog(operationLog);
        operationLog.setOperateNt(req.getRemark());
        operationLog.setOperationType(req.getOperateType());
        operationLog.setOperatorId(req.getUserId());
        operationLog.setOperatorName(req.getUserName());
        List<SysOperationLog> operationLogList = new ArrayList<>();
        req.getTaskIdList().forEach(taskId -> {
            SysOperationLog sysLog = operationLog.clone();
            sysLog.setDataId(taskId);
            sysLog.setId(UUID.randomUUID().toString().replaceAll("-", ""));
            if (operationLogList.size() == 0) {
                sysLog.setParams(transParamsToStr(params));
            } else {
                sysLog.setParams("批量操作,详细参数见日志:" + operationLogList.get(0).getId());
            }
            operationLogList.add(sysLog);
        });
        logService.saveBatchOperationLogs(operationLogList);
    }

    public void fillDefaultInfoToLog(SysOperationLog log) {
        log.setId(UUID.randomUUID().toString());
        log.setCreateTime(new Date());
        log.setOperationName("批量DEMO存储");
        log.setOperationCode("BATCH_DEMO_LOG");
        log.setTableName("leixi_demo");
    }

}


//Controller测试
@RestController
public class DemoController {
    @PostMapping("/demo")
    @OperationLog(Constant.BATCH_DEMO_LOG)
    public Object demo(@RequestBody DemoRequest req) {
        return req;
    }
}

          启动测试服务,执行demo请求,最终结果如下图,说明以依赖包的方式引入operation-log-dm,也可以进行日志的存储,以及调用包里的查询接口。

  • 23
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值