讀取檔案內容,然後進行處理,在Java中我們通常利用 Files 類中的方法,將可以檔案內容載入到記憶體,並流順利地進行處理。但是,在一些場景下,我們需要處理的檔案可能比我們機器所擁有的記憶體要大。此時,我們則需要採用另一種策略:部分讀取它,並具有其他結構來僅編譯所需的資料。
接下來,我們就來說說這一場景:當遇到大檔案,無法一次載入記憶體時候要如何處理。
模擬場景
假設,當前我們需要開發一個程式來分析來自伺服器的日誌檔案,並生成一份報告,列出前 10 個最常用的應用程式。
每天,都會生成一個新的日誌檔案,其中包含時間戳、主機資訊、持續時間、服務呼叫等資訊,以及可能與我們的特定方案無關的其他資料。
2024-02-25T00:00:00.000+GMT host7 492 products 0.0.3 PUT 73.182.150.152 eff0fac5-b997-40a3-87d8-02ff2f397b44
2024-02-25T00:00:00.016+GMT host6 123 logout 2.0.3 GET 34.235.76.94 8b97acae-dd36-4e83-b423-12905a4ab38d
2024-02-25T00:00:00.033+GMT host6 50 payments/:id 0.4.6 PUT 148.241.146.59 ac3c9064-4782-46d9-a0b6-69e4d55a5b38
2024-02-25T00:00:00.050+GMT host2 547 orders 1.5.0 PUT 6.232.116.248 2285a81e-c511-41b9-b0ea-a475a0a45805
2024-02-25T00:00:00.067+GMT host4 400 suggestions 0.8.6 DELETE 149.138.227.154 8031b639-700e-4a7c-b257-fcbed0d029ce
2024-02-25T00:00:00.084+GMT host2 644 login 6.90 GET 208.158.145.204 3906a28c-56e4-4e5f-b548-591eab737aa7
2024-02-25T00:00:00.101+GMT host5 339 suggestions 0.8.9 PUT 173.109.21.97 c7dfec8a-5ca8-4d0d-b903-aaf65629fdd0
2024-02-25T00:00:00.118+GMT host9 87 products 2.6.3 POST 220.252.90.140 e5ceef67-2f0f-4c2d-a6d2-c698598aaef2
2024-02-25T00:00:00.134+GMT host0 845 products 9.4.6 GET 136.79.178.188 f28578c1-c37c-47a3-a473-4e65371e0245
2024-02-25T00:00:00.151+GMT host4 675 login 0.89 DELETE 32.159.65.239 d27ff353-e501-43e6-bdce-680d79a07c36
我們的程式碼將收到日誌檔案列表,我們的目標是編制一份報告,列出最常用的 10 個服務。但是,要包含在報告中,服務必須在提供的每個日誌檔案中至少有一個條目。簡而言之,一項服務必須每天使用才有資格包含在報告中。
基礎實現
解決這個問題的最初方法是考慮業務需求並建立以下程式碼:
public void processFiles(final List<File> fileList) {
final Map<LocalDate, List<LogLine>> fileContent = getFileContent(fileList);
final List<String> serviceList = getServiceList(fileContent);
final List<Statistics> statisticsList = getStatistics(fileContent, serviceList);
final List<Statistics> topCalls = getTop10(statisticsList);
print(topCalls);
}
該方法接收檔案列表作為引數,核心流程如下:
- 建立一個包含每個檔案條目的對映,其中Key是 LocalDate,Value是檔案行列表。
- 使用所有檔案中的唯一服務名稱建立字串列表。
- 生成所有服務的統計資訊列表,將檔案中的資料組織到結構化地圖中。
- 篩選統計資訊,獲取排名前 10 的服務呼叫。
- 列印結果。
可以注意到,這種方法將太多資料載入到記憶體中,不可避免地會導致 OutOfMemoryError
改進實現
就如文章開頭說的,我們需要採用另一種策略:逐行處理檔案的模式。
private void processFiles(final List<File> fileList) {
final Map<String, Counter> compiledMap = new HashMap<>();
for (int i = 0; i < fileList.size(); i++) {
processFile(fileList, compiledMap, i);
}
final List<Counter> topCalls =
compiledMap.values().stream()
.filter(Counter::allDaysSet)
.sorted(Comparator.comparing(Counter::getNumberOfCalls).reversed())
.limit(10)
.toList();
print(topCalls);
}
- 首先,它宣告一個Map(compiledMap),其中一個String作為鍵,代表服務名稱,以及一個Counter物件(稍後解釋),它將儲存統計資訊。
- 接下來,它逐一處理這些檔案並相應地更新compileMap。
- 然後,它利用流功能來: 僅過濾具有全天資料的計數器;按呼叫次數排序;最後,檢索前 10 名。
在看整個處理的核心processFile
方法之前,我們先來分析一下Counter
類,它在這個過程中也起到了至關重要的作用:
public class Counter {
@Getter private String serviceName;
@Getter private long numberOfCalls;
private final BitSet daysWithCalls;
public Counter(final String serviceName, final int numberOfDays) {
this.serviceName = serviceName;
this.numberOfCalls = 0L;
daysWithCalls = new BitSet(numberOfDays);
}
public void add() {
numberOfCalls++;
}
public void setDay(final int dayNumber) {
daysWithCalls.set(dayNumber);
}
public boolean allDaysSet() {
return daysWithCalls.stream()
.mapToObj(index -> daysWithCalls.get(index))
.reduce(Boolean.TRUE, Boolean::logicalAnd);
}
}
- 它包含三個屬性:serviceName、numberOfCalls 和 daysWithCalls
- numberOfCalls 屬性透過 add 方法遞增,該方法為 serviceName 的每個處理行呼叫。
- daysWithCalls 屬性是一個 Java BitSet,一種用於儲存布林屬性的記憶體高效結構。它使用要處理的天數進行初始化,每個位代表一天,初始化為 false。
- setDay 方法將 BitSet 中與給定日期位置相對應的位設定為 true。
allDaysSet 方法負責檢查 BitSet 中的所有日期是否都設定為 true。它透過將 BitSet 轉換為布林流,然後使用邏輯 AND 運算子減少它來實現此目的。
private void processFile(final List<File> fileList,
final Map<String, Counter> compiledMap,
final int dayNumber) {
try (Stream<String> lineStream = Files.lines(fileList.get(dayNumber).toPath())) {
lineStream
.map(this::toLogLine)
.forEach(
logLine -> {
Counter counter = compiledMap.get(logLine.serviceName());
if (counter == null) {
counter = new Counter(logLine.serviceName(), fileList.size());
compiledMap.put(logLine.serviceName(), counter);
}
counter.add();
counter.setDay(dayNumber);
});
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
- 該過程使用Files類的lines方法逐行讀取檔案,並將其轉換為流。這裡的關鍵特徵是lines方法是惰性的,這意味著它不會立即讀取整個檔案;相反,它會在流被消耗時讀取檔案。
- toLogLine 方法將每個字串檔案行轉換為具有用於訪問日誌行資訊的屬性的物件。
- 處理檔案行的主要過程比預期的要簡單。它從與serviceName關聯的compileMap中檢索(或建立)Counter,然後呼叫Counter的add和setDay方法。
正如我們所看到的,在 Java 中處理大檔案而不將整個檔案載入到記憶體中並不是什麼複雜的事情。 Files類提供了逐行處理檔案的方法,我們還可以在檔案處理過程中利用雜湊來儲存資料,這有助於節省記憶體。
歡迎關注我的公眾號:程式猿DD。第一時間瞭解前沿行業訊息、分享深度技術乾貨、獲取優質學習資源