【Spring Cloud & Alibaba+Vue微信小程式開源全棧專案實戰】:SpringBoot整合ELK實現分散式登入日誌收集和統計

你好,舊時光發表於2021-03-30

一. 前言

其實早前就想計劃出這篇文章,但是最近主要精力在完善微服務、系統許可權設計、微信小程式和管理前端的功能,不過好在有群裡小夥伴的一起幫忙反饋問題,基礎版的功能已經差不多,也在此謝過,希望今後大家還是能夠相互學習,一起進步~

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的日誌蒐集的功能,本篇實現的需求如下:

  1. 記錄系統使用者登入日誌,資訊包括使用者IP、登入耗時、認證令牌JWT
  2. 統計十天內使用者登入次數、今日訪問IP和總訪問IP
  3. 充分利用記錄的JWT資訊,通過黑名單的方式讓JWT失效實現強制下線

實現效果:

訪問地址:http://www.youlai.store

  • 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 --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. 測試

訪問: http://localhost:5601/

四. 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、微信小程式全棧等技術。

最後附上有來專案往期文章

後臺微服務

  1. Spring Cloud實戰 | 第一篇:Windows搭建Nacos服務
  2. Spring Cloud實戰 | 第二篇:Spring Cloud整合Nacos實現註冊中心
  3. Spring Cloud實戰 | 第三篇:Spring Cloud整合Nacos實現配置中心
  4. Spring Cloud實戰 | 第四篇:Spring Cloud整合Gateway實現API閘道器
  5. Spring Cloud實戰 | 第五篇:Spring Cloud整合OpenFeign實現微服務之間的呼叫
  6. Spring Cloud實戰 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT實現微服務統一認證授權
  7. Spring Cloud實戰 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2整合統一認證授權平臺下實現登出使JWT失效方案
  8. Spring Cloud實戰 | 最八篇:Spring Cloud +Spring Security OAuth2+ Vue前後端分離模式下無感知重新整理實現JWT續期
  9. Spring Cloud實戰 | 最九篇:Spring Security OAuth2認證伺服器統一認證自定義異常處理
  10. Spring Cloud實戰 | 第十篇 :Spring Cloud + Nacos整合Seata 1.4.1最新版本實現微服務架構中的分散式事務,進階之路必須要邁過的檻
  11. Spring Cloud實戰 | 第十一篇 :Spring Cloud Gateway閘道器實現對RESTful介面許可權和按鈕許可權細粒度控制

後臺管理前端

  1. vue-element-admin實戰 | 第一篇: 移除mock接入微服務介面,搭建SpringCloud+Vue前後端分離管理平臺
  2. vue-element-admin實戰 | 第二篇: 最小改動接入後臺實現根據許可權動態載入選單

微信小程式

  1. vue+uni-app商城實戰 | 第一篇:從0到1快速開發一個商城微信小程式,無縫接入Spring Cloud OAuth2認證授權登入

應用部署

  1. Docker實戰 | 第一篇:Linux 安裝 Docker
  2. Docker實戰 | 第二篇:Docker部署nacos-server:1.4.0
  3. Docker實戰 | 第三篇:IDEA整合Docker外掛實現一鍵自動打包部署微服務專案,一勞永逸的技術手段值得一試
  4. Docker實戰 | 第四篇:Docker安裝Nginx,實現基於vue-element-admin框架構建的專案線上部署
  5. Docker實戰 | 第五篇:Docker啟用TLS加密解決暴露2375埠引發的安全漏洞,被黑掉三臺雲主機的教訓總結

相關文章