Reactive programming (3): a simple HTTP service

The book continues Reactive programming above , we have understood the basic API, now we start to write the actual application. Reactive abstracts concurrent programming very well, and there are many underlying features that require our attention. When using these features, we gain control over details that were previously hidden in containers, platforms, and frameworks.

Spring MVC shifts from blocking to Reactive

Reactive requires us to look at problems differently. Different from the traditional request->response mode, all data is published as a sequence (Publisher) and then subscribed (Subscriber). Different from waiting for the result to be returned synchronously, register a callback instead. As long as we are used to this way, it will not feel very complicated. But there is no way to make the entire environment into Reactive mode at the same time, so it is inevitable to deal with old-fashioned blocking APIs.

Suppose we have a blocking method that returns HttpStatus:

private RestTemplate restTemplate = new RestTemplate();

private HttpStatus block(int value) {
    return this.restTemplate.getForEntity("http://example.com/{value}", String.class, value)
            .getStatusCode();
}

We need to pass different parameters to call this method repeatedly, and process the returned results. This is a typical "scatter-gather" application scenario. For example, the first N pieces of data are extracted from multiple pages.

Here's an example done the wrong way:

Flux.range(1, 10) (1)
    .log()
    .map(this::block) (2)
    .collect(Result::new, Result::add) (3)
    .doOnSuccess(Result::stop) (4)
  1. Call the interface 10 times
  2. blockage
  3. Summarize the results and put them into an object
  4. Finally end processing (result is one Mono<Result>)

Don't write code this way. This is a wrong implementation, which will block the calling thread, which is no different from calling block() in a loop. A good implementation should be to put block()the call into the worker thread. We can use a Mono<HttpStatus>method that returns:

private Mono<HttpStatus> fetch(int value) {
    return Mono.fromCallable(() -> block(value)) (1)
        .subscribeOn(this.scheduler);            (2)
}
  1. Put the blocking call Callableinto
  2. Subscribe in worker thread

schedulerSeparately defined as a shared variable:


  Scheduler scheduler = Schedulers.parallel()

then flatMap()replacemap()


Flux.range(1, 10)
    .log()
    .flatMap(                             (1)
        this::fetch, 4)                   (2)
    .collect(Result::new, Result::add)
    .doOnSuccess(Result::stop)
  1. Parallel processing in the new publisher
  2. Parallel parameters for flatMap

Embed Non-Reactive Services

If you want to put the above code into a Non-Reactive service like a servlet, you can use Spring MVC:


@RequestMapping("/parallel")
public CompletableFuture<Result> parallel() {
    return Flux.range(1, 10)
      ...
      .doOnSuccess(Result::stop)
      .toFuture();
}

@RequestMappingAfter reading the javadoc, we will find that this method will return a value CompletableFuture, and the application will choose to return the value in a separate thread. In our case this single thread is schedulerprovided .

no free lunch

Using worker threads for scatter-gather calculations is a good pattern, but not perfect - doesn't block the caller, but still blocks something, just deflects the problem. We have a non-blocking IO HTTP service that puts the processing into a thread pool, one thread per request - this is the mechanism of the servlet container (eg tomcat). Requests are processed asynchronously, so the worker thread inside tomcat is not blocked, and our schedulerwill create 4 threads. When processing 10 requests, the theoretical processing performance will increase by 4 times. Simply put, if we sequentially process 10 requests in a single thread and it takes 1000ms, the method we use only takes 250ms.

We can further improve performance by adding threads (allocating 16 threads):


private Scheduler scheduler = Schedulers.newParallel("sub", 16);

Tomcat will allocate 100 threads to process requests by default. When all requests are processed at the same time, our scheduler thread pool will become a bottleneck. The number of our scheduler thread pools is much smaller than that of Tomcat. This shows that performance tuning is not a simple matter, and the matching of various parameters and resources needs to be considered.

Compared with a fixed number of thread pools, we can use a more flexible thread pool, which can dynamically adjust the number of threads according to needs. Reactor already provides this mechanism. Schedulers.elastic()After you can see that when the number of requests increases, the number of threads will increase accordingly.

Fully adopt Reactive

Bridging from blocking calls to reactive is an effective pattern and is easy to implement with Spring MVC techniques. Next we will completely deprecate blocking mode and adopt new APIs and new tools. In the end we implemented a full-stack Reactive.

In our example, the first step is to spring-boot-starter-web-reactivereplace with spring-boot-starter-web:

Maven:


<dependencies>
  <dependency>
   <groupId>org.springframework.boot.experimental</groupId>
     <artifactId>spring-boot-starter-web-reactive</artifactId>
  </dependency>
  ...
</dependencies>
    <dependencyManagement>
     <dependencies>
       <dependency>
         <groupId>org.springframework.boot.experimental</groupId>
         <artifactId>spring-boot-dependencies-web-reactive</artifactId>
         <version>0.1.0.M1</version>
         <type>pom</type>
         <scope>import</scope>
      </dependency>
     </dependencies>
    </dependencyManagement>

Gradle:


dependencies {
    compile('org.springframework.boot.experimental:spring-boot-starter-web-reactive')
    ...
}
dependencyManagement {
    imports {
        mavenBom "org.springframework.boot.experimental:spring-boot-dependencies-web-reactive:0.1.0.M1"
    }
}

In the controller, no longer use CompletableFutureand instead return a Mono:


@RequestMapping("/parallel")
public Mono<Result> parallel() {
    return Flux.range(1, 10)
            .log()
            .flatMap(this::fetch, 4)
            .collect(Result::new, Result::add)
            .doOnSuccess(Result::stop);
}

Putting this code into a SpringBoot application can run on Tomcat, Jetty or Netty, depending on which package the classpath introduces. Tomcat is the default container. If you want to use other containers, you need to remove Tomcat from the classpath, and then introduce other containers. The 3 containers have little difference in startup time, memory usage and runtime resources.

We still call block()the blocking service interface, so we still need to subscribe in the worker thread to not block the caller. We could also use a non-blocking client, for example by WebClientreplacing RestTemplate:


private WebClient client = new WebClient(new ReactorHttpClientRequestFactory());

private Mono<HttpStatus> fetch(int value) {
    return this.client.perform(HttpRequestBuilders.get("http://example.com"))
            .extract(WebResponseExtractors.response(String.class))
            .map(response -> response.getStatusCode());
}

Note WebClient.perform()that the return value of is a Mono<HttpStatus>Reactive type converted to , but we are not subscribing to it. The work of subscribing is done by the framework.

inversion of control

Now we remove the concurrency parameter after fetch()the call :


@RequestMapping("/netty")
public Mono<Result> netty() {
    return Flux.range(1, 10) (1)
        .log() //
        .flatMap(this::fetch) (2)
        .collect(Result::new, Result::add)
        .doOnSuccess(Result::stop);
}
  1. make 10 calls
  2. Parallel processing in the new publisher

Since no additional subscription threads are used, the code in the blocking and Reactive bridge mode is much more concise, and now it is a fully reactive mode. WebClientReturns one Mono, of course we need to use it in the conversion chain flatMap(). Writing this kind of code is a great experience, easy to understand and easy to maintain. At the same time, there is no need for thread pool and concurrency parameters, and there is no magic number 4 that affects performance. Performance depends on system resources rather than application thread control.

The application can run on Tomcat, Jetty or Netty. Tomcat and Jetty support asynchronous processing based on Servlet 3.1, limited to one thread per request. Running on Netty does not have this limitation. As long as the client is not blocked, client requests will be distributed as soon as possible. Since the Netty service is not threaded per request, it will not use a large number of threads.

Note that many applications block not only HTTP calls, but also database operations. Very few databases currently support non-blocking clients (except MongoDB and Couchbase). Thread pools and blocking-to-reactive patterns are here to stay for a long time.

still no free lunch

First of all, our code is declarative, which is not convenient for debugging, and it is not easy to locate errors when they occur. Using a native API, such as using Reactor directly without going through the Spring framework, will make the situation worse, because we have to do a lot of error handling ourselves, and we have to write a lot of boilerplate code every time we make a network call. By combining Spring and Reactor we can easily view stack information and uncaught exceptions. Since the running thread is not under our control, it will be difficult to understand.

Second, once a programming error causes a Reactive callback to be blocked, all requests on the same thread will hang. In the servlet container, since one request is one thread, when one request is blocked, other requests will not be affected. Whereas in Reactive, a blocked request increases the latency of all requests.

Summarize

It is very nice to be able to control all aspects of asynchronous processing: there are thread pools and queues at each level. We can make some tiers elastic, dynamically adjusting to load. But this is also a burden, and we expect a more concise way. Scalability analysis results tend to reduce redundant threads, do not exceed the constraints of hardware resources.

Reactive is not a solution to all problems. In fact, it is not a solution itself. It just promotes the generation of solutions to certain types of problems. The cost of learning, program adjustment, and subsequent maintenance costs may far outweigh the benefits. So be very cautious about whether to use Reactive.

Guess you like

Origin blog.csdn.net/haiyan_qi/article/details/79691246