Это третья часть серии блогов о реактивном программировании, в которой я познакомлю вас с WebFlux - реактивным веб-фреймворком Spring.
1. Введение в Spring WebFlux
Исходный веб-фреймворк для Spring - Spring Web MVC - был построен для Servlet API и контейнеров Servlet.
WebFlux был представлен как часть Spring Framework 5.0. В отличие от Spring MVC, он не требует Servlet API. Он полностью асинхронный и неблокирующий, реализует спецификацию Reactive Streams через проект Reactor (см. предыдущий пост в блоге ).
WebFlux требует Reactor в качестве основной зависимости, но он также может взаимодействовать с другими реактивными библиотеками через Reactive Streams.
1.1 Модели программирования
Spring WebFlux поддерживает две разные модели программирования: на основе аннотаций и функциональную.
1.1.1 Аннотированные контроллеры
Если вы работали со Spring MVC, модель на основе аннотаций будет выглядеть довольно знакомой, поскольку в ней используются те же аннотации из веб-модуля Spring, что и в Spring MVC. Основное отличие состоит в том, что теперь методы возвращают реактивные типы Mono и Flux. См. Следующий пример RestController с использованием модели на основе аннотаций:
@RestController
@RequestMapping("/students")
public class StudentController {
@Autowired
private StudentService studentService;
public StudentController() {
}
@GetMapping("/{id}")
public Mono<ResponseEntity<Student>> getStudent(@PathVariable long id) {
return studentService.findStudentById(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@GetMapping
public Flux<Student> listStudents(@RequestParam(name = "name", required = false) String name) {
return studentService.findStudentsByName(name);
}
@PostMapping
public Mono<Student> addNewStudent(@RequestBody Student student) {
return studentService.addNewStudent(student);
}
@PutMapping("/{id}")
public Mono<ResponseEntity<Student>> updateStudent(@PathVariable long id, @RequestBody Student student) {
return studentService.updateStudent(id, student)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Void>> deleteStudent(@PathVariable long id) {
return studentService.findStudentById(id)
.flatMap(s ->
studentService.deleteStudent(s)
.then(Mono.just(new ResponseEntity<Void>(HttpStatus.OK)))
)
.defaultIfEmpty(new ResponseEntity<>(HttpStatus.NOT_FOUND));
}
}
Некоторые пояснения к функциям, использованным в примере:
map
функция используется для преобразования элемента, испускаемого Mono, применяя функцию синхронной к нему.flatMap
функция используется для преобразования элемент, испускаемый Mono асинхронно, возвращая значение, излучаемого другим Mono.defaultIfEmpty
функция обеспечивает значение по умолчанию, если Mono завершается без каких - либо данных.
1.1.2 Функциональные конечные точки
Модель функционального программирования основана на лямбда-выражении и оставляет за приложением полную обработку запроса. Он основан на концепциях HandlerFunctions
и RouterFunctions
.
HandlerFunctions используются для генерации ответа на данный запрос:
@FunctionalInterface
public interface HandlerFunction<T extends ServerResponse> {
Mono<T> handle(ServerRequest request);
}
RouterFunction используется для маршрутизации запросов к HandlerFunctions:
@FunctionalInterface
public interface RouterFunction<T extends ServerResponse> {
Mono<HandlerFunction<T>> route(ServerRequest request);
...
}
Продолжая с тем же примером ученика, мы получим что-то вроде следующего, используя функциональный стиль.
A StudentRouter:
@Configuration
public class StudentRouter {
@Bean
public RouterFunction<ServerResponse> route(StudentHandler studentHandler){
return RouterFunctions
.route(
GET("/students/{id:[0-9]+}")
.and(accept(APPLICATION_JSON)), studentHandler::getStudent)
.andRoute(
GET("/students")
.and(accept(APPLICATION_JSON)), studentHandler::listStudents)
.andRoute(
POST("/students")
.and(accept(APPLICATION_JSON)),studentHandler::addNewStudent)
.andRoute(
PUT("students/{id:[0-9]+}")
.and(accept(APPLICATION_JSON)), studentHandler::updateStudent)
.andRoute(
DELETE("/students/{id:[0-9]+}")
.and(accept(APPLICATION_JSON)), studentHandler::deleteStudent);
}
}
И StudentHandler:
@Component
public class StudentHandler {
private StudentService studentService;
public StudentHandler(StudentService studentService) {
this.studentService = studentService;
}
public Mono<ServerResponse> getStudent(ServerRequest serverRequest) {
Mono<Student> studentMono = studentService.findStudentById(
Long.parseLong(serverRequest.pathVariable("id")));
return studentMono.flatMap(student -> ServerResponse.ok()
.body(fromValue(student)))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> listStudents(ServerRequest serverRequest) {
String name = serverRequest.queryParam("name").orElse(null);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(studentService.findStudentsByName(name), Student.class);
}
public Mono<ServerResponse> addNewStudent(ServerRequest serverRequest) {
Mono<Student> studentMono = serverRequest.bodyToMono(Student.class);
return studentMono.flatMap(student ->
ServerResponse.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(studentService.addNewStudent(student), Student.class));
}
public Mono<ServerResponse> updateStudent(ServerRequest serverRequest) {
final long studentId = Long.parseLong(serverRequest.pathVariable("id"));
Mono<Student> studentMono = serverRequest.bodyToMono(Student.class);
return studentMono.flatMap(student ->
ServerResponse.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(studentService.updateStudent(studentId, student), Student.class));
}
public Mono<ServerResponse> deleteStudent(ServerRequest serverRequest) {
final long studentId = Long.parseLong(serverRequest.pathVariable("id"));
return studentService
.findStudentById(studentId)
.flatMap(s -> ServerResponse.noContent().build(studentService.deleteStudent(s)))
.switchIfEmpty(ServerResponse.notFound().build());
}
}
Некоторые пояснения к функциям, использованным в примере:
switchIfEmpty
функция имеет ту же цель,defaultIfEmpty
, но вместо того, чтобы обеспечить значение по умолчанию, она используется для обеспечения альтернативного Mono.
Сравнивая две модели, мы видим, что:
Для использования функционального варианта требуется еще немного кода для таких вещей, как получение входных параметров и синтаксический анализ до ожидаемого типа.
Не полагаясь на аннотации, но написание явного кода предлагает некоторую большую гибкость и может быть лучшим выбором, если нам, например, нужно реализовать более сложную маршрутизацию.
1.2 Поддержка сервера
WebFlux работает в средах выполнения, отличных от сервлетов, таких как Netty и Undertow (неблокирующий режим), а также в средах выполнения сервлетов 3.1+, таких как Tomcat и Jetty.
По умолчанию стартер Spring Boot WebFlux использует Netty, но его легко переключить, изменив зависимости Maven или Gradle.
Например, чтобы переключиться на Tomcat, просто исключите spring-boot-starter-netty из зависимости spring-boot-starter-webflux и добавьте spring-boot-starter-tomcat:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-netty</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
1.3 Конфигурация
Spring Boot обеспечивает автоматическую настройку Spring WebFlux, которая хорошо работает в общих случаях. Если вам нужен полный контроль над конфигурацией WebFlux, можно использовать аннотацию @EnableWebFlux
(эта аннотация также потребуется в простом приложении Spring для импорта конфигурации Spring WebFlux).
Если вы хотите сохранить конфигурацию Spring Boot WebFlux и просто добавить дополнительную конфигурацию WebFlux, вы можете добавить свой собственный класс @Configuration типа WebFluxConfigurer (но без @EnableWebFlux).
Подробные сведения и примеры см. в документации по конфигурации WebFlux.
2. Защита ваших конечных точек
Чтобы получить поддержку Spring Security WebFlux, сначала добавьте в свой проект зависимость spring-boot-starter-security. Теперь вы можете включить его, добавив @EnableWebFluxSecurity
аннотацию в свой класс Configuration (доступно с Spring Security 5.0).
В следующем упрощенном примере будет добавлена поддержка двух пользователей, один с ролью USER, а другой с ролью ADMIN, принудительно применить базовую аутентификацию HTTP и потребовать роль ADMIN для любого доступа к пути /student/admin:
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User
.withUsername("user")
.password(passwordEncoder().encode("userpwd"))
.roles("USER")
.build();
UserDetails admin = User
.withUsername("admin")
.password(passwordEncoder().encode("adminpwd"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, admin);
}
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http.authorizeExchange()
.pathMatchers("/students/admin")
.hasAuthority("ROLE_ADMIN")
.anyExchange()
.authenticated()
.and().httpBasic()
.and().build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Также можно защитить метод, а не путь, сначала добавив аннотацию @EnableReactiveMethodSecurity
к вашей конфигурации:
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
...
}
А затем добавляем @PreAuthorize
аннотацию к защищаемым методам. Например, мы можем захотеть, чтобы наши методы POST, PUT и DELETE были доступны только для роли ADMIN. Затем к этим методам можно применить аннотацию PreAuthorize, например:
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Mono<ResponseEntity<Void>> deleteStudent(@PathVariable long id) {
...
}
Spring Security предлагает дополнительную поддержку, связанную с приложениями WebFlux, например защиту CSRF, интеграцию OAuth2 и реактивную аутентификацию X.509. Для получения дополнительной информации прочтите следующий раздел в документации Spring Security: Реактивные приложения
3. Веб-клиент
Spring WebFlux также включает реактивный, полностью неблокирующий веб-клиент. У него есть функциональный, свободный API, основанный на Reactor.
Давайте рассмотрим (еще раз) упрощенный пример того, как WebClient можно использовать для запроса нашего StudentController:
public class StudentWebClient {
WebClient client = WebClient.create("http://localhost:8080");
public Mono<Student> get(long id) {
return client
.get()
.uri("/students/" + id)
.headers(headers -> headers.setBasicAuth("user", "userpwd"))
.retrieve()
.bodyToMono(Student.class);
}
public Flux<Student> getAll() {
return client.get()
.uri("/students")
.headers(headers -> headers.setBasicAuth("user", "userpwd"))
.retrieve()
.bodyToFlux(Student.class);
}
public Flux<Student> findByName(String name) {
return client.get()
.uri(uriBuilder -> uriBuilder.path("/students")
.queryParam("name", name)
.build())
.headers(headers -> headers.setBasicAuth("user", "userpwd"))
.retrieve()
.bodyToFlux(Student.class);
}
public Mono<Student> create(Student s) {
return client.post()
.uri("/students")
.headers(headers -> headers.setBasicAuth("admin", "adminpwd"))
.body(Mono.just(s), Student.class)
.retrieve()
.bodyToMono(Student.class);
}
public Mono<Student> update(Student student) {
return client
.put()
.uri("/students/" + student.getId())
.headers(headers -> headers.setBasicAuth("admin", "adminpwd"))
.body(Mono.just(student), Student.class)
.retrieve()
.bodyToMono(Student.class);
}
public Mono<Void> delete(long id) {
return client
.delete()
.uri("/students/" + id)
.headers(headers -> headers.setBasicAuth("admin", "adminpwd"))
.retrieve()
.bodyToMono(Void.class);
}
}
4. Тестирование
Для тестирования вашего реактивного веб-приложения WebFlux предлагает WebTestClient, который поставляется с API, аналогичным WebClient.
Давайте посмотрим, как мы можем протестировать наш StudentController с помощью WebTestClient:
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class StudentControllerTest {
@Autowired
WebTestClient webClient;
@Test
@WithMockUser(roles = "USER")
void test_getStudents() {
webClient.get().uri("/students")
.header(HttpHeaders.ACCEPT, "application/json")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(Student.class);
}
@Test
@WithMockUser(roles = "ADMIN")
void testAddNewStudent() {
Student newStudent = new Student();
newStudent.setName("some name");
newStudent.setAddress("an address");
webClient.post().uri("/students")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body(Mono.just(newStudent), Student.class)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.id").isNotEmpty()
.jsonPath("$.name").isEqualTo(newStudent.getName())
.jsonPath("$.address").isEqualTo(newStudent.getAddress());
}
...
}
5. WEBSOCKETS и RSOCKET
5.1 Веб-сокеты
В Spring 5 WebSockets также получает дополнительные реактивные возможности. Чтобы создать сервер WebSocket, вы можете создать реализацию WebSocketHandler
интерфейса, которая содержит следующий метод:
Mono<Void> handle(WebSocketSession session)
Этот метод вызывается при установке нового соединения WebSocket и позволяет обрабатывать сеанс. Он принимает в WebSocketSession
качестве входных данных и возвращает Mono <Void>, чтобы сигнализировать о завершении обработки сеанса приложением.
WebSocketSession имеет методы, определенные для обработки входящих и исходящих потоков:
Flux<WebSocketMessage> receive()
Mono<Void> send(Publisher<WebSocketMessage> messages)
Spring WebFlux также предоставляет WebSocketClient
реализации для Reactor Netty, Tomcat, Jetty, Undertow и стандартной Java.
Для получения дополнительной информации прочтите следующую главу в документации Spring's Web on Reactive Stack: WebSockets
5.2 RSOCKET
RSocket - это протокол, моделирующий семантику реактивных потоков по сети. Это двоичный протокол для использования в транспортных потоках байтовых потоков, таких как TCP, WebSockets и Aeron. В качестве введения в эту тему я рекомендую следующий пост в блоге, который написал мой коллега Pär: An introduction to RSocket
А для получения дополнительной информации о поддержке Spring Framework протокола RSocket
6. Подводя итог…
Это сообщение в блоге продемонстрировало, как WebFlux можно использовать для создания реактивного веб-приложения. В следующем и последнем посте этой серии будет показано, как мы можем сделать весь наш стек приложений полностью неблокирующим, также реализовав неблокирующую связь с базой данных - с помощью R2DBC (Reactive Relational Database Connectivity)!
Ссылки
Spring Framework documentation - Web on Reactive Stack