一. 前言
其實早前就想計划出這篇文章,但是最近主要精力在完善微服務、系統權限設計、微信小程序和管理前端的功能,不過好在有群里小伙伴的一起幫忙反饋問題,基礎版的功能已經差不多,也在此謝過,希望今后大家還是能夠相互學習,一起進步~
ELK是Elasticsearch、Logstash、Kibana三個開源軟件的組合,相信很多童鞋使用ELK有去做過分布式日志收集。流程概括為:微服務應用把Logback輸出的日志通過HTTP傳輸至LogStash,然后經過分析過濾,轉發至ES,再由Kibana提供檢索和統計可視化界面。
在本實戰案例中,使用Spring AOP、Logback橫切認證接口來記錄用戶登錄日志,收集到ELK,通過SpringBoot整合RestHighLevelClient實現對ElasticSearch數據檢索和統計。從日志搜集到數據統計,一次性的走個完整,快速入門ElasticSearch。
本篇涉及的前后端全部源碼已上傳gitee和github,熟悉有來項目的童鞋快速過一下步驟即可。
項目名稱 | Github | 碼雲 |
---|---|---|
后台 | youlai-mall | youlai-mall |
前端 | youlai-mall-admin | youlai-mall-admin |
二. 需求
基於ELK的日志搜集的功能,本篇實現的需求如下:
- 記錄系統用戶登錄日志,信息包括用戶IP、登錄耗時、認證令牌JWT
- 統計十天內用戶登錄次數、今日訪問IP和總訪問IP
- 充分利用記錄的JWT信息,通過黑名單的方式讓JWT失效實現強制下線
實現效果:
- Kibana日志可視化統計
- 登錄次數統計、今日訪問IP統計、總訪問IP統計
- 登錄信息,強制用戶下線,演示的是自己強制自己下線的效果
三. Docker快速搭建ELK環境
1. 拉取鏡像
docker pull elasticsearch:7.10.1
docker pull kibana:7.10.1
docker pull logstash:7.10.1
2. elasticsearch部署
1. 環境准備
# 創建文件
mkdir -p /opt/elasticsearch/{plugins,data} /etc/elasticsearch
touch /etc/elasticsearch/elasticsearch.yml
chmod -R 777 /opt/elasticsearch/data/
vim /etc/elasticsearch/elasticsearch.yml
# 寫入
cluster.name: elasticsearch
http.cors.enabled: true
http.cors.allow-origin: "*"
http.host: 0.0.0.0
node.max_local_storage_nodes: 100
2. 啟動容器
docker run -d --name=elasticsearch --restart=always \
-e discovery.type=single-node \
-e ES_JAVA_OPTS="-Xms256m -Xmx256m" \
-p 9200:9200 \
-p 9300:9300 \
-v /etc/elasticsearch/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /opt/elasticsearch/data:/usr/share/elasticsearch/data \
-v /opt/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
elasticsearch:7.10.1
3. 驗證和查看ElasticSearch版本
curl -XGET localhost:9200
2. kibana部署
1. 環境准備
# 創建文件
mkdir -p /etc/kibana
vim /etc/kibana/kibana.yml
# 寫入
server.name: kibana
server.host: "0"
elasticsearch.hosts: [ "http://elasticsearch:9200" ]
i18n.locale: "zh-CN"
2. 啟動容器
docker run -d --restart always -p 5601:5601 --name kibana --link elasticsearch \
-e ELASTICSEARCH_URL=http://elasticsearch:9200 \
-v /etc/kibana/kibana.yml:/usr/share/kibana/config/kibana.yml \
kibana:7.10.1
3. logstash部署
1. 環境准備
- 配置
logstash.yml
# 創建文件
mkdir -p /etc/logstash/config
vim /etc/logstash/config/logstash.yml
# 寫入
http.host: "0.0.0.0"
xpack.monitoring.elasticsearch.hosts: [ "http://elasticsearch:9200" ]
xpack.management.pipeline.id: ["main"]
- 配置
pipeline.yml
# 創建文件
vim /etc/logstash/config/pipeline.yml
# 寫入(注意空格)
- pipeline.id: main
path.config: "/usr/share/logstash/pipeline/logstash.config"
- 配置
logstash.conf
# 創建文件
mkdir -p /etc/logstash/pipeline
vim /etc/logstash/pipeline/logstash.conf
# 寫入
input {
tcp {
port => 5044
mode => "server"
host => "0.0.0.0"
codec => json_lines
}
}
filter{
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
# 索引名稱,沒有會自動創建
index => "%{[project]}-%{[action]}-%{+YYYY-MM-dd}"
}
}
2. 啟動容器
docker run -d --restart always -p 5044:5044 -p 9600:9600 --name logstash --link elasticsearch \
-v /etc/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml \
-v /etc/logstash/config/pipeline.yml:/usr/share/logstash/config/pipeline.yml \
-v /etc/logstash/pipeline/logstash.conf:/usr/share/logstash/pipeline/logstash.conf \
logstash:7.10.1
4. 測試
四. Spring AOP + Logback 橫切打印登錄日志
1. Spring AOP橫切認證接口添加日志
代碼坐標: common-web#LoginLogAspect
@Aspect
@Component
@AllArgsConstructor
@Slf4j
@ConditionalOnProperty(value = "spring.application.name", havingValue = "youlai-auth")
public class LoginLogAspect {
@Pointcut("execution(public * com.youlai.auth.controller.AuthController.postAccessToken(..))")
public void Log() {
}
@Around("Log()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
LocalDateTime startTime = LocalDateTime.now();
Object result = joinPoint.proceed();
// 獲取請求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 刷新token不記錄
String grantType=request.getParameter(AuthConstants.GRANT_TYPE_KEY);
if(grantType.equals(AuthConstants.REFRESH_TOKEN)){
return result;
}
// 時間統計
LocalDateTime endTime = LocalDateTime.now();
long elapsedTime = Duration.between(startTime, endTime).toMillis(); // 請求耗時(毫秒)
// 獲取接口描述信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String description = signature.getMethod().getAnnotation(ApiOperation.class).value();// 方法描述
String username = request.getParameter(AuthConstants.USER_NAME_KEY); // 登錄用戶名
String date = startTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 索引名需要,因為默認生成索引的date時區不一致
// 獲取token
String token = Strings.EMPTY;
if (request != null) {
JSONObject jsonObject = JSONUtil.parseObj(result);
token = jsonObject.getStr("value");
}
String clientIP = IPUtils.getIpAddr(request); // 客戶端請求IP(注意:如果使用Nginx代理需配置)
String region = IPUtils.getCityInfo(clientIP); // IP對應的城市信息
// MDC 擴展logback字段,具體請看logback-spring.xml的自定義日志輸出格式
MDC.put("elapsedTime", StrUtil.toString(elapsedTime));
MDC.put("description", description);
MDC.put("region", region);
MDC.put("username", username);
MDC.put("date", date);
MDC.put("token", token);
MDC.put("clientIP", clientIP);
log.info("{} 登錄,耗費時間 {} 毫秒", username, elapsedTime); // 收集日志這里必須打印一條日志,內容隨便吧,記錄在message字段,具體看logback-spring.xml文件
return result;
}
}
2. Logback日志上傳至LogStash
代碼坐標:common-web#logback-spring.xml
<!-- Logstash收集登錄日志輸出到ElasticSearch -->
<appender name="LOGIN_LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>localhost:5044</destination>
<encoder charset="UTF-8" class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<timeZone>Asia/Shanghai</timeZone>
</timestamp>
<!--自定義日志輸出格式-->
<pattern>
<pattern>
{
"project": "${APP_NAME}",
"date": "%X{date}", <!-- 索引名時區同步 -->
"action":"login",
"pid": "${PID:-}",
"thread": "%thread",
"message": "%message",
"elapsedTime": "%X{elapsedTime}",
"username":"%X{username}",
"clientIP": "%X{clientIP}",
"region":"%X{region}",
"token":"%X{token}",
"loginTime": "%date{\"yyyy-MM-dd HH:mm:ss\"}",
"description":"%X{description}"
}
</pattern>
</pattern>
</providers>
</encoder>
<keepAliveDuration>5 minutes</keepAliveDuration>
</appender>
<!-- additivity="true" 默認是true 會向上傳遞至root -->
<logger name="com.youlai.common.web.aspect.LoginLogAspect" level="INFO" additivity="true">
<appender-ref ref="LOGIN_LOGSTASH"/>
</logger>
- localhost:5044 Logstash配置的input收集數據的監聽
- %X{username} 輸出MDC添加的username的值
五. SpringBoot整合ElasticSearch客戶端RestHighLevelClient
1. pom依賴
代碼坐標: common-elasticsearch#pom.xml
客戶端的版本需和服務器的版本對應,這里也就是7.10.1
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<exclusions>
<exclusion>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
</exclusion>
<exclusion>
<artifactId>elasticsearch</artifactId>
<groupId>org.elasticsearch</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.10.1</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.10.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
2. yml 配置
spring:
elasticsearch:
rest:
uris: ["http://localhost:9200"]
cluster-nodes:
- localhost:9200
3. RestHighLevelClientConfig 配置類
代碼坐標: common-elasticsearch#RestHighLevelClientConfig
@ConfigurationProperties(prefix = "spring.elasticsearch.rest")
@Configuration
@AllArgsConstructor
public class RestHighLevelClientConfig {
@Setter
private List<String> clusterNodes;
@Bean
public RestHighLevelClient restHighLevelClient() {
HttpHost[] hosts = clusterNodes.stream()
.map(this::buildHttpHost) // eg: new HttpHost("127.0.0.1", 9200, "http")
.toArray(HttpHost[]::new);
return new RestHighLevelClient(RestClient.builder(hosts));
}
private HttpHost buildHttpHost(String node) {
String[] nodeInfo = node.split(":");
return new HttpHost(nodeInfo[0].trim(), Integer.parseInt(nodeInfo[1].trim()), "http");
}
}
4. RestHighLevelClient API封裝
代碼坐標: common-elasticsearch#ElasticSearchService
- 暫只簡單封裝實現需求里需要的幾個方法,計數、去重計數、日期聚合統計、列表查詢、分頁查詢、刪除,后續可擴展...
@Service
@AllArgsConstructor
public class ElasticSearchService {
private RestHighLevelClient client;
/**
* 計數
*/
@SneakyThrows
public long count(QueryBuilder queryBuilder, String... indices) {
// 構造請求
CountRequest countRequest = new CountRequest(indices);
countRequest.query(queryBuilder);
// 執行請求
CountResponse countResponse = client.count(countRequest, RequestOptions.DEFAULT);
long count = countResponse.getCount();
return count;
}
/**
* 去重計數
*/
@SneakyThrows
public long countDistinct(QueryBuilder queryBuilder, String field, String... indices) {
String distinctKey = "distinctKey"; // 自定義計數去重key,保證上下文一致
// 構造計數聚合 cardinality:集合中元素的個數
CardinalityAggregationBuilder aggregationBuilder = AggregationBuilders
.cardinality(distinctKey).field(field);
// 構造搜索源
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder).aggregation(aggregationBuilder);
// 構造請求
SearchRequest searchRequest = new SearchRequest(indices);
searchRequest.source(searchSourceBuilder);
// 執行請求
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
ParsedCardinality result = searchResponse.getAggregations().get(distinctKey);
return result.getValue();
}
/**
* 日期聚合統計
*
* @param queryBuilder 查詢條件
* @param field 聚合字段,如:登錄日志的 date 字段
* @param interval 統計時間間隔,如:1天、1周
* @param indices 索引名稱
* @return
*/
@SneakyThrows
public Map<String, Long> dateHistogram(QueryBuilder queryBuilder, String field, DateHistogramInterval interval, String... indices) {
String dateHistogramKey = "dateHistogramKey"; // 自定義日期聚合key,保證上下文一致
// 構造聚合
AggregationBuilder aggregationBuilder = AggregationBuilders
.dateHistogram(dateHistogramKey) //自定義統計名,和下文獲取需一致
.field(field) // 日期字段名
.format("yyyy-MM-dd") // 時間格式
.calendarInterval(interval) // 日歷間隔,例: 1s->1秒 1d->1天 1w->1周 1M->1月 1y->1年 ...
.minDocCount(0); // 最小文檔數,比該值小就忽略
// 構造搜索源
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder
.query(queryBuilder)
.aggregation(aggregationBuilder)
.size(0);
// 構造SearchRequest
SearchRequest searchRequest = new SearchRequest(indices);
searchRequest.source(searchSourceBuilder);
searchRequest.indicesOptions(
IndicesOptions.fromOptions(
true, // 是否忽略不可用索引
true, // 是否允許索引不存在
true, // 通配符表達式將擴展為打開的索引
false // 通配符表達式將擴展為關閉的索引
));
// 執行請求
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
// 處理結果
ParsedDateHistogram dateHistogram = searchResponse.getAggregations().get(dateHistogramKey);
Iterator<? extends Histogram.Bucket> iterator = dateHistogram.getBuckets().iterator();
Map<String, Long> map = new HashMap<>();
while (iterator.hasNext()) {
Histogram.Bucket bucket = iterator.next();
map.put(bucket.getKeyAsString(), bucket.getDocCount());
}
return map;
}
/**
* 列表查詢
*/
@SneakyThrows
public <T extends BaseDocument> List<T> search(QueryBuilder queryBuilder, Class<T> clazz, String... indices) {
List<T> list = this.search(queryBuilder, null, 1, ESConstants.DEFAULT_PAGE_SIZE, clazz, indices);
return list;
}
/**
* 分頁列表查詢
*/
@SneakyThrows
public <T extends BaseDocument> List<T> search(QueryBuilder queryBuilder, SortBuilder sortBuilder, Integer page, Integer size, Class<T> clazz, String... indices) {
// 構造SearchSourceBuilder
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder);
searchSourceBuilder.sort(sortBuilder);
searchSourceBuilder.from((page - 1) * size);
searchSourceBuilder.size(size);
// 構造SearchRequest
SearchRequest searchRequest = new SearchRequest(indices);
searchRequest.source(searchSourceBuilder);
// 執行請求
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
List<T> list = CollectionUtil.newArrayList();
for (SearchHit hit : searchHits) {
T t = JSONUtil.toBean(hit.getSourceAsString(), clazz);
t.setId(hit.getId()); // 數據的唯一標識
t.setIndex(hit.getIndex());// 索引
list.add(t);
}
return list;
}
/**
* 刪除
*/
@SneakyThrows
public boolean deleteById(String id, String index) {
DeleteRequest deleteRequest = new DeleteRequest(index,id);
DeleteResponse deleteResponse = client.delete(deleteRequest, RequestOptions.DEFAULT);
return true;
}
}
六. 后台接口
在SpringBoot整合了ElasticSearch的高級客戶端RestHighLevelClient,以及簡單了封裝方法之后,接下來就准備為前端提供統計數據、分頁列表查詢記錄、根據ID刪除記錄接口了。
1. 首頁控制台
首頁控制台需要今日IP訪問數,歷史總IP訪問數、近十天每天的登錄次數統計,具體代碼如下:
代碼坐標: youlai-admin#DashboardController
@Api(tags = "首頁控制台")
@RestController
@RequestMapping("/api.admin/v1/dashboard")
@Slf4j
@AllArgsConstructor
public class DashboardController {
ElasticSearchService elasticSearchService;
@ApiOperation(value = "控制台數據")
@GetMapping
public Result data() {
Map<String, Object> data = new HashMap<>();
// 今日IP數
long todayIpCount = getTodayIpCount();
data.put("todayIpCount", todayIpCount);
// 總IP數
long totalIpCount = getTotalIpCount();
data.put("totalIpCount", totalIpCount);
// 登錄統計
int days = 10; // 統計天數
Map loginCount = getLoginCount(days);
data.put("loginCount", loginCount);
return Result.success(data);
}
private long getTodayIpCount() {
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("date", date);
String indexName = ESConstants.LOGIN_INDEX_PATTERN + date; //索引名稱
// 這里使用clientIP聚合計數,為什么加.keyword后綴呢?下文給出截圖
long todayIpCount = elasticSearchService.countDistinct(termQueryBuilder, "clientIP.keyword", indexName);
return todayIpCount;
}
private long getTotalIpCount() {
long totalIpCount = elasticSearchService.countDistinct(null, "clientIP.keyword", ESConstants.LOGIN_INDEX_PATTERN);
return totalIpCount;
}
private Map getLoginCount(int days) {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String startDate = now.plusDays(-days).format(formatter);
String endDate = now.format(formatter);
String[] indices = new String[days]; // 查詢ES索引數組
String[] xData = new String[days]; // 柱狀圖x軸數據
for (int i = 0; i < days; i++) {
String date = now.plusDays(-i).format(formatter);
xData[i] = date;
indices[i] = ESConstants.LOGIN_INDEX_PREFIX + date;
}
// 查詢條件,范圍內日期統計
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("date").from(startDate).to(endDate);
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery()
.must(rangeQueryBuilder);
// 總數統計
Map<String, Long> totalCountMap = elasticSearchService.dateHistogram(
boolQueryBuilder,
"date", // 根據date字段聚合統計登錄數 logback-spring.xml 中的自定義擴展字段 date
DateHistogramInterval.days(1),
indices);
// 當前用戶統計
HttpServletRequest request = RequestUtils.getRequest();
String clientIP = IPUtils.getIpAddr(request);
boolQueryBuilder.must(QueryBuilders.termQuery("clientIP", clientIP));
Map<String, Long> myCountMap = elasticSearchService.dateHistogram(boolQueryBuilder, "date", DateHistogramInterval.days(1), indices);
// 組裝echarts數據
Long[] totalCount = new Long[days];
Long[] myCount = new Long[days];
Arrays.sort(xData);// 默認升序
for (int i = 0; i < days; i++) {
String key = xData[i];
totalCount[i] = Convert.toLong(totalCountMap.get(key), 0l);
myCount[i] = Convert.toLong(myCountMap.get(key), 0l);
}
Map<String, Object> map = new HashMap<>(4);
map.put("xData", xData); // x軸坐標
map.put("totalCount", totalCount); // 總數
map.put("myCount", myCount); // 我的
return map;
}
}
- 聚合字段clientIP為什么添加.keyword后綴?
2. 登錄記錄分頁查詢接口
代碼坐標: youlai-admin # LoginRecordController
@Api(tags = "登錄記錄")
@RestController
@RequestMapping("/api.admin/v1/login_records")
@Slf4j
@AllArgsConstructor
public class LoginRecordController {
ElasticSearchService elasticSearchService;
ITokenService tokenService;
@ApiOperation(value = "列表分頁")
@ApiImplicitParams({
@ApiImplicitParam(name = "page", value = "頁碼", defaultValue = "1", paramType = "query", dataType = "Long"),
@ApiImplicitParam(name = "limit", value = "每頁數量", defaultValue = "10", paramType = "query", dataType = "Long"),
@ApiImplicitParam(name = "startDate", value = "開始日期", paramType = "query", dataType = "String"),
@ApiImplicitParam(name = "endDate", value = "結束日期", paramType = "query", dataType = "String"),
@ApiImplicitParam(name = "clientIP", value = "客戶端IP", paramType = "query", dataType = "String")
})
@GetMapping
public Result list(
Integer page,
Integer limit,
String startDate,
String endDate,
String clientIP
) {
// 日期范圍
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("date");
if (StrUtil.isNotBlank(startDate)) {
rangeQueryBuilder.from(startDate);
}
if (StrUtil.isNotBlank(endDate)) {
rangeQueryBuilder.to(endDate);
}
BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery().must(rangeQueryBuilder);
if (StrUtil.isNotBlank(clientIP)) {
queryBuilder.must(QueryBuilders.wildcardQuery("clientIP", "*" + clientIP + "*"));
}
// 總記錄數
long count = elasticSearchService.count(queryBuilder, ESConstants.LOGIN_INDEX_PATTERN);
// 排序
FieldSortBuilder sortBuilder = new FieldSortBuilder("@timestamp").order(SortOrder.DESC);
// 分頁查詢
List<LoginRecord> list = elasticSearchService.search(queryBuilder, sortBuilder, page, limit, LoginRecord.class, ESConstants.LOGIN_INDEX_PATTERN);
// 遍歷獲取會話狀態
list.forEach(item -> {
String token = item.getToken();
int tokenStatus = 0;
if (StrUtil.isNotBlank(token)) {
tokenStatus = tokenService.getTokenStatus(item.getToken());
}
item.setStatus(tokenStatus);
});
return Result.success(list, count);
}
@ApiOperation(value = "刪除登錄記錄")
@ApiImplicitParam(name = "ids", value = "id集合", required = true, paramType = "query", dataType = "String")
@DeleteMapping
public Result delete(@RequestBody List<BaseDocument> documents) {
documents.forEach(document -> elasticSearchService.deleteById(document.getId(), document.getIndex()));
return Result.success();
}
}
3. 強制下線接口
代碼坐標: youlai-admin#TokenController
- 這里還是將JWT添加至黑名單,然后在網關限制被加入黑名單的JWT登錄
@Api(tags = "令牌接口")
@RestController
@RequestMapping("/api.admin/v1/tokens")
@Slf4j
@AllArgsConstructor
public class TokenController {
ITokenService tokenService;
@ApiOperation(value = "強制下線")
@ApiImplicitParam(name = "token", value = "訪問令牌", required = true, paramType = "query", dataType = "String")
@PostMapping("/{token}/_invalidate")
@SneakyThrows
public Result invalidateToken(@PathVariable String token) {
boolean status = tokenService.invalidateToken(token);
return Result.judge(status);
}
}
代碼坐標: youlai-admin#TokenServiceImpl
@Override
@SneakyThrows
public boolean invalidateToken(String token) {
JWTPayload payload = JWTUtils.getJWTPayload(token);
// 計算是否過期
long currentTimeSeconds = System.currentTimeMillis() / 1000;
Long exp = payload.getExp();
if (exp < currentTimeSeconds) { // token已過期,無需加入黑名單
return true;
}
// 添加至黑名單使其失效
redisTemplate.opsForValue().set(AuthConstants.TOKEN_BLACKLIST_PREFIX + payload.getJti(), null, (exp - currentTimeSeconds), TimeUnit.SECONDS);
return true;
}
七. 前端界面
項目前端源碼:youlai-mall-admin,以下只貼出頁面路徑,有興趣下載到本地查看源碼和效果
代碼坐標: src/views/dashboard/common/components/LoginCountChart.vue
- 登錄次數統計、今日訪問IP統計、總訪問IP統計
代碼坐標: src/views/admin/record/login/index.vue
- 登錄信息,強制用戶下線,演示的是自己強制自己下線的效果
八. 問題
1. 日志記錄登錄時間比正常時間晚了8個小時
項目使用Docker部署,其中依賴openjdk鏡像時區是UTC,比北京時間晚了8個小時,執行以下命令修改時區解決問題
docker exec -it youlai-auth /bin/sh
echo "Asia/Shanghai" > /etc/timezone
docker restart youlai-auth
2. 用Nginx代理轉發,怎么獲取用戶的真實IP?
在配置代理轉發的時候添加:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
九. Kibana索引檢索
在LogStash的logout我們指定了索引的名稱 "%{[project]}-%{[action]}-%{+YYYY-MM-dd}"
在logback-spring.xml指定了project為youlai-auth,action為login,替換生成類似youlai-auth-login-2021-3-25的索引,其中日期是可變的,然后我們在Kibana界面創建youlai-auth-login-*索引模式來對日志進行檢索。
- 創建youlai-auth-login-*索引模式
- 根據索引模式,設置日期范圍,進行登錄日志的檢索
十. 結語
至此,整個實戰過程已經完成,搭建了ELK環境,使用Spring AOP橫切來對登錄日志的定點的搜集,最后通過SpringBoot整合ElasticSearch的高級Java客戶端RestHighLevelClient來對搜集登錄日志信息進行聚合計數、統計、以及日志中訪問令牌操作來實現無狀態的JWT會話管理,強制JWT失效讓用戶下線。文中只貼出關鍵的代碼,其中還有像IP轉地區的工具使用鑒於篇幅的原因並未一一說明,完整代碼請參考git上的完整源代碼。點擊跳轉
希望大家通過本篇文章能夠快速入門ElasticSearch,如果有問題歡迎留言或者加我微信(haoxianrui)。
終. 附錄
歡迎大家加入開源項目有來項目交流群,一起學習Spring Cloud微服務生態組件、分布式、Docker、K8S、Vue、element-ui、uni-app等全棧技術。
最后附上有來項目往期文章
后台微服務
- Spring Cloud實戰 | 第一篇:Windows搭建Nacos服務
- Spring Cloud實戰 | 第二篇:Spring Cloud整合Nacos實現注冊中心
- Spring Cloud實戰 | 第三篇:Spring Cloud整合Nacos實現配置中心
- Spring Cloud實戰 | 第四篇:Spring Cloud整合Gateway實現API網關
- Spring Cloud實戰 | 第五篇:Spring Cloud整合OpenFeign實現微服務之間的調用
- Spring Cloud實戰 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT實現微服務統一認證授權
- Spring Cloud實戰 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2集成統一認證授權平台下實現注銷使JWT失效方案
- Spring Cloud實戰 | 最八篇:Spring Cloud +Spring Security OAuth2+ Vue前后端分離模式下無感知刷新實現JWT續期
- Spring Cloud實戰 | 最九篇:Spring Security OAuth2認證服務器統一認證自定義異常處理
- Spring Cloud實戰 | 第十篇 :Spring Cloud + Nacos整合Seata 1.4.1最新版本實現微服務架構中的分布式事務,進階之路必須要邁過的檻
- Spring Cloud實戰 | 第十一篇 :Spring Cloud Gateway網關實現對RESTful接口權限和按鈕權限細粒度控制
后台管理前端
- vue-element-admin實戰 | 第一篇: 移除mock接入微服務接口,搭建SpringCloud+Vue前后端分離管理平台
- vue-element-admin實戰 | 第二篇: 最小改動接入后台實現根據權限動態加載菜單
微信小程序
應用部署