1. 程式人生 > 其它 >第二部分:實戰二

第二部分:實戰二

第二部分:實戰二

實戰二(上)

專案背景

  • 文中舉例,設計開發一個小的框架,能夠獲取介面呼叫的各種統計資訊,並且支援將統計結果以各種顯示格式輸出到各種終端,以方便檢視。

需求分析

  • 效能計數器作為一個跟業務無關的功能,我們完全可以把它開發成一個獨立的框架或者類庫,整合到很多業務系統中。
  • 作為可被複用的框架,除了功能性需求之外,非功能性需求也非常重要。

功能性需求分析

  • 介面統計資訊:包括介面響應時間的統計資訊,以及介面呼叫次數的統計資訊等。
  • 統計資訊的型別:max、min、avg、percentile、count、tps 等。
  • 統計資訊顯示格式:Json、Html、自定義顯示格式。
  • 統計資訊顯示終端:Console、Email、HTTP 網頁、日誌、自定義顯示終端。
  • 統計觸發方式:包括主動和被動兩種。
  • 統計時間區間:框架需要支援自定義統計時間區間。
  • 統計時間間隔:對於主動觸發統計,我們還要支援指定統計時間間隔,也就是多久觸發一次統計顯示。

非功能性需求分析

  • 易用性:
    • 框架是否易整合、易插拔、跟業務程式碼是否鬆耦合、提供的介面是否夠靈活等等,都是我們應該花心思去思考和設計的。
    • 有的時候,文件寫得好壞甚至都有可能決定一個框架是否受歡迎。
  • 效能:
    • 對於需要整合到業務系統的框架來說,我們不希望框架本身的程式碼執行效率,對業務系統有太多效能上的影響。
  • 擴充套件性:
    • 在不修改或儘量少修改程式碼的情況下新增新的功能。
    • 使用者可以在不修改框架原始碼,甚至不拿到框架原始碼的情況下,為框架擴充套件新的功能。這就有點類似給框架開發外掛。
    • 比如,在不修改框架原始碼的情況下,以繼承框架中方法的方式來達到修改框架原始碼中方法的目的。
  • 容錯性:
    • 不能因為框架本身的異常導致介面請求出錯。
    • 對外暴露的介面丟擲的所有執行時、非執行時異常都進行捕獲處理。
  • 通用性:
    • 為了提高框架的複用性,能夠靈活應用到各種場景中。框架在設計的時候,要儘可能通用。

框架設計

  • 資料採集:
    • 負責打點採集原始資料,包括記錄每次介面請求的響應時間和請求時間。
    • 資料採集過程要高度容錯,不能影響到介面本身的可用性。
    • 暴露給框架的使用者,也要儘量考慮其易用性。
  • 儲存:
    • 負責將採集的原始資料儲存下來,以便後面做聚合統計。
    • 資料儲存比較耗時,為了儘量地減少對介面效能(比如響應時間)的影響,採集和儲存的過程非同步完成。
  • 聚合統計:
    • 負責將原始資料聚合為統計資料。
    • 為了支援更多的聚合統計規則,程式碼希望儘可能靈活、可擴充套件。
  • 顯示:
    • 負責將統計資料以某種格式顯示到終端。

解決一個簡單應用場景的效能計數器:統計使用者註冊、登入這兩個介面的響應時間的最大值和平均值、介面呼叫次數,並且將統計結果以 JSON 的格式輸出到命令列中。

應用場景的程式碼,具體如下所示:

// 應用場景:統計下面兩個介面 (註冊和登入)的響應時間和訪問次數
public class UserController {
	public void register(UserVo user) {
		//...
	}
	public UserVo login(String telephone, String password) {
		//...
	}
}

最小原型實現如下所示:recordResponseTime() 和 recordTimestamp() 兩個函式分別用來記錄介面請求的響應時間和訪問時間,startRepeatedReport() 函式以指定的頻率統計資料並輸出結果

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.google.gson.Gson;

public class Metrics {
        // Map 的 key 是介面名稱,value 對應介面請求的響應時間或時間戳;
        private Map<String, List<Double>> responseTimes = new HashMap<>();
        private Map<String, List<Double>> timestamps = new HashMap<>();
        private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

        public void recordResponseTime(String apiName, double responseTime) {
            responseTimes.putIfAbsent(apiName, new ArrayList<>());
            responseTimes.get(apiName).add(responseTime);
        }

        public void recordTimestamp(String apiName, double timestamp) {
            timestamps.putIfAbsent(apiName, new ArrayList<>());
            timestamps.get(apiName).add(timestamp);
        }

        public void startRepeatedReport(long period, TimeUnit unit){
            executor.scheduleAtFixedRate(new Runnable() {
                @Override
                public void run() {
                    Gson gson = new Gson();
                    Map<String, Map<String, Double>> stats = new HashMap<>();
                    for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet()) {
                        String apiName = entry.getKey();
                        List<Double> apiRespTimes = entry.getValue();
                        stats.putIfAbsent(apiName, new HashMap<>());
                        stats.get(apiName).put("max", max(apiRespTimes));
                        stats.get(apiName).put("avg", avg(apiRespTimes));
                    }
                    for (Map.Entry<String, List<Double>> entry : timestamps.entrySet()) {
                        String apiName = entry.getKey();
                        List<Double> apiTimestamps = entry.getValue();
                        stats.putIfAbsent(apiName, new HashMap<>());
                        stats.get(apiName).put("count", (double)apiTimestamps.size());
                    }
                    System.out.println(gson.toJson(stats));

                }
            }, 0, period, unit);
        }

    private double max(List<Double> dataset) {
        // 省略程式碼實現
        return (Double)null;
        
    }
    private double avg(List<Double> dataset) {
        // 省略程式碼實現
        return (Double)null;
    }

}

如何用它來統計註冊、登入介面的響應時間和訪問次數,具體的程式碼如下所示:

// 應用場景:統計下面兩個介面 (註冊和登入)的響應時間和訪問次數
import java.util.concurrent.TimeUnit;

public class UserController {
    private Metrics metrics = new Metrics();

    public UserController() {
        metrics.startRepeatedReport(60, TimeUnit.SECONDS);
    }

    public void register(UserVo user) {
        long startTimestamp = System.currentTimeMillis();
        metrics.recordTimestamp("regsiter", startTimestamp);
        //...
        long respTime = System.currentTimeMillis() - startTimestamp;
        metrics.recordResponseTime("register", respTime);
    }

    public UserVo login(String telephone, String password) {
        long startTimestamp = System.currentTimeMillis();
        metrics.recordTimestamp("login", startTimestamp);
        //...
        long respTime = System.currentTimeMillis() - startTimestamp;
        metrics.recordResponseTime("login", respTime);
        return (UserVo) null;
    }
}

實戰二(下)

小步快跑、逐步迭代

  • 資料採集:負責打點採集原始資料,包括記錄每次介面請求的響應時間。
  • 儲存:負責將採集的原始資料儲存下來,以便之後做聚合統計。資料的儲存方式有很多種,我們暫時只支援 Redis 這一種儲存方式,並且,採集與儲存兩個過程同步執行。
  • 聚合統計:負責將原始資料聚合為統計資料,包括響應時間的最大值、最小值、平均值、99.9 百分位值、99 百分位值,以及介面請求的次數和 tps。
  • 顯示:負責將統計資料以某種格式顯示到終端,暫時只支援主動推送給命令列和郵件。命令列間隔 n 秒統計顯示上 m 秒的資料(比如,間隔 60s 統計上 60s 的資料)。郵件每日統計上日的資料。

面向物件設計與實現

劃分職責進而識別出有哪些類

  • MetricsCollector 類負責提供 API,來採集介面請求的原始資料。我們可以為 MetricsCollector 抽象出一個介面,但這並不是必須的,因為暫時我們只能想到一個 MetricsCollector 的實現方式。
  • MetricsStorage 介面負責原始資料儲存,RedisMetricsStorage 類實現 MetricsStorage 介面。這樣做是為了今後靈活地擴充套件新的儲存方法,比如用 HBase 來儲存。
  • Aggregator 類負責根據原始資料計算統計資料。
  • ConsoleReporter 類、EmailReporter 類分別負責以一定頻率統計併發送統計資料到命令列和郵件。至於ConsoleReporter 和 EmailReporter 是否可以抽象出可複用的抽象類,或者抽象出一個公共的介面,我們暫時還不能確定。

定義類及類與類之間的關係

  • 接下來就是定義類及屬性和方法,定義類與類之間的關係。這兩步沒法分得很開。
  • MetricsStorage 介面定義存取資料相關的屬性和方法。RedisMetricsStorage 類實現 MetricsStorage 介面,填充具體的方法和屬性。
  • MetricsCollector 類在建構函式中,以依賴注入的方式引入 MetricsStorage 介面,並在類內部的方法中得以呼叫資料存取的方法。
  • 統計顯示所要完成的功能邏輯細分位下面 4 點:
    1. 根據給定的時間區間,從資料庫中拉取資料
    2. 根據原始資料,計算得到統計資料
    3. 將統計資料顯示到終端(命令列或郵件)
    4. 定時觸發以上 3 個過程的執行
  • 面向物件設計和實現要做的事情,就是把合適的程式碼放到合適的類中。讓程式碼儘量地滿足低耦合、高內聚、單一職責、對擴充套件開放對修改關閉等之前講到的各種設計原則和思想,儘量地讓設計滿足程式碼易複用、易讀、易擴充套件、易維護。
  • 我們暫時選擇把第 1、3、4 邏輯放到 ConsoleReporter 或 EmailReporter 類中,把第 2 個邏輯放到 Aggregator 類中。
  • Aggregator 類負責的邏輯比較簡單,我們把它設計成只包含靜態方法的工具類。靜態方法中有統計方式,比如加和、取最大最小等,使用RequestStat 類中的 set 方法賦值給 RequestStat 類定義的這些統計屬性。
  • ConsoleReporter 類相當於一個上帝類,定時根據給定的時間區間,從資料庫中取出資料,藉助 Aggregator 類完成統計工作,並將統計結果輸出到命令列。也就是在 4 中定時觸發 1、2、3 程式碼的執行。

MetricsCollector 程式碼實現:

import org.apache.commons.lang3.StringUtils;

public class MetricsCollector {
    private MetricsStorage metricsStorage;// 基於介面而非實現程式設計
    // 依賴注入
    public MetricsCollector(MetricsStorage metricsStorage) {
        this.metricsStorage = metricsStorage;
    }
    // 用一個函式代替了最小原型中的兩個函式
    public void recordRequest(RequestInfo requestInfo) {
        if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
            return;
        }
        metricsStorage.saveRequestInfo(requestInfo);
    }
}

RequestInfo 程式碼實現:

public class RequestInfo {
    private String apiName;
    private double responseTime;
    private long timestamp;

    public RequestInfo(String apiName, int responseTime, int timestamp) {
        this.apiName = apiName;
        this.responseTime = responseTime;
        this.timestamp = timestamp;
    }
    //... 省略 constructor/getter/setter 方法...

    public String getApiName() {
        return apiName;
    }

    public void setApiName(String apiName) {
        this.apiName = apiName;
    }

    public double getResponseTime() {
        return responseTime;
    }

    public void setResponseTime(double responseTime) {
        this.responseTime = responseTime;
    }

    public long getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(long timestamp) {
        this.timestamp = timestamp;
    }
}

MetricsStorage 程式碼實現:

import java.util.List;
import java.util.Map;

public interface MetricsStorage {
    void saveRequestInfo(RequestInfo requestInfo);
    List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis);
    Map<String, List<RequestInfo>>getRequestInfos(long startTimeInMillis, long endTimeInMillis);
}

RedisMetricsStorage 程式碼實現:

import java.util.List;
import java.util.Map;

public class RedisMetricsStorage implements MetricsStorage{

    @Override
    public void saveRequestInfo(RequestInfo requestInfo) {

    }

    @Override
    public List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis) {
        return null;
    }

    @Override
    public Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis) {
        return null;
    }
}

Aggregator 程式碼實現:

import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class Aggregator {
    public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
      double maxRespTime = Double.MIN_VALUE;
                double minRespTime = Double.MAX_VALUE;
                double avgRespTime = -1;
                double p999RespTime = -1;
                double p99RespTime = -1;
                double sumRespTime = 0;
                long count = 0;
                for (RequestInfo requestInfo : requestInfos) {
        ++count;
        double respTime = requestInfo.getResponseTime();
        if (maxRespTime < respTime) {
            maxRespTime = respTime;
        }
        if (minRespTime > respTime) {
            minRespTime = respTime;
        }
        sumRespTime += respTime;
    }
      if (count != 0) {
        avgRespTime = sumRespTime / count;
    }
    long tps = (long)(count / durationInMillis * 1000);
      Collections.sort(requestInfos, new Comparator<RequestInfo>() {
        @Override
        public int compare(RequestInfo o1, RequestInfo o2) {
            double diff = o1.getResponseTime() - o2.getResponseTime();
            if (diff < 0.0) {
                return -1;
            } else if (diff > 0.0) {
                return 1;
            } else {
                return 0;
            }
        }
    });
    int idx999 = (int)(count * 0.999);
    int idx99 = (int)(count * 0.99);
      if (count != 0) {
        p999RespTime = requestInfos.get(idx999).getResponseTime();
        p99RespTime = requestInfos.get(idx99).getResponseTime();
    }
    RequestStat requestStat = new RequestStat();
      requestStat.setMaxResponseTime(maxRespTime);
      requestStat.setMinResponseTime(minRespTime);
      requestStat.setAvgResponseTime(avgRespTime);
      requestStat.setP999ResponseTime(p999RespTime);
      requestStat.setP99ResponseTime(p99RespTime);
      requestStat.setCount(count);
      requestStat.setTps(tps);
      return requestStat;
    }

}

RequestStat 程式碼實現:

public class RequestStat {
    private double maxResponseTime;
    private double minResponseTime;
    private double avgResponseTime;
    private double p999ResponseTime;
    private double p99ResponseTime;
    private long count;
    private long tps;
    //... 省略 getter/setter 方法...

    public double getMaxResponseTime() {
        return maxResponseTime;
    }

    public void setMaxResponseTime(double maxResponseTime) {
        this.maxResponseTime = maxResponseTime;
    }

    public double getMinResponseTime() {
        return minResponseTime;
    }

    public void setMinResponseTime(double minResponseTime) {
        this.minResponseTime = minResponseTime;
    }

    public double getAvgResponseTime() {
        return avgResponseTime;
    }

    public void setAvgResponseTime(double avgResponseTime) {
        this.avgResponseTime = avgResponseTime;
    }

    public double getP999ResponseTime() {
        return p999ResponseTime;
    }

    public void setP999ResponseTime(double p999ResponseTime) {
        this.p999ResponseTime = p999ResponseTime;
    }

    public double getP99ResponseTime() {
        return p99ResponseTime;
    }

    public void setP99ResponseTime(double p99ResponseTime) {
        this.p99ResponseTime = p99ResponseTime;
    }

    public long getCount() {
        return count;
    }

    public void setCount(long count) {
        this.count = count;
    }

    public long getTps() {
        return tps;
    }

    public void setTps(long tps) {
        this.tps = tps;
    }


}

ConsoleReporter 程式碼實現:

import com.google.gson.Gson;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ConsoleReporter {
    private MetricsStorage metricsStorage;
    private ScheduledExecutorService executor;

    public ConsoleReporter(MetricsStorage metricsStorage) {
        this.metricsStorage = metricsStorage;
        this.executor = Executors.newSingleThreadScheduledExecutor();
    }

    // 第 4 個程式碼邏輯:定時觸發第 1、2、3 程式碼邏輯的執行;
    public void startRepeatedReport(long periodInSeconds, long durationInSeconds){
      executor.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            // 第 1 個程式碼邏輯:根據給定的時間區間,從資料庫中拉取資料;
            long durationInMillis = durationInSeconds * 1000;
            long endTimeInMillis = System.currentTimeMillis();
            long startTimeInMillis = endTimeInMillis - durationInMillis;
            Map<String, List<RequestInfo>> requestInfos =
                    metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
            Map<String, RequestStat> stats = new HashMap<>();
            for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
                String apiName = entry.getKey();
                List<RequestInfo> requestInfosPerApi = entry.getValue();
                // 第 2 個程式碼邏輯:根據原始資料,計算得到統計資料;
                RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
                stats.put(apiName, requestStat);
            }
            // 第 3 個程式碼邏輯:將統計資料顯示到終端(命令列或郵件);
            System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
            Gson gson = new Gson();
            System.out.println(gson.toJson(stats));

        }
    }, 0, periodInSeconds, TimeUnit.SECONDS);
    }
}

EmailReporter 程式碼實現:

import java.util.*;

public class EmailReporter {
    private static final Long DAY_HOURS_IN_SECONDS = 86400L;
    private MetricsStorage metricsStorage;
    private EmailSender emailSender;
    private List<String> toAddresses = new ArrayList<>();

    public EmailReporter(MetricsStorage metricsStorage) {
        this(metricsStorage, new EmailSender(/* 省略引數 */));
    }
    public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
        this.metricsStorage = metricsStorage;
        this.emailSender = emailSender;
    }
    public void addToAddress(String address) {
        toAddresses.add(address);
    }

    public void startDailyReport() {
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.DATE, 1);
        calendar.set(Calendar.HOUR_OF_DAY, 0);
        calendar.set(Calendar.MINUTE, 0);
        calendar.set(Calendar.SECOND, 0);
        calendar.set(Calendar.MILLISECOND, 0);
        Date firstTime = calendar.getTime();
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
                long endTimeInMillis = System.currentTimeMillis();
                long startTimeInMillis = endTimeInMillis - durationInMillis;
                Map<String, List<RequestInfo>> requestInfos =
                        metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
                Map<String, RequestStat> stats = new HashMap<>();
                for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
                    String apiName = entry.getKey();
                    List<RequestInfo> requestInfosPerApi = entry.getValue();
                    RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
                    stats.put(apiName, requestStat);
                }
                // TODO: 格式化為 html 格式,並且傳送郵件
            }
        }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
    }
}

Demo 類實現:

public class Demo {
    public static void main(String[] args) {
        MetricsStorage storage = new RedisMetricsStorage();
        ConsoleReporter consoleReporter = new ConsoleReporter(storage);
        consoleReporter.startRepeatedReport(60, 60);
        EmailReporter emailReporter = new EmailReporter(storage);
        emailReporter.addToAddress("[email protected]");
        emailReporter.startDailyReport();
        MetricsCollector collector = new MetricsCollector(storage);
        collector.recordRequest(new RequestInfo("register", 123, 10234));
        collector.recordRequest(new RequestInfo("register", 223, 11234));
        collector.recordRequest(new RequestInfo("register", 323, 12334));
        collector.recordRequest(new RequestInfo("login", 23, 12434));
        collector.recordRequest(new RequestInfo("login", 1223, 14234));
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

將類組裝起來並提供執行入口

  • 一個是 MetricsCollector 類,提供了一組 API 來採集原始資料。
  • 另一個是 ConsoleReporter 類和 EmailReporter 類,用來觸發統計顯示。

Review 設計與實現

  • MetricsCollector 負責採集和儲存資料,職責相對來說還算比較單一。它基於介面而非實現程式設計,通過依賴注入的方式來傳遞 MetricsStorage 物件,可以在不需要修改程式碼的情況下,靈活地替換不同的儲存方式,滿足開閉原則。

  • RedisMetricsStorageMetricsStorage 的設計比較簡單。當我們需要實現新的儲存方式的時候,只需要實現 MetricsStorage 介面即可。其他介面函式呼叫的地方都不需要改動,滿足開閉原則。

  • Aggregator 類是一個工具類,裡面只有一個靜態函式,有 50 行左右的程式碼量,負責各種統計資料的計算。一旦越來越多的統計功能新增進來之後,這個函式的程式碼量會持續增加,可讀性、可維護性就變差了。這個類的設計可能存在職責不夠單一、不易擴充套件等問題,需要在之後的版本中,對其結構做優化。

  • ConsoleReporter和EmailReporter中存在問題較多:

    • 從資料庫中取資料、做統計的邏輯都是相同的,可以抽取出來複用,違反了 DRY 原則。
    • 整個類負責的事情比較多,職責不單一。特別是顯示部分的程式碼,可能會比較複雜(比如 Email 的展示方式),最好是將顯示部分的程式碼邏輯拆分成獨立的類。
    • 因為程式碼中涉及執行緒操作,並且呼叫了 Aggregator 的靜態函式,所以程式碼的可測試性不好。