Статьи

Делать вещи с помощью Spring WebFlux

Spring Boot 2.0 недавно вышел на GA, поэтому я решил написать свой первый пост о Spring довольно долго. С момента выпуска я все больше и больше упоминал о Spring WebFlux вместе с учебными пособиями по его использованию. Но, прочитав их и попробовав, чтобы все заработало самостоятельно, я обнаружил, что сделать переход от кода, включенного в публикации и учебные пособия, которые я прочитал, к написанию кода, который на самом деле делает что-то чуть более интересное, чем возвращение строки, оказалось довольно сложно. от конца Теперь я надеюсь, что не буду стрелять себе в ногу, сказав, что, как вы, вероятно, можете подвергнуть той же критике код, который я использую в этом посте, но вот моя попытка дать учебник по Spring WebFlux, который на самом деле напоминает то, что вы могли бы использовать в дикой природе.

Прежде чем я продолжу, и после всего этого упоминания о WebFlux, что это на самом деле? Spring WebFlux — полностью неблокирующая реактивная альтернатива Spring MVC. Это позволяет лучше вертикальное масштабирование без увеличения ваших аппаратных ресурсов. Будучи реактивным, теперь он использует Reactive Streams, чтобы разрешить асинхронную обработку данных, возвращаемых при обращениях к серверу. Это означает, что мы увидим намного меньше List s, Collection s или даже отдельных объектов и вместо них их реактивные эквиваленты, такие как Flux и Mono (из Reactor). Я не собираюсь вдаваться в подробности о том, что такое Reactive Streams, так как, честно говоря, мне нужно самому изучить его еще больше, прежде чем пытаться объяснить это кому-либо. Вместо этого давайте вернемся к сосредоточению на WebFlux.

Я использовал Spring Boot для написания кода в этом уроке, как обычно.

Ниже приведены зависимости, которые я использовал в этом посте.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<dependencies>
 
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>
 
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-cassandra-reactive</artifactId>
    <version>2.0.0.RELEASE</version>
  </dependency>
 
</dependencies>

Хотя я не включил его в приведенный выше фрагмент зависимости, используется spring-boot-starter-parent , который в конечном итоге может быть обновлен до версии 2.0.0.RELEASE . Этот урок посвящен WebFlux, в том числе Spring spring-boot-starter-webflux , очевидно, хорошая идея. spring-boot-starter-data-cassandra-reactive также был включен, так как мы будем использовать его в качестве базы данных для примера приложения, поскольку это одна из немногих баз данных, которые имеют реактивную поддержку (на момент написания). Используя эти зависимости вместе, наше приложение может быть полностью реактивным от начала до конца.

WebFlux представляет другой способ обработки запросов вместо использования модели программирования @Controller или @RestController которая используется в Spring MVC. Но это не заменяет его. Вместо этого он был обновлен, чтобы разрешить использование реактивных типов. Это позволяет вам сохранить тот же формат, который вы использовали при написании в Spring, но с некоторыми изменениями в типах возвращаемых значений, поэтому вместо них возвращаются Flux или Mono . Ниже приведен очень надуманный пример.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@RestController
public class PersonController {
 
  private final PersonRepository personRepository;
 
  public PersonController(PersonRepository personRepository) {
    this.personRepository = personRepository;
  }
 
  @GetMapping("/people")
  public Flux<Person> all() {
    return personRepository.findAll();
  }
 
  @GetMapping("/people/{id}")
    Mono<Person> findById(@PathVariable String id) {
        return personRepository.findOne(id);
    }
}

Для меня это выглядит очень знакомо и, на первый взгляд, на самом деле не выглядит так же, как ваш стандартный контроллер Spring MVC, но после прочтения методов мы можем увидеть различные типы возвращаемых данных, которые мы обычно ожидаем. В этом примере PersonRepository должен быть реактивным репозиторием, поскольку мы были в состоянии напрямую возвращать результаты их поисковых запросов, для справки, реактивные репозитории возвращают Flux для коллекций и Mono для отдельных объектов.

Метод аннотации не является тем, на чем я хочу сосредоточиться в этом посте. Это не круто и достаточно модно для нас. Недостаточно использования лямбд, чтобы удовлетворить нашу тягу к написанию Java более функциональным способом. Но у Spring WebFlux есть свои спины. Он предоставляет альтернативный метод для маршрутизации и обработки запросов к нашим серверам, который слегка использует лямбды для написания функций маршрутизатора. Давайте посмотрим на пример.

01
02
03
04
05
06
07
08
09
10
11
12
13
@Configuration
public class PersonRouter {
 
  @Bean
  public RouterFunction<ServerResponse> route(PersonHandler personHandler) {
    return RouterFunctions.route(GET("/people/{id}").and(accept(APPLICATION_JSON)), personHandler::get)
        .andRoute(GET("/people").and(accept(APPLICATION_JSON)), personHandler::all)
        .andRoute(POST("/people").and(accept(APPLICATION_JSON)).and(contentType(APPLICATION_JSON)), personHandler::post)
        .andRoute(PUT("/people/{id}").and(accept(APPLICATION_JSON)).and(contentType(APPLICATION_JSON)), personHandler::put)
        .andRoute(DELETE("/people/{id}"), personHandler::delete)
        .andRoute(GET("/people/country/{country}").and(accept(APPLICATION_JSON)), personHandler::getByCountry);
  }
}

Это все пути к методам в PersonHandler которые мы рассмотрим позже. Мы создали компонент, который будет обрабатывать нашу маршрутизацию. Для настройки функций маршрутизации мы используем хорошо названный класс RouterFunctions предоставляющий нам множество статических методов, но сейчас нас интересует только его метод route . Ниже подпись метода route .

1
2
3
4
public static <T extends ServerResponse> RouterFunction<T> route(
      RequestPredicate predicate, HandlerFunction<T> handlerFunction) {
  // stuff
}

Метод показывает, что он принимает RequestPredicate вместе с HandlerFunction и выводит RouterFunction .

RequestPredicate — это то, что мы используем для определения поведения маршрута, такого как путь к нашей функции-обработчику, тип запроса и тип ввода, который он может принять. Из-за того, что я использовал статический импорт, чтобы все читалось чище, некоторая важная информация была скрыта от вас. Чтобы создать RequestPredicate мы должны использовать RequestPredicates (множественное число), статический вспомогательный класс, предоставляющий нам все необходимые нам методы. Лично я рекомендую статически импортировать RequestPredicates иначе ваш код будет беспорядочным из-за того, что вам может понадобиться использовать статические методы RequestPredicates . В приведенном выше примере GET , POST , PUT , DELETE , accept и contentType являются статическими методами RequestPredicates .

Следующим параметром является HandlerFunction , который является функциональным интерфейсом. Здесь есть три важных элемента информации, у них есть универсальный тип <T extends ServerResponse> , его метод handle возвращает Mono<T> и принимает ServerRequest . Используя их, мы можем определить, что нам нужно передать функцию, которая возвращает Mono<ServerResponse> (или один из его подтипов). Это, очевидно, накладывает жесткие ограничения на то, что возвращается из наших функций-обработчиков, поскольку они должны соответствовать этому требованию, иначе они не будут подходить для использования в этом формате.

Наконец, результатом является RouterFunction . Затем его можно вернуть и использовать для маршрутизации к любой функции, которую мы указали. Но обычно мы хотим направлять множество разных запросов одновременно к различным обработчикам, которые обслуживает WebFlux. Из-за route возвращающей RouterFunction и того факта, что RouterFunction также имеет свой собственный доступный метод маршрутизации andRoute , мы можем andRoute вызовы вместе и продолжать добавлять все дополнительные маршруты, которые нам нужны.

Если мы еще раз PersonRouter приведенный выше пример PersonRouter , то увидим, что методы названы в честь глаголов REST, таких как GET и POST которые определяют путь и тип запросов, которые будет принимать обработчик. Если мы возьмем, например, первый GET , это маршрутизация к /people с id имени пути path (переменная пути обозначается {id} ), а тип возвращаемого содержимого, в частности APPLICATION_JSON (статическое поле из MediaType ), определяется с помощью метод accept . Если используется другой путь, он не будет обработан. Если путь указан правильно, но заголовок Accept не относится к числу допустимых типов, запрос не будет выполнен.

Прежде чем продолжить, я хочу перейти к методам accept и contentType . Оба из этих установленных заголовков запроса accept совпадения с заголовком Accept и contentType для Content-Type. Заголовок Accept определяет, какие медиа-типы являются приемлемыми для ответа, так как мы возвращали JSON-представления объекта Person устанавливая его в APPLICATION_JSON ( application/json в фактическом заголовке). Content-Type имеет ту же идею, но вместо этого описывает, какой Media Type находится внутри тела отправленного запроса. Вот почему только глаголы POST и PUT включают contentType как остальные не содержат ничего в своих телах. DELETE не включает accept и contentType поэтому мы можем заключить, что он не ожидает, что что-либо будет возвращено, и не включит ничего в тело запроса.

Теперь, когда мы знаем, как настроить маршруты, давайте рассмотрим написание методов-обработчиков, которые обрабатывают входящие запросы. Ниже приведен код, который обрабатывает все запросы от маршрутов, которые были определены в предыдущем примере.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Component
public class PersonHandler {
 
  private final PersonManager personManager;
 
  public PersonHandler(PersonManager personManager) {
    this.personManager = personManager;
  }
 
  public Mono<ServerResponse> get(ServerRequest request) {
    final UUID id = UUID.fromString(request.pathVariable("id"));
    final Mono<Person> person = personManager.findById(id);
    return person
        .flatMap(p -> ok().contentType(APPLICATION_JSON).body(fromPublisher(person, Person.class)))
        .switchIfEmpty(notFound().build());
  }
 
  public Mono<ServerResponse> all(ServerRequest request) {
    return ok().contentType(APPLICATION_JSON)
        .body(fromPublisher(personManager.findAll(), Person.class));
  }
 
  public Mono<ServerResponse> put(ServerRequest request) {
    final UUID id = UUID.fromString(request.pathVariable("id"));
    final Mono<Person> person = request.bodyToMono(Person.class);
    return personManager
        .findById(id)
        .flatMap(
            old ->
                ok().contentType(APPLICATION_JSON)
                    .body(
                        fromPublisher(
                            person
                                .map(p -> new Person(p, id))
                                .flatMap(p -> personManager.update(old, p)),
                            Person.class)))
        .switchIfEmpty(notFound().build());
  }
 
  public Mono<ServerResponse> post(ServerRequest request) {
    final Mono<Person> person = request.bodyToMono(Person.class);
    final UUID id = UUID.randomUUID();
    return created(UriComponentsBuilder.fromPath("people/" + id).build().toUri())
        .contentType(APPLICATION_JSON)
        .body(
            fromPublisher(
                person.map(p -> new Person(p, id)).flatMap(personManager::save), Person.class));
  }
 
  public Mono<ServerResponse> delete(ServerRequest request) {
    final UUID id = UUID.fromString(request.pathVariable("id"));
    return personManager
        .findById(id)
        .flatMap(p -> noContent().build(personManager.delete(p)))
        .switchIfEmpty(notFound().build());
  }
 
  public Mono<ServerResponse> getByCountry(ServerRequest serverRequest) {
    final String country = serverRequest.pathVariable("country");
    return ok().contentType(APPLICATION_JSON)
        .body(fromPublisher(personManager.findAllByCountry(country), Person.class));
  }
}

Одна вещь, которая весьма заметна, это отсутствие аннотаций. За @Component аннотации @Component для автоматического создания бина PersonHandler , других аннотаций Spring нет.

Я пытался сохранить большую часть логики хранилища вне этого класса и скрыл любые ссылки на объекты сущности, пройдя через PersonManager который делегирует PersonRepository он содержит. Если вас интересует код внутри PersonManager его можно увидеть здесь, на моем GitHub , дальнейшие пояснения к нему будут исключены для этого поста, поэтому мы можем сосредоточиться на самом WebFlux.

Хорошо, вернемся к коду под рукой. Давайте подробнее рассмотрим методы get и post чтобы выяснить, что происходит.

1
2
3
4
5
6
7
public Mono<ServerResponse> get(ServerRequest request) {
  final UUID id = UUID.fromString(request.pathVariable("id"));
  final Mono<Person> person = personManager.findById(id);
  return person
      .flatMap(p -> ok().contentType(APPLICATION_JSON).body(fromPublisher(person, Person.class)))
      .switchIfEmpty(notFound().build());
}

Этот метод предназначен для извлечения одной записи из базы данных, которая поддерживает этот пример приложения. Поскольку Cassandra является предпочтительной базой данных, я решил использовать UUID для первичного ключа каждой записи, что приводит к тому, что тестирование примера становится более раздражающим, но ничего такого, что некоторые копирование и вставка не могут решить.

Помните, что переменная пути была включена в путь для этого запроса GET . Используя метод pathVariable в ServerRequest переданный в метод, мы можем извлечь его значение, указав имя переменной, в данном случае id . Затем идентификатор преобразуется в UUID , который выдает исключение, если строка не в правильном формате, я решил игнорировать эту проблему, чтобы пример кода не стал более беспорядочным.

Когда у нас есть идентификатор, мы можем запросить базу данных на наличие соответствующей записи. Возвращается Mono<Person> который либо содержит существующую запись, сопоставленную с Person либо оставляется как пустой Mono .

Используя возвращенный Mono мы можем выводить разные ответы в зависимости от его существования. Это означает, что мы можем возвращать полезные коды состояния клиенту, чтобы они соответствовали содержимому тела. Если запись существует, то flatMap возвращает ServerResponse со статусом OK . Наряду с этим статусом мы хотим вывести запись, для этого мы указываем тип содержимого тела, в данном случае APPLICATION_JSON , и добавляем в него запись. fromPublisher принимает наш Mono<Person> (который является Publisher ) вместе с классом Person чтобы он знал, что он отображает в теле. fromPublisher — это статический метод из класса BodyInserters .

Если запись не существует, то поток переместится в блок switchIfEmpty и вернет состояние NOT FOUND . Так как ничего не найдено, тело можно оставить пустым, поэтому мы просто создадим ServerResponse .

Теперь перейдем к post обработчику.

1
2
3
4
5
6
7
8
9
public Mono<ServerResponse> post(ServerRequest request) {
  final Mono<Person> person = request.bodyToMono(Person.class);
  final UUID id = UUID.randomUUID();
  return created(UriComponentsBuilder.fromPath("people/" + id).build().toUri())
      .contentType(APPLICATION_JSON)
      .body(
          fromPublisher(
              person.map(p -> new Person(p, id)).flatMap(personManager::save), Person.class));
}

Даже из первой строки мы видим, что это уже отличается от того, как работал метод get . Так как это запрос POST он должен принять объект, который мы хотим сохранить из тела запроса. Поскольку мы пытаемся вставить одну запись, мы будем использовать метод bodyToMono для извлечения Person из тела. Если вы имеете дело с несколькими записями, вы, вероятно, захотите использовать вместо него bodyToFlux .

Мы вернем статус CREATED используя метод CREATED который принимает URI для определения пути к вставленной записи. Затем он следует настройке, аналогичной методу get , используя метод fromPublisher для добавления новой записи в тело ответа. Код, который формирует Publisher , немного отличается, но вывод по-прежнему Mono<Person> что имеет значение. Просто для дальнейшего объяснения того, как выполняется вставка, Person переданный из запроса, сопоставляется с новым Person с помощью сгенерированного нами UUID а затем передается для save путем вызова flatMap . Создавая нового Person мы вставляем в Cassandra только те значения, которые нам разрешены, в этом случае мы не хотим, чтобы UUID передавался из тела запроса.

Вот и все, что касается обработчиков. Очевидно, есть другие методы, которые мы не прошли. Все они работают по-разному, но все следуют одной и той же концепции возврата ServerResponse который содержит подходящий код состояния и записи в теле, если это необходимо.

Теперь мы написали весь код, который нам нужен для запуска базового бэк-энда Spring WebFlux. Осталось только связать всю конфигурацию, что легко сделать с помощью Spring Boot.

1
2
3
4
5
6
@SpringBootApplication
public class Application {
  public static void main(String args[]) {
    SpringApplication.run(Application.class);
  }
}

Вместо того, чтобы заканчивать пост здесь, мы, вероятно, должны посмотреть, как на самом деле использовать код.

Spring предоставляет класс WebClient для обработки запросов без блокировки. Мы можем использовать это сейчас как способ тестирования приложения, хотя есть также WebTestClient который мы могли бы использовать здесь вместо этого. WebClient — это то, что вы бы использовали вместо блокирующего RestTemplate при создании реактивного приложения.

Ниже приведен код, вызывающий обработчики, определенные в PersonHandler .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Client {
 
  private WebClient client = WebClient.create("http://localhost:8080");
 
  public void doStuff() {
 
    // POST
    final Person record = new Person(UUID.randomUUID(), "John", "Doe", "UK", 50);
    final Mono<ClientResponse> postResponse =
        client
            .post()
            .uri("/people")
            .body(Mono.just(record), Person.class)
            .accept(APPLICATION_JSON)
            .exchange();
    postResponse
        .map(ClientResponse::statusCode)
        .subscribe(status -> System.out.println("POST: " + status.getReasonPhrase()));
 
    // GET
    client
        .get()
        .uri("/people/{id}", "a4f66fe5-7c1b-4bcf-89b4-93d8fcbc52a4")
        .accept(APPLICATION_JSON)
        .exchange()
        .flatMap(response -> response.bodyToMono(Person.class))
        .subscribe(person -> System.out.println("GET: " + person));
 
    // ALL
    client
        .get()
        .uri("/people")
        .accept(APPLICATION_JSON)
        .exchange()
        .flatMapMany(response -> response.bodyToFlux(Person.class))
        .subscribe(person -> System.out.println("ALL: " + person));
 
    // PUT
    final Person updated = new Person(UUID.randomUUID(), "Peter", "Parker", "US", 18);
    client
        .put()
        .uri("/people/{id}", "ec2212fc-669e-42ff-9c51-69782679c9fc")
        .body(Mono.just(updated), Person.class)
        .accept(APPLICATION_JSON)
        .exchange()
        .map(ClientResponse::statusCode)
        .subscribe(response -> System.out.println("PUT: " + response.getReasonPhrase()));
 
    // DELETE
    client
        .delete()
        .uri("/people/{id}", "ec2212fc-669e-42ff-9c51-69782679c9fc")
        .exchange()
        .map(ClientResponse::statusCode)
        .subscribe(status -> System.out.println("DELETE: " + status));
  }
}

Не забудьте создать экземпляр Client где-нибудь, ниже приведен хороший ленивый способ сделать это!

1
2
3
4
5
6
7
8
@SpringBootApplication
public class Application {
  public static void main(String args[]) {
    SpringApplication.run(Application.class);
    Client client = new Client();
    client.doStuff();
  }
}

Сначала мы создаем WebClient .

1
private final WebClient client = WebClient.create("http://localhost:8080");

После создания мы можем начать делать что-то с ним, отсюда метод doStuff .

Давайте разберем POST запрос, который отправляется на сервер.

01
02
03
04
05
06
07
08
09
10
final Mono<ClientResponse> postResponse =
    client
        .post()
        .uri("/people")
        .body(Mono.just(record), Person.class)
        .accept(APPLICATION_JSON)
        .exchange();
postResponse
    .map(ClientResponse::statusCode)
    .subscribe(status -> System.out.println("POST: " + status.getReasonPhrase()));

Я написал это немного по-другому, так что вы можете видеть, что Mono<ClientResponse> возвращается после отправки запроса. Метод exchange запускает HTTP-запрос к серверу. Ответ будет обработан всякий раз, когда ответ прибудет, если он когда-либо поступит.

Используя WebClient мы указываем, что мы хотим отправить POST запрос, конечно же, используя метод post . Затем URI добавляется с помощью метода uri (перегруженный метод, этот принимает String а другой принимает URI ). Я устал говорить, что этот метод делает то, что называется методом, поэтому содержимое тела добавляется вместе с заголовком Accept. Наконец мы отправляем запрос по телефону exchange .

Обратите внимание, что тип носителя APPLICATION_JSON совпадает с типом, определенным в функции маршрутизатора POST . Если бы мы отправили другой тип, скажем, TEXT_PLAIN мы получили бы ошибку 404 поскольку не существует обработчика, который бы соответствовал ожидаемому запросу.

Используя Mono<ClientResponse> возвращаемый вызовом exchange мы можем отобразить его содержимое на желаемый результат. В приведенном выше примере код состояния выводится на консоль. Если мы вспомним метод post в PersonHandler , помните, что он может только возвращать статус «Создан», но если отправленный запрос не совпадает правильно, то «Not Found» будет распечатан.

Давайте посмотрим на один из других запросов.

1
2
3
4
5
6
7
client
    .get()
    .uri("/people/{id}", "a4f66fe5-7c1b-4bcf-89b4-93d8fcbc52a4")
    .accept(APPLICATION_JSON)
    .exchange()
    .flatMap(response -> response.bodyToMono(Person.class))
    .subscribe(person -> System.out.println("GET: " + person));

Это наш типичный GET . Это выглядит очень похоже на запрос POST мы только что прошли. Основное отличие состоит в том, что uri принимает как путь запроса, так и UUID (в данном случае как String ) в качестве параметра, который заменит переменную пути {id} и что тело остается пустым. Как обрабатывается ответ, также отличается. В этом примере он извлекает тело ответа, отображает его в Mono<Person> и распечатывает его. Это можно было сделать с помощью предыдущего примера POST но код состояния ответа был более полезен для его сценария.

Для немного другой перспективы, мы могли бы использовать cURL для отправки запросов и посмотреть, как выглядит ответ.

1
CURL -H "Accept:application/json" -i localhost:8080/people
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json
 
[
  {
      "id": "13c403a2-6770-4174-8b76-7ba7b75ef73d",
      "firstName": "John",
      "lastName": "Doe",
      "country": "UK",
      "age": 50
  },
  {
      "id": "fbd53e55-7313-4759-ad74-6fc1c5df0986",
      "firstName": "Peter",
      "lastName": "Parker",
      "country": "US",
      "age": 50
  }
]

Ответ будет выглядеть примерно так, очевидно, он будет отличаться в зависимости от данных, которые вы сохранили.

Обратите внимание на заголовки ответа.

1
2
transfer-encoding: chunked
Content-Type: application/json

transfer-encoding здесь представляет данные, которые передаются порциями, которые могут использоваться для потоковой передачи данных. Это то, что нам нужно, чтобы клиент мог реагировать на данные, которые ему возвращаются.

Я думаю, что это должно быть хорошим местом для остановки. Мы рассмотрели довольно много материала, который, надеюсь, помог вам лучше понять Spring WebFlux. Есть несколько других тем, о которых я хочу рассказать о WebFlux, но я остановлюсь на них в отдельных постах, так как считаю, что эта достаточно длинная.

В заключение, в этом посте мы очень кратко обсудили, почему вы хотели бы использовать Spring WebFlux поверх типичного Spring MVC-сервера. Затем мы посмотрели, как настроить маршруты и обработчики для обработки входящих запросов. Обработчики реализовали методы, которые могли работать с большинством глаголов REST, и возвращали правильные данные и коды состояния в своих ответах. Наконец, мы рассмотрели два способа отправки запросов к WebClient один с использованием WebClient для обработки вывода непосредственно на стороне клиента, а другой с помощью cURL, чтобы увидеть, как выглядит возвращаемый JSON.

Если вы заинтересованы в просмотре остального кода, который я использовал для создания примера приложения для этого поста, его можно найти на моем GitHub .

Как всегда, если вы нашли этот пост полезным, поделитесь им, и если вы хотите быть в курсе моих последних постов, вы можете подписаться на меня в Twitter по адресу @LankyDanDev .

Опубликовано на Java Code Geeks с разрешения Дэна Ньютона, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Работа с Spring WebFlux

Мнения, высказанные участниками Java Code Geeks, являются их собственными.