用Elasticsearch代替数据库存储日志方式

之前的项目中一直使用的是数据库表记录用户操作日志的,但随着时间的推移,数据库log单表是越来越大「不考虑删除」,再加上近期项目中需要用到Elasticsearch,所以干脆把这些用户日志迁移到ES上来了。

环境:SpringBoot2.2.6 + Elasticsearch6.8.8

如果你还不了解Elasticsearch的话,可以参考之前的几篇文章:

  1. ES基本概念:https://www.cnblogs.com/niceyoo/p/10864783.html
  2. 重温ES基础:https://www.cnblogs.com/niceyoo/p/11329426.html
  3. ES-Windows集群搭建:https://www.cnblogs.com/niceyoo/p/11343697.html
  4. ES-Docker集群搭建:https://www.cnblogs.com/niceyoo/p/11342903.html
  5. MacOS中ES搭建:https://www.cnblogs.com/niceyoo/p/12936325.html

由于之前就是使用的AOP+注解方式实现日志记录,而本次依旧采用这种方式,所以改动不大,把保存至数据库换成ES就可以了,开始吧。

文章最后我会提供源码的,正文描述部分有省略~

1、引入依赖文件

pom.xml文件中引入需要的esaop所需的依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <!-- Gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>
        <!-- Hutool工具包 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.3.2</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2、修改yml配置文件

加入elasticsearch的配置信息:

server:
  port: 6666
  servlet:
    context-path: /
  tomcat:
    uri-encoding: UTF-8

spring:
  # Elasticsearch
  data:
    elasticsearch:
      client:
        reactive:
          # 要连接的ES客户端 多个逗号分隔
          endpoints: 127.0.0.1:9300
      # 暂未使用ES 关闭其持久化存储
      repositories:
        enabled: true

3、Log实体

使用了lombok@Data 注解」简化 set\getspring-data-elasticsearch提供了@Document@Id@Field注解,其中@Document作用在实体类上,指向文档地址,@Id@Field作用于成员变量上,分别表示主键字段

@Data
@Document(indexName = "log", type = "log", shards = 1, replicas = 0, refreshInterval = "-1")
public class EsLog implements Serializable{
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @Id
    private String id = SnowFlakeUtil.nextId().toString();
    /**
     * 创建者
     */
    private String createBy;
    /**
     * 创建时间
     */
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @Field(type = FieldType.Date, index = false, format = DateFormat.custom, pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime = new Date();
    /**
     * 时间戳 查询时间范围时使用
     */
    private Long timeMillis = System.currentTimeMillis();
    /**
     * 方法操作名称
     */
    private String name;
    /**
     * 日志类型
     */
    private Integer logType;
    /**
     * 请求链接
     */
    private String requestUrl;
    /**
     * 请求类型
     */
    private String requestType;
    /**
     * 请求参数
     */
    private String requestParam;
    /**
     * 请求用户
     */
    private String username;
    /**
     * ip
     */
    private String ip;
    /**
     * 花费时间
     */
    private Integer costTime;
    /**
     * 转换请求参数为Json
     * @param paramMap
     */
    public void setMapToParams(Map<String, String[]> paramMap) {
        this.requestParam = ObjectUtil.mapToString(paramMap);
    }
}

4、Dao层

数据操作层,有两种方式实现对Elasticsearch数据的修改,一是使用ElasticsearchTemplate,二是通过ElasticsearchRepository接口,本文基于后者接口方式。

用过SpringDataJPA的小伙伴就不陌生了,如下实现接口就跟JPA通过方法名称生成SQL一样简单。

/**
 * esc dao
 */
public interface EsLogDao extends ElasticsearchRepository<EsLog, String> {
    /**
     * 通过类型获取
     * @param type
     * @return
     */
    Page<EsLog> findByLogType(Integer type, Pageable pageable);
}

默认情况下,ElasticsearchRepository提供了findById()findAll()findAllById()search()等方法供我们方便使用。

5、自定义注解

自定义 @SystemLog 注解,用于标记需要记录日志的方法。

@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SystemLog {
   /**
    * 日志名称
    * @return
    */
    String description() default "";

   /**
    * 日志类型
    * @return
    */
    LogType type() default LogType.OPERATION;
}

6、编写切面、通知

步骤5中自定义了注解,那么接下来就是定位注解,以及对定位后的方法进行业务处理部分了,而对我们来说就是把日志记录至Elasticsearch中。

/**
 * 日志管理
 */
@Aspect
@Component
@Slf4j
public class SystemLogAspect {

    private static final ThreadLocal<Date> beginTimeThreadLocal = new NamedThreadLocal<Date>("ThreadLocal beginTime");

    @Autowired
    private EsLogService esLogService;

    @Autowired(required = false)
    private HttpServletRequest request;

    /**
     * Controller层切点,注解方式
     */
    @Pointcut("@annotation(com.example.demo.annotation.SystemLog)")
    public void controllerAspect() {

    }

    /**
     * 前置通知 (在方法执行之前返回)用于拦截Controller层记录用户的操作的开始时间
     * @param joinPoint 切点
     * @throws InterruptedException
     */
    @Before("controllerAspect()")
    public void doBefore(JoinPoint joinPoint) throws InterruptedException{

        //线程绑定变量(该数据只有当前请求的线程可见)
        Date beginTime = new Date();
        beginTimeThreadLocal.set(beginTime);
    }

    /**
     * 后置通知(在方法执行之后并返回数据) 用于拦截Controller层无异常的操作
     * @param joinPoint 切点
     */
    @AfterReturning("controllerAspect()")
    public void after(JoinPoint joinPoint){
        try {
            String username = "";
            String description = getControllerMethodInfo(joinPoint).get("description").toString();
            int type = (int)getControllerMethodInfo(joinPoint).get("type");
            Map<String, String[]> logParams = request.getParameterMap();
            EsLog esLog = new EsLog();
            //请求用户
            esLog.setUsername("小伟");
            //日志标题
            esLog.setName(description);
            //日志类型
            esLog.setLogType(type);
            //日志请求url
            esLog.setRequestUrl(request.getRequestURI());
            //请求方式
            esLog.setRequestType(request.getMethod());
            //请求参数
            esLog.setMapToParams(logParams);
            //请求开始时间
            long beginTime = beginTimeThreadLocal.get().getTime();
            long endTime = System.currentTimeMillis();
            //请求耗时
            Long logElapsedTime = endTime - beginTime;
            esLog.setCostTime(logElapsedTime.intValue());
            //调用线程保存至ES
            ThreadPoolUtil.getPool().execute(new SaveEsSystemLogThread(esLog, esLogService));
        } catch (Exception e) {
            log.error("AOP后置通知异常", e);
        }
    }

    /**
     * 保存日志至ES
     */
    private static class SaveEsSystemLogThread implements Runnable {

        private EsLog esLog;
        private EsLogService esLogService;

        public SaveEsSystemLogThread(EsLog esLog, EsLogService esLogService) {
            this.esLog = esLog;
            this.esLogService = esLogService;
        }

        @Override
        public void run() {
            esLogService.saveLog(esLog);
        }
    }

    /**
     * 获取注解中对方法的描述信息 用于Controller层注解
     * @param joinPoint 切点
     * @return 方法描述
     * @throws Exception
     */
    public static Map<String, Object> getControllerMethodInfo(JoinPoint joinPoint) throws Exception{

        Map<String, Object> map = new HashMap<String, Object>(16);
        //获取目标类名
        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 description = "";
        Integer type = null;

        for(Method method : methods) {
            if(!method.getName().equals(methodName)) {
                continue;
            }
            Class[] clazzs = method.getParameterTypes();
            if(clazzs.length != arguments.length) {
                //比较方法中参数个数与从切点中获取的参数个数是否相同,原因是方法可以重载哦
                continue;
            }
            description = method.getAnnotation(SystemLog.class).description();
            type = method.getAnnotation(SystemLog.class).type().ordinal();
            map.put("description", description);
            map.put("type", type);
        }
        return map;
    }

}

7、EsLogService接口类

EsLogService中我们编写几个常用的接口方法,增删改查:

/**
 * 日志操作service
 */
public interface EsLogService {

    /**
     * 添加日志
     * @param esLog
     * @return
     */
    EsLog saveLog(EsLog esLog);

    /**
     * 通过id删除日志
     * @param id
     */
    void deleteLog(String id);

    /**
     * 删除全部日志
     */
    void deleteAll();

    /**
     * 分页搜索获取日志
     * @param type
     * @param key
     * @param searchVo
     * @param pageable
     * @return
     */
    Page<EsLog> findAll(Integer type, String key, SearchVo searchVo, Pageable pageable);
}

我们简单看一下这个 findAll 方法的实现类吧,其他方法就是直接调用ElasticsearchRepository提供的findById()findAll()findAllById()save()等方法。

/**
 * @param type 类型
 * @param key 搜索的关键字
 * @param searchVo
 * @param pageable
 * @return
 */
@Override
public Page<EsLog> findAll(Integer type, String key, SearchVo searchVo, Pageable pageable) {

    if(type==null&&StrUtil.isBlank(key)&&StrUtil.isBlank(searchVo.getStartDate())){
        // 无过滤条件获取全部
        return logDao.findAll(pageable);
    }else if(type!=null&&StrUtil.isBlank(key)&&StrUtil.isBlank(searchVo.getStartDate())){
        // 仅有type
        return logDao.findByLogType(type, pageable);
    }

    QueryBuilder qb;

    QueryBuilder qb0 = QueryBuilders.termQuery("logType", type);
    QueryBuilder qb1 = QueryBuilders.multiMatchQuery(key, "name", "requestUrl", "requestType","requestParam","username","ip");
    // 在有type条件下
    if(StrUtil.isNotBlank(key)&&StrUtil.isBlank(searchVo.getStartDate())&&StrUtil.isBlank(searchVo.getEndDate())){
        // 仅有key
        qb = QueryBuilders.boolQuery().must(qb0).must(qb1);
    }else if(StrUtil.isBlank(key)&&StrUtil.isNotBlank(searchVo.getStartDate())&&StrUtil.isNotBlank(searchVo.getEndDate())){
        // 仅有时间范围
        Long start = DateUtil.parse(searchVo.getStartDate()).getTime();
        Long end = DateUtil.endOfDay(DateUtil.parse(searchVo.getEndDate())).getTime();
        QueryBuilder qb2 = QueryBuilders.rangeQuery("timeMillis").gte(start).lte(end);
        qb = QueryBuilders.boolQuery().must(qb0).must(qb2);
    }else{
        // 两者都有
        Long start = DateUtil.parse(searchVo.getStartDate()).getTime();
        Long end = DateUtil.endOfDay(DateUtil.parse(searchVo.getEndDate())).getTime();
        QueryBuilder qb2 = QueryBuilders.rangeQuery("timeMillis").gte(start).lte(end);
        qb = QueryBuilders.boolQuery().must(qb0).must(qb1).must(qb2);
    }

    //多字段搜索
    return logDao.search(qb, pageable);
}

8、controller层测试方法

/**
 * 日志操作controller
 */
@Slf4j
@RestController
@RequestMapping("/log")
public class LogController {

    @Autowired
    private EsLogService esLogService;

    /**
     * 测试
     */
    @SystemLog(description = "测试", type = LogType.OPERATION)
    @RequestMapping(value = "/getA", method = RequestMethod.GET)
    public Result<Object> getA(String va){
        return ResultUtil.success("测试成功");
    }

    /**
     * 查询全部
     * @param type es 中的logType 不能为空
     * @param key 查询的关键字
     * @param searchVo
     * @param pageVo
     * @return
     */
    @RequestMapping(value = "/getAll", method = RequestMethod.GET)
    public Result<Object> getAll(@RequestParam(required = false) Integer type,@RequestParam String key,SearchVo searchVo,PageVo pageVo){
        Page<EsLog> es = esLogService.findAll(type, key, searchVo, PageUtil.initPage(pageVo));
        return ResultUtil.data(es);
    }

    /**
     * 批量删除
     * @param ids
     * @return
     */
    @RequestMapping(value = "/delByIds", method = RequestMethod.POST)
    public Result<Object> delByIds(@RequestParam String[] ids){
        for(String id : ids){
            esLogService.deleteLog(id);
        }
        return ResultUtil.success("删除成功");
    }

    /**
     * 全部删除
     * @return
     */
    @RequestMapping(value = "/delAll", method = RequestMethod.POST)
    public Result<Object> delAll(){
        esLogService.deleteAll();
        return ResultUtil.success("删除成功");
    }
}

getA()方法为例,直接通过浏览器调用:http://127.0.0.1:6666/log/getA,然后在 ES 中查询一下是否保存成功:

image-20200526224423804image-20200526224423804

以getAll()方法为例,再测试一下查询方法,在浏览器输入 http://127.0.0.1:8888/log/getAll?key=&type=2,返回如下:

image-20200526224614801image-20200526224614801

9、最后补充

本节是我拆分出来的一个demo,经测试增删改查是没问题、同时查询方法加入了分页查询,具体代码细节可以下载本节源码自行查看。

源码下载链接:https://niceyoo.lanzoux.com/id0yikf

如果你觉得本篇文章对你有所帮助,不如右上角关注一下我~

posted @ 2020-05-26 22:54  niceyoo  阅读(3184)  评论(0编辑  收藏  举报