Spring響應式Reactive程式設計的10個陷阱 -Jeroen Rosenberg

banq發表於2019-11-29

我從事Akka Streams的Scala專案已經有很多年了,我對需要提防的事情有相當好的感覺。在我當前的專案中,我們正在使用Java,並且正在使用Reactive Streams Specification的Reactor的實現。在學習該庫包時,我偶然發現了許多常見的錯誤和不良做法,這些我將在這裡列出。感謝Enric Sala指出了這些不良做法。

反應流
首先,讓我們看一下Reactive Streams規範,看看Reactor如何對映實現它。規則非常簡單:

public interface Publisher<T> {
  public void subscribe(Subscriber<? super T> s);
}

public interface Subscriber<T> {
  public void onSubscribe(Subscription s);
  public void onNext(T t);
  public void onError(Throwable t);
  public void onComplete();
}

public interface Subscription {
  public void request(long n); // back pressure happens here
  public void cancel();
}


Publisher是一個潛在的資料來源。人們可以訂閱Publisher一個Subscriber,一個訂閱Subscription傳遞到一個Subscriber,訂閱Subscription是來自Publisher的要求。這是反應流的核心原理,這個訂閱要求是控制資料是否透過的關鍵。
對於Reactor,您需要處理兩種基本型別:
  1. Mono,如果Publisher包含0或1個元素
  2. Flux,如果Publisher包含0..N個元素

訂閱Subscription的實現方法是以阻塞方式使用 :Mono或Flux,這是一種堵塞方法的變體,例如可以使用訂閱來註冊一個lambda,這將返回Disposable,可用於取消訂閱的型別。有一個CoreSubscriber實現Subscriber介面的型別,但這更像一個內部API,作為庫包的使用者,您實際上不必直接使用它。
好了,足夠的理論。讓我們深入一些程式碼。在下面,我將列出10個潛在使用訂閱時有問題的程式碼段。有些將是完全錯誤的,而另一些則更像是不良習慣或氣味。你能發現他們嗎?

#1: Whoop Whoop Reactive!
開始試用Mono的簡單應用:

interface Service {    
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    void problem() {
        service.update("foo");
    }
}


在我們的problem方法中,我們正在呼叫一個update返回的方法Mono<Void>。這是一個空白,因為我們並不真正在乎結果,所以這裡可能出什麼問題了?
好吧,該update方法實際上根本不會執行。還記得Subscription要求決定了資料流是否可以透過嗎?這是由Subscription控制的。在此程式碼段中,我們根本沒有訂閱Mono,因此將不會執行。
解決方法非常簡單。我們只需要使用終端操作,例如block或的subscribe變體之一。

interface Service {    
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Void> problem() {
        return service.update("foo");
    }
}


我們可以將Mono傳遞給problem方法的呼叫者。

#2: Reactive + Reactive = Reactive
看看組合reactive方法:

interface Service {    
    Mono<String> create(String s);
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Void> problem() {
        return service
            .create("foo")
            .doOnNext(service::update)
            .then();
    }
}

我們首先呼叫create,然後使用doOnNext來對該update方法進行呼叫。then()呼叫可確保我們返回一個Mono<Void>型別。應該沒事吧?
在這種情況下update方法也不會執行,可能會讓您感到驚訝。使用doOnNext或任何doOn*方法均不訂閱釋出者。

#3:訂閱所有釋出者!
太酷了,我們知道如何解決這個問題!只需訂閱內部發布者,對不對?

interface Service {    
    Mono<String> create(String s);
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Void> problem() {
        return service
            .create("foo")
            .doOnNext(foo -> service.update(foo).block())
            .then();
    }
}

這實際上可能有效,但是內部訂閱不會很好地傳播。這意味著作為problem方法返回的訂閱者,我們沒有任何控制權。
使用doOn*有副作用的地方:例如記錄日誌,上傳指標。
為了正確修復此程式碼並傳播內部訂閱,我們需要使用其中一種:map。flatMap摺疊內部Mono並組成單個流。我們也可以刪除then()呼叫,因為flatMap將已經返回內部發布者的型別:Mono<Void>。

interface Service {    
    Mono<String> create(String s);
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Void> problem() {
        return service
                .create("foo")
            .flatMap(service::update);
    }
}


#4:我不太明白……
您準備好再來一個嗎?

interface Service {    
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    void problem() {
        try { 
            service.update("foo").subscribe();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

這次對由update方法返回的結果進行Mono處理。它可能會丟擲一個錯誤,因此我們應用防禦性程式設計並將該呼叫包裝在try-catch塊中。
但是,由於該subscribe方法不一定會阻塞,因此我們可能根本不會捕獲該異常。簡單的try-catch結構對(可能)非同步程式碼沒有幫助。
要解決此問題,我們可以block()再次使用,subscribe()或者可以使用一種內建的錯誤處理機制。

interface Service {    
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    void problem() {
            service.update("foo").onErrorResume(e -> {
                    e.printStackTrace();
                    return Mono.empty();
            }).subscribe();
    }
}


您可以使用任何一種onError*方法來註冊“錯誤鉤子”以將錯誤返回釋出者。

#5:看著我
讓我們看一下以下片段

interface Service {    
    Mono<String> create(String s);
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Integer> problem() {
        return service.create("foo").map(foo -> {
            service.update(foo).subscribe();
            return foo.length();
        });
    }
}

在這裡實現的是訂閱更新和變換結果為Mono<Integer>。因此,我們使用map操作來獲取字串foo的長度。
儘管update將在某個時刻執行,但我們還是不會傳播內部訂閱,類似於陷阱3。內部訂閱已分離,我們無法對其進行控制。
更好的方法是使用flatMap,然後使用thenReturn轉換結果。

interface Service {    
    Mono<String> create(String s);
    Mono<Void> update(String s);
}
class Foo {
    private final Service service;
    
    Mono<Integer> problem() {
        return service.create("foo").flatMap(foo ->
            service.update(foo).thenReturn(foo.length())
        );
    }
}


您是否開始小心翼翼使用訂閱呢?大多數時候不是。有一些潛在的用例需要小心:
  1. 短暫的一勞永逸的任務(例如遙測,上傳日誌)。請注意併發和執行上下文。
  2. 長期執行的後臺作業。記住被返回的是Disposable,使用它進行生命週期控制。


#6:不要指望它……
下一個可能會很棘手:

interface Service {    
    Flux<Integer> findAll();
}
class Foo {
    private final Service service;
    
    Flux<Integer> problem() {
        AtomicInteger count = new AtomicInteger();
        return service.findAll()
            .doOnNext(count::addAndGet)
            .doOnComplete(() -> System.out.println("Sum: " + count.get()));
    }
}


在這裡,我們只是使用一個doOnNext運算子來累加流過的所有數字,並使用doOnComplete運算子在完成流時列印出結果總和。我們使用一個AtomicInteger來保證執行緒安全的增量。
problem().block()一次甚至多次呼叫時,這似乎可行。但是,如果我們problem()多次訂閱結果,則結果將完全不同。此外,如果由於某種原因而使下游訂閱續訂,則該計數也將關閉。發生這種情況的原因是,我們正在向釋出商之外收集狀態。在所有訂戶之間存在共享的可變狀態,這是一種非常難聞的氣味。
正確的方法是 將狀態的初始化推遲到釋出者,例如透過將狀態也包裝在釋出者中Mono。這樣,每個訂戶都會擁有自己的數量。

#7:關閉,但沒有雪茄
下一個也有類似的問題。你能發現嗎?

abstract class UploadService {    
    protected Mono<Void> doUpload(InputStream in);
    Mono<Void> upload(InputStream in) {
        doUpload(in).doFinally(x -> in.close());
    }
}
class Foo {
    private final UploadService service;
    
    Mono<Void> problem(byte[] data) {
        return service.upload(new ByteArrayInputStream(data))
            .retry(5);
    }
}


在這裡,我們嘗試上載輸入流,在UploadService使用完doFinally運算子後將其關閉。為了確保我們成功完成上傳,我們希望使用retry操作員對任何失敗重試五次。
當重試開始時,我們將注意到輸入流已經關閉,並且所有重試將用來耗盡IOException。與前面的情況類似,我們在此處處理釋出者外部的狀態,即輸入流。我們正在關閉它,透過使用doFinally運算子來更改其狀態。這是我們應避免的副作用。
解決方案再次是將輸入流的建立推遲到釋出者。

#8:不給糖就搗蛋
以下問題可能是十個問題中最微妙的一個,但仍然值得一提:

interface Service {    
    Flux<String> findAll();
    Mono<Void> operation(String s);
}
class Foo {
    private final Service service;
    
    Flux<Void> problem() {
        return service.findAll()
            .flatMap(service::operation);
    }
}


乍看之下,我們在這裡所做的一切都是正確的。我們將使用flatMap組合兩個釋出者。
這段程式碼可能會起作用,但是值得了解幕後發生的事情。雖然flatMap看起來像一個簡單的轉換器,類似於API之類的集合,但在Reactor中,它是一個非同步運算子。內部發布者將被非同步訂閱。這導致不受控制的並行性。根據我們Flux<String> findAll()將發出的元素數量,我們可能會啟動100個併發子流。這可能不是您想要的,我認為Reactor API應該對此更加明確,如果不禁止這樣做的話。
例如,使用Akka Streams甚至不可能。相應的運算子被顯式呼叫mapAsync,它在此處清楚地指示您正在處理併發執行。此外,它嚴格要求您透過傳遞並行度整數引數來明確限制併發性。
幸運的是對於flatMapReactor中有一個過載,您也可以配置並行性。

interface Service {    
    Flux<String> findAll();
    Mono<Void> operation(String s);
}
class Foo {
    private final Service service;
    
    Flux<Void> problem() {
        return service.findAll()
            .flatMap(service::operation, 4); // parallelism=4
    }
}

通常,您甚至根本不需要並行處理。如果只想同步組成兩個流,則可以使用concatMap運算子。

interface Service {    
    Flux<String> findAll();
    Mono<Void> operation(String s);
}
class Foo {
    private final Service service;
    
    Flux<Void> problem() {
        return service.findAll()
            .concatMap(service::operation);
    }
}



#9:我的流洩漏了
差不多好了。在編寫反應式程式碼時,有時必須與非反應式程式碼整合。這是以下片段的內容。

interface Service {    
    Flux<String> findAll();
}
class Foo {
    private final Service service;
    
    Iterable<String> problem() {
        return service.findAll().toIterable();
    }
}


這段程式碼幾乎太簡單了。我們正在處理Flux <String>,但是我們不希望我們的API公開這個Flux反應型別。因此,我們正在使用內建toIterable方法將Flux流轉換為Iterable<String>。
雖然這可能會產生預期的結果,但Iterable以這種方式將Reactor流轉換為卻是一種氣味。Iterable不支援流關閉,因此釋出者將永遠不知道訂閱者何時完成。坦白說,我不明白為什麼toIterable它甚至是流API的一部分。我認為我們應該避免它!
替代方法是java.util.Stream,使用toStream方法轉換為較新的API 。這確實支援整齊地關閉資源。

interface Service {    
    Flux<String> findAll();
    Mono<String> lookup(String s);
}
class Foo {
    private final Service service;
    
    Stream<String> problem() {
        return service.findAll().toStream();
    }
}


#10:我不想結束
如果您走了這麼遠,恭喜!您可能不希望這樣結束,如下面的程式碼片段所示:

interface Service {    
    Flux<String> observe();
    Mono<Void> save(String s);
}
class Foo {
    private final Service service;
    
    void longRunningProblem() {
        service.observe()
            .flatMap(service::save, 10)
            .subscribeOn(Schedulers.elastic())
            .subscribe();
    }
}

在這裡,我們一直希望觀察一個流,並在流過每個元素時儲存它們。這將是一個潛在的無休止的流,因我們不想阻塞主執行緒。因此,我們正在使用subscribeOn運算子來訂閱Scheduler的彈性方法。Scheduler排程程式動態建立ExecutorService,基於工作程式並快取執行緒池以供重用。最後,我們呼叫subscribe()以確保將執行流。
這裡的問題是,透過儲存建立的上游觀察者或內部發布者中的任何失敗都將導致流終止。我們缺少錯誤處理程式或重試機制。

可以
  1. 使用onError*運算子之一註冊錯誤處理程式
  2. 在內部或外部發布者上使用retry任何運算子變體
  3. 使用doOnTerminate掛鉤重新啟動完整的流。

interface Service {    
    Flux<String> observe();
    Mono<Void> save(String s);
}
class Foo {
    private final Service service;
    
    void longRunningProblem() {
        service.observe()
            .flatMap(service::save, 10)
            .doOnTerminate(this::longRunningProblem) // start over on terminate
            .subscribeOn(Schedulers.elastic())
                  .retry() // retry indefinitely
            .subscribe();
    }
}



結論
因此,經驗教訓。如果您可以從中學到一些東西,那就是以下幾點
不要對其他釋出者做任何假設

  1. 上游可能會發生故障,因此您需要處理潛在的錯誤並考慮重試和後備
  2. 控制併發和執行上下文。讓事情變得簡單,喜歡concatMap而不是flatMap,如果你不嚴格需要並行執行。如果確實需要並行性,請使用flatMap(lambda, parallelism)過載明確其限制。此外,在這些情況下,請使用subscribeOn來使用Scheduler。

不要對其他訂戶做任何假設
  1. 避免產生副作用並避免釋出者外部出現易變的狀態
  2. (重新)訂閱應該總是安全的




 

相關文章