Playing around with Kotlin and Spring Webflux

I have recently been doing quite a bit of kotlin at timeular and must say I like it a lot so far.

Also, with the new Spring Boot 2.0.0, the kotlin support got improved and there is now a reactive option to the Spring MVC web framework called Webflux.

In this post, we will take a look at how to create a small, Kotlin-based Spring-Boot application using Webflux. We will create a Web Application with a single GET /api route, which fetches posts and comments from jsonplaceholder in parallel and transforms them to a combined output.

Webflux uses the Reactor library underneath, which has two important types: Flux and Mono. A Flux is a reactive Publisher for 0 to n values, whereas a Mono is limited to 0 or 1 value. A more detailed explanation can be found here.

There is also a new functional DSL for specifying routes in Webflux, but in this example, we will stick to the good old @RestController to define our route.

Let’s get started.

Example

First, let’s create a new Spring Boot application at https://start.spring.io/ selecting the Reactive Web dependency. With this application template, we can start our implementation by configuring the Spring Boot application to be a reactive web application:

@SpringBootApplication
class KotlinWebfluxDemoApplication

fun main(args: Array<String>) {
    val app = SpringApplication(KotlinWebfluxDemoApplication::class.java)
    app.webApplicationType = WebApplicationType.REACTIVE
    app.run(*args)
}

And we also create a WebConfig class, which adds cors mappings, has the @EnableWebFlux annotation and sets up our dependency injection config.

@Configuration
@EnableWebFlux
@ComponentScan("org.zupzup.kotlinwebfluxdemo")
class WebConfig: WebFluxConfigurer {
    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("api/**")
    }
}

With the boilerplate out of the way, let’s create some data models. Kotlin provides the great data class for this purpose, which let’s us define value classes in a highly succinct way.

In this example, we will need a Post and Comment for the data source and a Response class for our transformed response:

data class Comment(
        val postId: Int,
        val id: Int,
        val name: String,
        val email: String,
        val body: String
)

data class Post(
        val userId: Int,
        val id: Int,
        val title: String,
        val body: String
)

data class Response(
        val postId: Int,
        val userId: Int,
        val title: String,
        val comments: List<LightComment>
)

data class LightComment(
        val email: String,
        val body: String
)

In order to fetch data from jsonplaceholder, we create an APIService, which uses the reactive WebClient integrated into Webflux.

This is quite nice, as it enables us to easily transform our responses to a Flux or Mono of the model represented by the returned JSON.

@Service
class APIService {
    fun fetchComments(postId: Int): Flux<Comment> = fetch("posts/$postId/comments").bodyToFlux(Comment::class.java)

    fun fetchPosts(): Flux<Post> = fetch("/posts").bodyToFlux(Post::class.java)

    fun fetch(path: String): WebClient.ResponseSpec {
        val client = WebClient.create("http://jsonplaceholder.typicode.com/")
        return client.get().uri(path).retrieve()
    }
}

That’s all we need for fetching the data and transforming it to reactive versions of our models. Pretty cool. The WebClient works well and provides all kinds of utility functionality you would expect.

The last and most important/interesting part is our handler. In this small example we will implement the whole logic right in the controller for simplicity.

The goal is to fetch 20 posts with an even id from jsonplaceholder and then, for each post in parallel, fetch its comments and transforming the posts and comments into a nested data-structure like this:

[
    {
        "postId": 1,
        "userId": 2,
        "title": "...",
        "comments":
        [
            {
                "email": "...",
                "body": "..."
            }
        ]
    }
]

Let’s see how that works:

@RestController
@RequestMapping(path = ["/api"], produces = [ APPLICATION_JSON_UTF8_VALUE ])
class APIController(
        private val apiService: APIService
) {
    @RequestMapping(method = [RequestMethod.GET])
    fun getData(): Mono<ResponseEntity<List<Response>>> {
        return apiService.fetchPosts()
                .filter { it -> it.userId % 2 == 0 }
                .take(20)
                .parallel(4)
                .runOn(Schedulers.parallel())
                .map { post -> apiService.fetchComments(post.id)
                        .map { comment -> LightComment(email = comment.email, body = comment.body) }
                        .collectList()
                        .zipWith(post.toMono()) }

                .flatMap { it -> it }
                .map { result -> Response(
                        postId = result.t2.id,
                        userId = result.t2.userId,
                        title = result.t2.title,
                        comments = result.t1
                ) }
                .sequential()
                .collectList()
                .map { body -> ResponseEntity.ok().body(body) }
                .toMono()
    }
}

Ok, quite a few things are happening here and if you’re not used to functional programming or reactive sequences, this might not seem very intuitive at first - don’t worry about it, it’s the same for everyone.

We start off by fetching the posts using our injected apiService. Then we filter this reactive stream of post elements, using only posts with even ids and taking only the first 20 elements (take(20)). So far, so good.

To fetch the comments with explicit parallelism of 4, we add .parallel(4) and .runOn(Schedulers.parallel()) to the stream. Later on, we have to call .sequential() again to wait for all the values fetched in parallel, otherwise we couldn’t stuff them together in a List, which we need to be able to zip the comments with the post.

Just to be clear, the parallel(4) is not necessary to fetch the comments concurrently, but rather explicitly controls the parallelism of the concurrent and asynchronous processing. As @hpgrahsl pointed out to me on twitter, the idiomatic, recommended way for this use-case would be to just use flatMap.

In any case, the next step is to map the posts to another reactive sequence. In this new sequence, we call fetchComments with the given post.id to fetch a post’s comments. The goal is to map these comments to LightComment, throwing away some of the data we don’t want to display and to zip them up with the post data, so we can create the above mentioned data structure later on.

Now we have a sequence of Tuple2<List<LightComment, Post>> from the zipWith operation. We use flatMap to convert the stream from a sequence of Monos to a sequence of just our zipped data, which we then simply map to the Response model.

All that’s left now is collecting all the values to a list and mapping that list to a Mono with a ResponseEntity inside it, including our data as the body, which is what the controller expects as a return value.

That’s it!

The whole code for this example can be found here.

Conclusion

Reactive programming, when you’re used to the style, can be a lot of fun. There are some great benefits when writing applications in a non-blocking way, especially when dealing with microservice architectures.

There are some downsides to this asynchronous, non-blocking, stream-transforming way of coding like that it’s inherently hard to test and debug and that there is a bit of a learning-curve at first, but for the right use-cases the benefits outweigh them in my opinion.

It’s been a while since I used Spring Boot or Spring in general and I’m impressed by the progress which happened in the meantime. Webflux, especially with Kotlin, looks like something I will strongly consider for a future service. :)

Thanks to @hpgrahsl for the invaluable feedback regarding the use of parallel and flatMap.

Resources


I work as a freelance software engineer and trainer and you can hire me. If you like my work, or my writing and if I sound like someone you'd like to work with, let's talk! :)