带有Kotlin协程的无阻碍Spring靴

Non-Blocking Spring Boot with Kotlin Coroutines

1.概述

Kotlin协程通常可以为反应性,大量回调的代码增加可读性。

在本教程中,我们将找到如何利用这些协程构建非阻塞的Spring Boot应用程序。 我们还将比较反应式和协程方法。

2.协程动机

如今,系统通常可以处理数千甚至数百万个请求。 因此,开发界正在朝着非阻塞计算和请求处理的方向发展。 与传统的线程每请求方法相比,通过从核心线程分担I / O操作来有效利用系统资源,这使得处理更多的请求成为可能。

异步处理不是一件容易的事,并且容易出错。 幸运的是,我们拥有解决这种复杂性的工具,例如Java CompletableFutures或反应式库,例如RxJava。 实际上,Spring框架已经支持Reactor和WebFlux框架的反应性方法。

异步代码可能很难看懂,但是Kotlin语言提供了协程的概念,以允许以顺序样式编写并发和异步代码。

协程非常灵活,因此我们可以通过Jobs和Scope更好地控制任务的执行。 除此之外,Kotlin协程与现有的Java非阻塞框架可以完美地协同工作。

Spring将从5.2版开始支持Kotlin Coroutines。

3.项目设置

让我们从添加所需的依赖关系开始。

由于本教程中使用的大多数依赖项尚未发布稳定版本,因此我们需要包括快照和里程碑存储库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<pluginRepositories>
    <pluginRepository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/snapshot</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </pluginRepository>
    <pluginRepository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
    </pluginRepository>
</pluginRepositories>

让我们使用Netty框架,这是一个异步客户端-服务器事件驱动的框架。 我们将NettyWebServer用作反应式Web服务器的嵌入式实现。

此外,从3.0版开始,Servlet规范引入了对应用程序的支持,以非阻塞方式处理请求。 因此,我们也可以使用像Jetty或Tomcat这样的servlet容器。

让我们使用以下版本,包括通过Spring Boot的Spring 5.2:

1
2
3
4
5
6
7
8
<properties>
    <kotlin.version>1.3.31</kotlin.version>
    <r2dbc.version>1.0.0.M1</r2dbc.version>
    <r2dbc-spi.version>1.0.0.M7</r2dbc-spi.version>
    <h2-r2dbc.version>1.0.0.BUILD-SNAPSHOT</h2-r2dbc.version>
    <kotlinx-coroutines.version>1.2.1</kotlinx-coroutines.version>
    <spring-boot.version>2.2.0.M2</spring-boot.version>
</properties>

接下来,由于我们依赖WebFlux进行异步处理,因此使用spring-boot-starter-webfluxin代替spring-boot-starter-web非常重要。 因此,我们需要在pom.xml中包含此依赖项:

1
2
3
4
5
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>${spring-boot.version}</version>
</dependency>

接下来,我们将添加R2DBC依赖项以支持反应式数据库访问:

1
2
3
4
5
6
7
8
9
10
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-h2</artifactId>
    <version>${h2-r2dbc.version}</version>
</dependency>
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-spi</artifactId>
    <version>${r2dbc-spi.version}</version>
</dependency>

最后,我们将添加Kotlin核心和协程依赖项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>${kotlinx-coroutines.version}</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-reactor</artifactId>
    <version>${kotlinx-coroutines.version}</version>
</dependency>

4.具有协程的Spring Data R2DBC

在本节中,我们将重点介绍以反应式和协程样式访问数据库。

4.1。 反应式R2DBC

让我们从反应性关系数据库客户端开始。 简而言之,R2DBC是一个API规范,它声明了由数据库供应商实现的反应式API。

我们的数据存储将由内存中的H2数据库提供支持。 此外,反应式关系驱动程序可用于PostgreSQL和Microsoft SQL。

首先,让我们使用反应式方法实现一个简单的存储库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Repository
class ProductRepository(private val client: DatabaseClient) {

    fun getProductById(id: Int): Mono<Product> {
        return client.execute()
         .sql("SELECT * FROM products WHERE id = $1")
         .bind(0, id)
        .`as`(Product::class.java)
         .fetch()
         .one()
    }

    fun addNewProduct(name: String, price: Float): Mono<Void> {
        return client.execute()
         .sql("INSERT INTO products (name, price) VALUES($1, $2)")
         .bind(0, name)
        .bind(1, price)
         .then()
    }

    fun getAllProducts(): Flux<Product> {
        return client.select()
         .from("products")
         .`as`(Product::class.java)
         .fetch()
        .all()
    }
}

在这里,我们使用非阻塞DatabaseClient对数据库执行查询。 现在,让我们使用挂起函数和相应的Kotlin类型重写存储库类。

4.2。 R2DBC与协程

为了将功能从反应式转换为Coroutines API,我们在功能定义之前添加了suspended修饰符:

1
2
fun noResultFunc(): Mono<Void>
suspend fun noResultFunc()

此外,我们可以省略Void返回类型。 如果是非空结果,我们只返回已定义类型的结果,而不将其包装在Mono类中:

1
2
fun singleItemResultFunc(): Mono<T>
fun singleItemResultFunc(): T?

接下来,如果一个光源可能发出的光不止一个,我们只需将Flux更改为Flow,如下所示:

1
2
fun multiItemsResultFunc(): Flux<T>
fun mutliItemsResultFunc(): Flow<T>

让我们应用这些规则并重构我们的存储库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Repository
class ProductRepositoryCoroutines(private val client: DatabaseClient) {

    suspend fun getProductById(id: Int): Product? =
        client.execute()
         .sql("SELECT * FROM products WHERE id = $1")
         .bind(0, id)
         .`as`(Product::class.java)
         .fetch()
         .one()
         .awaitFirstOrNull()

    suspend fun addNewProduct(name: String, price: Float) =
        client.execute()
         .sql("INSERT INTO products (name, price) VALUES($1, $2)")
         .bind(0, name)
         .bind(1, price)
         .then()
         .awaitFirstOrNull()

    @FlowPreview
    fun getAllProducts(): Flow<Product> =
        client.select()
         .from("products")
         .`as`(Product::class.java)
         .fetch()
         .all()
        .asFlow()
}

在上面的代码段中,有几点需要我们注意。 这些await *函数从何而来? 它们在kotlin-coroutines-reactive库中定义为Kotlin扩展功能。

此外,spring-data-r2dbc库中还有更多扩展可用。

5. Spring WebFlux控制器

到目前为止,我们已经了解了如何实现存储库,但是尚未对数据存储区进行任何实际查询。 因此,在本节中,我们将通过创建非阻塞控制器来弄清楚如何在Spring WebFlux框架中应用协程。

5.1。 无功控制器

让我们定义两个简单的端点,依次通过我们的存储库查询数据库。

让我们从更熟悉的反应式风格开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
class ProductController {
    @Autowired
    lateinit var productRepository: ProductRepository

    @GetMapping("/{id}")
    fun findOne(@PathVariable id: Int): Mono<Product> {
        return productRepository.getProductById(id)
    }

    @GetMapping("/")
    fun findAll(): Flux<Product> {
        return productRepository.getAllProducts()
    }
}

这就提出了一个问题,哪个线程负责执行实际的I / O操作? 默认情况下,每个查询的操作都在由基础调度程序实现选择的单独的反应堆NIO线程上运行。

5.2。 协程控制器

让我们利用挂起函数并使用相应的存储库类来重构控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
class ProductControllerCoroutines {
    @Autowired
    lateinit var productRepository: ProductRepositoryCoroutines

    @GetMapping("/{id}")
    suspend fun findOne(@PathVariable id: Int): Product? {
        return productRepository.getProductById(id)
    }

    @FlowPreview
    @GetMapping("/")
    fun findAll(): Flow<Product> {
        return productRepository.getAllProducts()
    }
}

首先,请注意,findAll()函数不是正在暂停的函数。 但是,就返回Flow而言,它在内部调用了暂停函数。

对于此版本,数据库查询将在与反应式示例相同的反应堆线程上运行。

6. Spring WebFlux WebClient

接下来,假设我们的系统中有微服务。

为了完成请求,我们需要查询另一项服务以获取其他数据。 因此,在我们的案例中,一个很好的例子是获取产品库存数量。 要通过API调用其他服务,我们将使用WebFlux框架中的WebClient。

6.1。 响应式WebClient

首先,让我们看看如何发出一个简单的请求:

1
2
3
val htmlResponse = webClient.get()
  .uri("https://www.baeldung.com/")
  .retrieve().bodyToMono<String>()

下一步是致电外部库存服务以获取库存数量,然后将合并结果返回给客户。 首先,我们将从存储库中获取产品,然后查询库存服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/{id}/stock")
fun findOneInStock(@PathVariable id: Int): Mono<ProductStockView> {
   val product = productRepository.getProductById(id)
   
   val stockQuantity = webClient.get()
     .uri("/stock-service/product/$id/quantity")
     .accept(MediaType.APPLICATION_JSON)
     .retrieve()
     .bodyToMono<Int>()
   return product.zipWith(stockQuantity) {
       productInStock, stockQty ->
         ProductStockView(productInStock, stockQty)
   }
}

注意,我们将返回Mono

的对象
从存储库中键入。 然后,我们从WebClient获得Mono 。 最后,实际的订阅发生在我们调用zipWith()方法时。 我们等待两个请求完成,最后将它们组合成一个新对象。

6.2。 带协程的WebClient

现在,让我们看看如何将WebClient与协程一起使用。

为了执行GET请求,我们应用awaitBody()暂停扩展功能:

1
2
3
4
val htmlResponse = webClient.get()
 .uri("https://www.baeldung.com/")
 .retrieve()
  .awaitBody<String>()

这样,如果API调用返回2xx响应以外的任何内容,则retrieve()方法将引发异常。 为了自定义针对不同响应状态的响应处理,我们可以使用awaitExchange()暂停扩展功能:

1
2
3
4
val response: ResponseEntity<String> = webClient.get()
  .uri("https://www.baeldung.com/")
  .awaitExchange()
  .awaitEntity()

由于我们可以访问生成的ResponseEntity,因此我们可以检查状态码,然后采取相应的措施。

让我们回到我们的微服务示例。 我们可以针对库存服务执行请求:

1
2
3
4
5
6
7
8
9
10
@GetMapping("/{id}/stock")
suspend fun findOneInStock(@PathVariable id: Int): ProductStockView {
    val product = productRepository.getProductById(id)
    val quantity = webClient.get()
     .uri("/stock-service/product/$id/quantity")
     .accept(APPLICATION_JSON)
     .retrieve()
    .awaitBody<Int>()
    return ProductStockView(product!!, quantity)
}

我们应该注意,这看起来像是一个阻塞代码。 使用协程的主要好处之一是能够以一种流畅且易读的方式编写异步代码。

在上面的示例中,数据库查询和Web请求将一个接一个地执行。 这是因为默认情况下协程是顺序的。

我们可以并行运行挂起功能吗? 绝对! 让我们修改端点方法以并行运行查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/{id}/stock")
suspend fun findOneInStock(@PathVariable id: Int): ProductStockView = coroutineScope {
    val product: Deferred<Product?> = async(start = CoroutineStart.LAZY) {
        productRepository.getProductById(id)
    }
    val quantity: Deferred<Int> = async(start = CoroutineStart.LAZY) {
        webClient.get()
          .uri("/stock-service/product/$id/quantity")
          .accept(APPLICATION_JSON)
          .retrieve().awaitBody<Int>()
    }
    ProductStockView(product.await()!!, quantity.await())
}

在这里,通过将暂停函数包装在async {}块中,我们获得了Deferred <>类型的对象。 默认情况下,协程会立即安排执行。 结果,如果我们想在调用await()方法时准确地运行它们,则需要传递CoroutineStart.LAZY作为可选的startparameter。

最后,要开始执行功能,我们调用await()方法。 在这种情况下,这两个功能将并行执行。 此技术也称为并行分解。

有趣的是,异步块中的函数被分派到单独的工作线程中。 之后,实际的I / O操作会在Reactor NIO池中的线程上发生。

为了实施结构化并发,我们使用了coroutineScope {} scoping函数来创建我们自己的范围。 在完成自身之前,它将等待块中的所有协程完成。 但是,与runBlocking相比,coroutineScope {}函数不会阻塞当前线程。

7. WebFlux.fn DSL路由

最后,让我们看看如何在DSL路由定义中使用协程。

由Kotlin支持的WebFlux功能框架提供了一种定义端点的简洁流畅的方法。 coRouter {} DSL支持用于定义路由器功能的Kotlin协程。

首先,让我们以DSL方式定义路由器端点:

1
2
3
4
5
6
7
8
9
10
@Configuration
class RouterConfiguration {
    @FlowPreview
    @Bean
    fun productRoutes(productsHandler: ProductsHandler) = coRouter {
        GET("/", productsHandler::findAll)
        GET("/{id}", productsHandler::findOne)
        GET("/{id}/stock", productsHandler::findOneInStock)
    }
}

现在我们有了路由定义,让我们使用与ProductController相同的功能来实现ProductsHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
class ProductsHandler(
  @Autowired var webClient: WebClient,
  @Autowired var productRepository: ProductRepositoryCoroutines) {
   
    @FlowPreview
    suspend fun findAll(request: ServerRequest): ServerResponse =
        ServerResponse.ok().json().bodyAndAwait(productRepository.getAllProducts())

    suspend fun findOneInStock(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toInt()
        val product: Deferred<Product?> = GlobalScope.async {
            productRepository.getProductById(id)
        }
        val quantity: Deferred<Int> = GlobalScope.async {
            webClient.get()
             .uri("/stock-service/product/$id/quantity")
             .accept(MediaType.APPLICATION_JSON)
             .retrieve().awaitBody<Int>()
        }
        return ServerResponse.ok()
         .json()
         .bodyAndAwait(ProductStockView(product.await()!!, quantity.await()))
    }

    suspend fun findOne(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toInt()
        return ServerResponse.ok()
         .json()
        .bodyAndAwait(productRepository.getProductById(id)!!)
    }
}

我们应该注意,我们已经使用了悬挂函数来定义ProductsHandler类。 除请求和响应类型外,与控制器相比没有太大变化。

这就是我们建立一个简单的REST控制器所需要的。 因此,得益于将Routes DSL与Kotlin协程一起使用,我们可以流畅而简洁地定义端点。

8.结论

在本文中,我们研究了Kotlin协程,并发现了如何将它们与Spring框架,R2DBC以及WebFlux特别集成。

在项目中应用非阻塞方法可以提高应用程序性能和可伸缩性。 此外,我们已经看到了使用Kotlin协程如何使异步代码更具可读性。

我们应该注意,上述库的开发中版本在达到稳定版本之前可能会发生很大变化,并且较小的版本差异可能会彼此不兼容。

可以从GitHub上一如既往地获得示例代码。