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

环境:SpringBoot2.2.6 + Elasticsearch6.8.8

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

  1. ES基本概念:​
​https://blog.51cto.com/u_11827525/2860275​
  1. 重温ES基础:​
​https://blog.51cto.com/u_11827525/2860242​
  1. ES-Windows集群搭建:​
​https://blog.51cto.com/u_11827525/2860239​
  1. ES-Docker集群搭建:​
​https://blog.51cto.com/u_11827525/2854808​
  1. MacOS中ES搭建:​​​​​
​https://blog.51cto.com/u_11827525/2860233​

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

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

1、引入依赖文件

​pom.xml​​文件中引入需要的​​es​​、​​aop​​所需的依赖:

<?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\get​​,​​spring-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 中查询一下是否保存成功:

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

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

用Elasticsearch代替数据库存储日志方式_java教程_02image-20200526224614801

9、最后补充

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

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

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