How to implement nested async code with reactive programming?

ProtossShuttle :

I'm very new to reactive programming. Although I'm very familiar with functional programming and kotlin coroutines, I still fail to figure out how to use reactive programing paradigms to refactor plain nested CRUD code, especially those with nested async operations.

For example, below is a simple async CRUD code snippet based on Java 8 CompletableFuture


        getFooAsync(id)
                .thenAccept(foo -> {
                    if (foo == null) {
                        insertFooAsync(id, new Foo());
                    } else {
                        getBarAsync(foo.bar)
                                .thenAccept(bar -> {
                                   updateBarAsync(foo, bar);
                                });
                    }
                });

It's very easy to refactor it with kotlin coroutines, which makes it much more readable without losing asynchronicity.

 val foo = suspendGetFoo(id)
 if(foo==null) {
   suspendInsertFoo(id, Foo())
 } else {
   val bar = suspendGetBar(foo.bar)
   suspendUpdateBar(foo, bar);-
}

However, is code like that suitable for reactive programming?

If so, given a Flux<String> idFlux, how to refactor it with Reactor 3?

Is it a good idea to just replace every CompletableFuture with Mono?

Phil Clay :

is code like that suitable for reactive programming?

IMHO, Kotlin coroutines are much better suited for this use case, and result in much cleaner code.

However, you can do this in reactive streams.

Is it a good idea to just replace every CompletableFuture with Mono?

I've found that reactive streams handle a lot of async use cases extremely well (e.g. the examples from project reactor). However, there are definitely some use cases that don't quite fit. So, I cannot recommend a policy of replacing every CompletableFuture with reactive streams.

However, the one case for which you must switch away from CompletableFuture is when you need backpressure.

A lot of the decision on what async pattern to use depends on the languages/frameworks/tools/libraries you are using and how comfortable you and your teammates are with them. If you are using libraries with good Kotlin support, and your team is familiar with Kotlin, then use coroutines. Likewise for reactive streams.

given a Flux<String> idFlux, how to refactor it with Reactor 3?

Here are some things to keep in mind when considering reactive streams for this use case:

  1. Reactive streams cannot emit null. Instead, an empty Mono is generally used. (You can technically also use Mono<Optional<...>>, but at that point you're just hurting your brain and begging for bugs)
  2. When a Mono is empty, lambdas passed to any operator that deals with the onNext signal (e.g. .map, .flatMap, .handle, etc) are not invoked. Remember that you're dealing with a stream of data (rather than an imperative control flow)
  3. The .switchIfEmpty or .defaultIfEmpty operators can operate on empty Monos. However, they do not provide an else condition. Downstream operators are unaware that the stream was previously empty (unless the element emitted from the Publisher passed to .switchIfEmpty is easily identifiable somehow)
  4. If you have a stream of many operators, and multiple operators could cause the stream to become empty, then it's difficult/impossible for downstream operators to determine why the stream became empty.
  5. The main asynchronous operators that allow processing emitted values from upstream operators are .flatMap, .flatMapSequential and .concatMap. You will need to use these to chain asynchronous operations that operate on the output of previous asynchronous operations.
  6. Since your use case does not return a value, the reactive stream implementation will return a Mono<Void>

Having said all of that, here's an attempt at transforming your example to reactor 3 (with some caveats):

    Mono<Void> updateFoos(Flux<String> idFlux) {
        return idFlux                                         // Flux<String>
            .flatMap(id -> getFoo(id)                         // Mono<Foo>
                /*
                 * If a Foo with the given id is not found,
                 * create a new one, and continue the stream with it.
                 */
                .switchIfEmpty(insertFoo(id, new Foo()))      // Mono<Foo>
                /*
                 * Note that this is not an "else" condition
                 * to the above .switchIfEmpty
                 *
                 * The lambda passed to .flatMap will be
                 * executed with either:
                 * A) The foo found from getFoo
                 *    OR
                 * B) the newly inserted Foo from insertFoo
                 */
                .flatMap(foo -> getBar(foo.bar)               // Mono<Bar>
                    .flatMap(bar -> updateBar(foo, bar))      // Mono<Bar>
                    .then()                                   // Mono<Void>
                )                                             // Mono<Void>
            )                                                 // Flux<Void>
            .then();                                          // Mono<Void>
    }

    /*
     * @return the Foo with the given id, or empty if not found
     */
    abstract Mono<Foo> getFoo(String id);

    /*
     * @return the Bar with the given id, or empty if not found
     */
    abstract Mono<Bar> getBar(String id);

    /*
     * @return the Foo inserted, never empty
     */
    abstract Mono<Foo> insertFoo(String id, Foo foo);

    /*
     * @return the Bar updated, never empty
     */
    abstract Mono<Bar> updateBar(Foo foo, Bar bar);

and here's a more complex example that uses a Tuple2<Foo,Boolean> to indicate if the original Foo was found (this should be semantically equivalent to your example):

    Mono<Void> updateFoos(Flux<String> idFlux) {
        return idFlux                                         // Flux<String>
            .flatMap(id -> getFoo(id)                         // Mono<Foo>
                /*
                 * Map to a Tuple2 whose t2 indicates whether the foo was found.
                 * In this case, it was found.
                 */
                .map(foo -> Tuples.of(foo, true))             // Mono<Tuple2<Foo,Boolean>>
                /*
                 * If a Foo with the given id is not found,
                 * create a new one, and continue the stream with 
                 * a Tuple2 indicating it wasn't originally found
                 */
                .switchIfEmpty(insertFoo(id, new Foo())       // Mono<Foo>
                    /*
                     * Foo was not originally found, so t2=false
                     */
                    .map(foo -> Tuples.of(foo, false)))       // Mono<Tuple2<Foo,Boolean>>
                /*
                 * The lambda passed to .flatMap will be
                 * executed with either:
                 * A) t1=foo found from getFoo, t2=true
                 *    OR
                 * B) t1=newly inserted Foo from insertFoo, t2=false
                 */
                .flatMap(tuple2 -> tuple2.getT2()
                    // foo originally found 
                    ? getBar(tuple2.getT1().bar)              // Mono<Bar>
                        .flatMap(bar -> updateBar(tuple2.getT1(), bar)) // Mono<Bar>
                        .then()                               // Mono<Void>
                    // foo originally not found (new inserted)
                    : Mono.empty()                            // Mono<Void>
                )
            )                                                 // Flux<Void>
            .then();                                          // Mono<Void>
    }

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=307208&siteId=1