Java Stream和Collection比較:何時以及如何從Java API返回Stream而不是集合Collection? - TomaszKiełbowicz

banq發表於2020-02-12

向您展示一些可以非常方便地使用Java Stream流的場景以及如何使用它們的示例。

本文基於標準Java庫java.util.stream。它既與反應流無關,也與諸如Vavr之類的其他流實現無關。另外,我將不介紹諸如並行執行之類的流的高階細節。

首先,讓我們簡要討論與集合相比獨特的流功能。儘管存在一些相似之處,但差異是很大的,您不應將流僅視為庫中的另一種集合。

根據java.util.stream 的文件,最重要的功能是:

  • 沒有儲存空間,可能是無限制的 -集合是現成的資料結構,而流表示產生資料的能力,通常在建立流時甚至不存在。由於不儲存流中的資料,因此我們可以建立幾乎不確定的流,或者可以更實際地對其重新措辭,我們可以讓消費者決定要從流中讀取多少個元素,從生產者的角度來看,它可能是不確定的(例如new Random().ints())。
  • 懶惰載入 —在定義流時暫停許多操作(例如過濾,對映),並且僅在使用者決定使用流中的資料時才執行
  • 本質上是實用的 -由於您已經具有使用流的經驗,因此您可能會注意到處理流中的資料是為每個步驟(例如過濾器或對映)建立新流,而不是修改源資料
  • 消耗性 -您只能讀取一次流,然後與可以多次讀取的集合不同,它變為“消耗性”

現在讓我們看看我們可以用流解決什麼問題。

處理大量資料

假設,我們必須將資料從外部服務複製到我們的資料庫中。要複製的資料量可以任意大。我們無法獲取所有資料,無法將其儲存在一個集合中,然後儲存在資料庫中,因為這可能會耗盡堆記憶體。我們必須分批處理資料,並設計外部服務客戶端和資料庫儲存之間的介面。由於流不儲存日期,因此可以使用它安全地處理所需的資料量。

在示例(及以下所有示例)中,我們將使用java.util.stream.Stream介面的靜態方法來構建流。用Java構建流的最強大,最靈活的方法是實現Spliterator介面,然後使用StreamSupport類將其包裝為流。但是,正如我們所看到的,Stream在許多情況下,介面中的靜態工廠方法就足夠了。

假定一個簡單的API從支援分頁的外部服務(例如,REST服務,資料庫)中獲取資料。該API最多可limit從提取專案offset。迭代地使用API​​,我們可以根據需要獲取儘可能多的資料。

interface ExternalService {
 List<String> fetch(int offset, int limit);
}

現在,我們可以使用API​​提供資料流,並將API的使用者與分頁API隔離開:

class Service<T> {
  private final ExternalService<T> externalService;
  
  public Stream<T> stream(int size, int batchSize) {
    var cursor = new Cursor();
    return Stream
      .generate(() -> next(cursor, size, batchSize))
      .takeWhile(not(List::isEmpty))
      .flatMap(List::stream);
  }

  private List<T> next(Cursor cursor, int size, int batchSize) {
    var fetchSize = Math.min(size - cursor.offset, batchSize);
    var result = externalService.fetch(cursor.offset, fetchSize);
    cursor.inc(result.size());
    return result;
  }
}

Cursor 握有當前偏移量offset:

private static class Cursor {
  private int offset;
   
  void inc(int by) {
    offset += by;
  }
}

我們使用Stream.generate()方法構建無限流,其中每個元素由流提供者建立。流元素是從REST API獲取的頁面List<T>。將為每個流建立Cursor類的例項,以跟蹤獲取的元素的進度。

Stream.takeWhile()方法用於檢測的最後一頁,最後返回的資料流T,而不是List<T>。

我們使用flatMap扁平化流。儘管在某些情況下,保留批處理(例如將整個頁面儲存在一個事務中)可能很有用。

現在,我們可以使用Service.stream(size, batchSize)來檢索任意長流,而無需任何分頁API的知識(我們決定公開batchSize引數,但這是一個設計決策)。在任何時間點,記憶體消耗都受到批處理大小的限制。使用者可以一一處理資料,將其儲存在資料庫中,或者再次進行批處理(批處理大小可能不同)。

快速訪問(不完整)資料

假設我們有一個耗時的操作,必須對資料的每個元素執行該操作,並且計算要花費時間t。對於n元素,使用者必須等待t * n才能接收到計算結果。例如,如果使用者正在等待帶有計算結果的表,則可能是一個問題。我們希望在顯示第一結果時立即顯示它們,而不是等待所有結果的計算並立即提交表。

public class Producer1 {
  private Stream<String> buildStream() {
    return Stream.of("a", "b", "c");
  }
  
  private String expensiveStringDoubler(String input) {
    return input + input;
  }

  public Stream<String> stream() {
    return buildStream().map(this::expensiveComputation);
  }
}

消費者:

stream().forEach(System.out::println)

輸出:

Processing of: a
aa
Processing of: b
…

輸出:

Processing of: a
aa
Processing of: b
…

如我們所見,在開始處理下一個元素之前,使用者可以使用第一個元素“ aa ”的處理結果,但是計算仍然是流的生產者責任。換句話說,消費者決定何時以及是否應該執行計算,但是生產者仍然負責如何執行計算。

您可能會認為這很容易,並且不需要流。當然,您是對的,讓我們看一下:

public class Producer1Classic {
  public List<String> data() {
    return List.of("a", "b", "c", "d", "e", "f");
  }
  
  public String expensiveStringDoubler(String input) {
    return input + input;
  }
}

消費者:

var producer = new Producer1Classic();

for (String element : producer.data()) {
  System.out.println(producer.expensiveComputation(element));
}

同樣的效果,但是實際上我們已經重新發明了輪子,我們的實現模仿了stream的祖先- Iterator並且我們失去了stream的API的優勢。

避免過早計算

再次假設我們要對每個流元素執行耗時的操作。在某些情況下,API的使用者無法提前說出需要多少資料。例如:

  • 使用者取消了資料載入
  • 在資料處理過程中發生錯誤,無需處理其餘資料
  • 消費者讀取資料直到滿足條件,例如第一個正值

由於流的惰性,在這種情況下可以避免一些計算。

private Stream<Double> buildStream() {
  return new Random().doubles().boxed();
}

private Double expensiveComputation(Double input) {
  return input / 2;
}

public Stream<Double> stream() {
  return buildStream().map(this::expensiveComputation);
}

消費者:

stream().peek(System.out::println).filter(value -> value > 0.4).findFirst();

在該示例中,使用者讀取資料,直到該值大於0.4。生產者並不瞭解消費者的這種邏輯,但它只計算必要的專案。邏輯(例如條件)可以在使用者端獨立更改。

API易於使用

使用流而不是自定義API設計還有另一個原因。流是標準庫的一部分,併為許多開發人員所熟知。在我們的API中使用流使其他開發人員更容易使用該API。

其他注意事項

錯誤處理

傳統的錯誤處理不適用於Streams。由於實際處理將推遲到需要時進行,因此構造流時不會引發異常。基本上,我們有兩個選擇:

  • 引發RuntimeException-終止方法(例如forEach)將引發異常
  • 將元素包裝到一個物件中,該物件表示正在處理的元素的當前狀態,例如TryVavr庫中的特殊類(部落格中的詳細資訊)

資源管理

有時我們必須使用一種資源來提供流資料(例如,外部服務中的會話),並且我們想在流處理完成時將其釋放。幸運的是,流實現了Autoclosable介面,我們可以在try-with-resources語句中使用流,從​​而使資源管理變得非常容易。我們要做的就是使用onClose方法在流中註冊一個鉤子。當流關閉時,該掛鉤將自動被呼叫。

private Stream<Double> buildStream() {
  return new Random().doubles().boxed();
}

private Double expensiveComputation(Double input) {
  if (input > 0.8) throw new RuntimeException("Data processing exception");
  return input / 2;
}

public Stream<Double> stream() {
  return buildStream().map(this::expensiveComputation).onClose(()-> System.out.println("Releasing resources…"));
}

消費者:

try (Stream<Double> stream = stream()){
  stream.forEach(System.out::println);
}

輸出:

0.2264004802916616
0.32777949557515484
Releasing resources…
Exception in thread “main” java.lang.RuntimeException: Data processing exception

在該示例中,當發生資料處理異常時,流將通過try-with-resources語句自動關閉,並呼叫已註冊的處理程式。在示例輸出中,我們可以看到Releasing resources…處理程式列印的訊息。

總結

  1. 流不是集合。
  2. 流可以幫助我們解決以下問題:*處理大量資料*快速訪問(不完整的)資料*避免過早計算
  3. 構建流並不難。
  4. 我們必須注意錯誤處理。
  5. 支援資源管理。

相關文章