之前的項目中一直使用的是數據庫表記錄用戶操作日志的,但隨着時間的推移,數據庫log單表是越來越大「不考慮刪除」,再加上近期項目中需要用到Elasticsearch
,所以干脆把這些用戶日志遷移到ES上來了。
環境:SpringBoot2.2.6 + Elasticsearch6.8.8
如果你還不了解Elasticsearch的話,可以參考之前的幾篇文章:
- ES基本概念:https://www.cnblogs.com/niceyoo/p/10864783.html
- 重溫ES基礎:https://www.cnblogs.com/niceyoo/p/11329426.html
- ES-Windows集群搭建:https://www.cnblogs.com/niceyoo/p/11343697.html
- ES-Docker集群搭建:https://www.cnblogs.com/niceyoo/p/11342903.html
- MacOS中ES搭建:https://www.cnblogs.com/niceyoo/p/12936325.html
由於之前就是使用的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 中查詢一下是否保存成功:
image-20200526224423804
以getAll()方法為例,再測試一下查詢方法,在瀏覽器輸入 http://127.0.0.1:8888/log/getAll?key=&type=2,返回如下:
image-20200526224614801
9、最后補充
本節是我拆分出來的一個demo,經測試增刪改查是沒問題、同時查詢方法加入了分頁查詢,具體代碼細節可以下載本節源碼自行查看。
源碼下載鏈接:https://niceyoo.lanzoux.com/id0yikf
如果你覺得本篇文章對你有所幫助,不如右上角關注一下我~