Writing Unadulterated Services with Kotlin and Coroutines

VMware Tanzu
VMware TanzuVMware Tanzu
Writing Unadulterated Services
with Kotlin and Coroutines
Zohaib Sibte Hassan
SpringOne 2021
About me
Zohaib Sibte Hassan


Working @DoorDash




Twitter: @zohaibility
DoorDash
Picking right the stack
• It’s hard to look into future, and depends a lot on your context!


• Key factors:


• Solid community & ecosystem


• Truly multithreaded - no GILs or bottle necks


• Async IO and Multi-reactor pattern
Today’s we will…
• Why JVM?


• Why Reactor + Spring?


• Why Kotlin + Coroutines?
Why JVM?
Why JVM?
• Battle tested


• Plethora of options including OSS versions (My fav. OpenJDK)


• Truly multithreaded


• Solid JIT & GC


• Strong community & eco-system


• And more …
Why true multi-threading…
Why Reactor + Spring?
• Battle tested & widely adopted


• Strong community & eco-system


• Tooling and support


• Async IO & Event Driven support!
Reactor Pattern
Fragmentation ahead!
Fragmentation
NIO frameworks
• Java NIO


• The Netty Project


• Apache Mina


• Project Grizzly
Abstraction on top of I/O
Abstraction of Future/Promise
A brief history…
Fragmentation
Future/CompletionStage/Deferred/Mono/ListenableFuture/Promise/…
• Java CompletionStage/Future/Stream - Early concepts popularized Java 5 by FutureTask.


• Reactor - Spring de-facto.


• RxJava - Rx 2 vs Rx 3 has major difference (https://github.com/ReactiveX/RxJava/wiki/
What%27s-different-in-3.0).


• Vert.x - Has it’s own AsyncResult<T> with interoperability with CompletionStage


• Mutiny - new kid on the block.


• Quasar - inspired by Erlang and Go (Possibly dead).


• Akka - Powerful actor & message driven.


• Google Guava - ListenableFuture<T> 🤦


• And more 🤦🤦🤦
Reactive manifesto to Rescue!
Callback hell
Hard to read/reason/debug code…
What can we do?
A timeline for async/await pattern
• The async/await pattern was
f
irst added in F# in 2007


• Made it’s way to C# around 2011


• Python 3.5 added support in 2015


• Typescript 1.7 added support in 2015


• Javascript added it part of ECMAScript 2017
Java async/await ?
Project Loom!
Return to virtual threads…
Why Kotlin & Coroutines?
WARNING: Code intensive slides ahead!
Testbed Setup
import lombok.AllArgsConstructor
;

import lombok.Builder
;

import lombok.Data
;

import lombok.NoArgsConstructor
;

import lombok.experimental.Accessors
;

import org.springframework.data.annotation.Id
;

@Dat
a

@Builder(toBuilder = true
)

@Accessors(chain = true
)

@NoArgsConstructor(force = true
)

@AllArgsConstructo
r

public class GeoInfo
{

@I
d

private String ip
;

private String city
;

private String country
;

private String lat
;

private String lon
;

}
import org.springframework.data.annotation.I
d

data class GeoInfo
(

@Id val ip: String = ""
,

val city: String? = null
,

val country: String? = null
,

val lat: String? = null
,

val lon: String? = nul
l

)
private Mono<GeoInfo> callService(String ip)
{

return Mono.defer(() ->
{

if (ip == null)
{

return Mono.error(new IllegalArgumentException("IP can't be null"))
;

}

String getUrl = "/json/" + UriUtils.encode(ip, "utf-8")
;

return httpClient.get(
)

.uri(getUrl
)

.retrieve(
)

.toEntity(GeoInfo.class
)

.publishOn(Schedulers.parallel()
)

.flatMap(resp ->
{

if (!resp.getStatusCode().is2xxSuccessful())
{

return Mono.error(new IOException("Error " + resp.getStatusCode()))
;

}

GeoInfo info = resp.getBody()
;

if (info == null)
{

return Mono.error(new IOException("Empty response body"))
;

}

logger.info("Received response...")
;

return Mono.just(info.toBuilder().ip(ip).build())
;

})
;

})
;

}

WebClient.create(BASE_URL
)
private suspend fun callService(ip: String): GeoInfo
{

val getUrl = "/json/${UriUtils.encode(ip, "utf-8")}
"

logger.info("Calling external expensive service... {}", getUrl
)

val resp = httpClient.get(
)

.uri(getUrl
)

.retrieve(
)

.toEntity(GeoInfo::class.java
)

.awaitSingle(
)

if (!resp.statusCode.is2xxSuccessful)
{

throw IOException("Error ${resp.statusCode}"
)

}

return resp.body?.copy(ip = ip) ?: throw IOException("Empty response body"
)

}
Let’s add a database to save extra calls!
databasedCached(ip, () -> callService(ip)
)
Reactive database?
import org.springframework.data.r2dbc.repository.Query
;

import org.springframework.data.repository.reactive.ReactiveCrudRepository
;

import reactor.core.publisher.Mono
;

public interface GeoIPRepo extends ReactiveCrudRepository<GeoInfo, String>
{

@Query("INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon) "
+

"ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lon "
+

"RETURNING *"
)

Mono<GeoInfo> upsert(String ip, String city, String country, String lat, String lon)
;

}
private Mono<GeoInfo> databasedCached(String ip, Supplier<Mono<GeoInfo>> fetch)
{

return findViaDatabase(ip).switchIfEmpty
(

fetch.get().flatMap(this::updateDatabase
)

)
;

}

private Mono<GeoInfo> findViaDatabase(String ip)
{

return dbRepo.findById(ip
)

.publishOn(Schedulers.parallel()
)

.doOnNext(info ->
{

logger.info("Found cache response in DB {}", info.toString())
;

}
)

.onErrorResume(err ->
{

logger.error("Unable to fetch from DB falling back", err)
;

return Mono.empty()
;

})
;

}

private Mono<GeoInfo> updateDatabase(GeoInfo info)
{

return Mono.defer(() ->
{

logger.info("Saving to DB... {}", info)
;

return dbRepo.upsert
(

info.getIp()
,

info.getCity()
,

info.getCountry()
,

info.getLat()
,

info.getLon(
)

).onErrorReturn(info)
;

}).publishOn(Schedulers.parallel())
;

}
Writing Unadulterated Services with Kotlin and Coroutines
private Mono<GeoInfo> databasedCached(String ip, Supplier<Mono<GeoInfo>> fetch)
{

return findViaDatabase(ip).switchIfEmpty
(

fetch.get().flatMap(this::updateDatabase
)

)
;

}

private Mono<GeoInfo> findViaDatabase(String ip)
{

return dbRepo.findById(ip
)

.publishOn(Schedulers.parallel()
)

.doOnNext(info ->
{

logger.info("Found cache response in DB {}", info.toString())
;

}
)

.onErrorResume(err ->
{

logger.error("Unable to fetch from DB falling back", err)
;

return Mono.empty()
;

})
;

}

private Mono<GeoInfo> updateDatabase(GeoInfo info)
{

return Mono.defer(() ->
{

logger.info("Saving to DB... {}", info)
;

return dbRepo.upsert
(

info.getIp()
,

info.getCity()
,

info.getCountry()
,

info.getLat()
,

info.getLon(
)

).onErrorReturn(info)
;

}).publishOn(Schedulers.parallel())
;

}
import org.springframework.data.r2dbc.repository.Quer
y

import org.springframework.data.repository.reactive.ReactiveCrudRepositor
y

import reactor.core.publisher.Mon
o

interface GeoIPRepo : ReactiveCrudRepository<GeoInfo, String>
{

@Query
(

"""INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon
)

ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lo
n

RETURNING
*

""
"

)

fun upsert(ip: String, city: String?, country: String?, lat: String?, lon: String?): Mono<GeoInfo
>

}
private suspend fun dbCached(ip: String, load: suspend () -> GeoInfo): GeoInfo
{

val cachedInfo = try
{

Optional.ofNullable(dbRepo.findById(ip).awaitSingleOrNull()
)

} catch (e: R2dbcException)
{

logger.warn("Unable to load from DB cache...", e
)

Optional.empty(
)

}

if (cachedInfo.isPresent)
{

logger.info("DB cache hit!!!"
)

return cachedInfo.get(
)

}

logger.info("DB cache miss..."
)

val loadedInfo = load(
)

logger.info("Saving to DB cache..."
)

try
{

dbRepo.upsert
(

loadedInfo.ip
,

loadedInfo.city
,

loadedInfo.country
,

loadedInfo.lat
,

loadedInfo.lo
n

).awaitSingle(
)

} catch (err: R2dbcException)
{

logger.error("Unable to cache data", err
)

}

return loadedInf
o

}
dbCached(ip)
{

callService(ip
)

}
suspend all the way…
import org.springframework.data.r2dbc.repository.Quer
y

import org.springframework.data.repository.reactive.ReactiveCrudRepositor
y

interface GeoIPRepo : CoroutineCrudRepository<GeoInfo, String>
{

@Query
(

"""INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon
)

ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lo
n

RETURNING
*

""
"

)

suspend fun upsert(ip: String, city: String?, country: String?, lat: String?, lon: String?): GeoInfo
?

}
private suspend fun dbCached(ip: String, load: suspend () -> GeoInfo): GeoInfo
{

val cachedInfo = try
{

Optional.ofNullable(dbRepo.findById(ip)
)

} catch (e: R2dbcException)
{

logger.warn("Unable to load from DB cache...", e
)

Optional.empty(
)

}

if (cachedInfo.isPresent)
{

logger.info("DB cache hit!!!"
)

return cachedInfo.get(
)

}

logger.info("DB cache miss..."
)

val loadedInfo = load(
)

logger.info("Saving to DB cache..."
)

try
{

dbRepo.upsert
(

loadedInfo.ip
,

loadedInfo.city
,

loadedInfo.country
,

loadedInfo.lat
,

loadedInfo.lo
n

)

} catch (err: R2dbcException)
{

logger.error("Unable to cache data", err
)

}

return loadedInf
o

}
import org.springframework.web.bind.annotation.GetMappin
g

import org.springframework.web.bind.annotation.PathVariabl
e

import org.springframework.web.bind.annotation.RequestMappin
g

import org.springframework.web.bind.annotation.RestControlle
r

@RestControlle
r

@RequestMapping("/"
)

class HomeController
(

private val repo: ExtremeIPLooku
p

)
{

@GetMapping("/find/{ip}"
)

suspend fun locate(@PathVariable ip: String): GeoInfo
{

return withContext(Dispatchers.Default)
{

repo.find(ip
)

}

}

}
Dealing with async
fragmentation…
Almost…
Dealing with async fragmentation…
Out of box extensions
• kotlinx-coroutines-jdk8


• kotlinx-coroutines-jdk9


• kotlinx-coroutines-reactive


• kotlinx-coroutines-reactor


• kotlinx-coroutines-rx2


• Kotlinx-coroutines-rx3


• kotlinx-coroutines-guava


• Kotlinx-coroutines-play-service
What about legacy thread blocking calls?
mono.publishOn(Schedulers.boundedElastic()
)

.map(v ->
{

// Do blocking operatio
n

}
)

.publishOn(Schedulers.parallel())
;

withContext(Dispatchers.IO)
{

// Do blocking operation
}
Kotlin coroutines has more!
• Control over structured concurrency using:


• Coroutine scopes, contexts, and dispatchers


• Dispatching constructs withContext, async, and launch


• Flows, Channels, and Actors!


• Spring Framework 5.2+ has full coroutine support


• Awesome reactor interoperability with coroutines
Keeping It Simple & Stupid.
What are the key takeaways?
Further reading
• https://www.youtube.com/watch?v=YrrUCSi72E8


• https://kotlinlang.org/docs/coroutines-guide.html


• https://kotlin.github.io/kotlinx.coroutines/index.html


• https://spring.io/blog/2019/04/12/going-reactive-with-spring-coroutines-
and-kotlin-
f
low


• https://www.baeldung.com/kotlin/spring-boot-kotlin-coroutines


• https://spring.io/guides/tutorials/spring-web
f
lux-kotlin-rsocket/


• https://kt.academy/article/cc-under-the-hood
Thank you!
1 of 48

More Related Content

More from VMware Tanzu(20)

tanzu_developer_connect.pptxtanzu_developer_connect.pptx
tanzu_developer_connect.pptx
VMware Tanzu153 views
Tanzu Developer Connect - FrenchTanzu Developer Connect - French
Tanzu Developer Connect - French
VMware Tanzu13 views

Recently uploaded(20)

Writing Unadulterated Services with Kotlin and Coroutines

  • 1. Writing Unadulterated Services with Kotlin and Coroutines Zohaib Sibte Hassan SpringOne 2021
  • 2. About me Zohaib Sibte Hassan Working @DoorDash 
 
 Twitter: @zohaibility
  • 3. DoorDash
  • 4. Picking right the stack • It’s hard to look into future, and depends a lot on your context! • Key factors: • Solid community & ecosystem • Truly multithreaded - no GILs or bottle necks • Async IO and Multi-reactor pattern
  • 5. Today’s we will… • Why JVM? • Why Reactor + Spring? • Why Kotlin + Coroutines?
  • 6. Why JVM?
  • 7. Why JVM? • Battle tested • Plethora of options including OSS versions (My fav. OpenJDK) • Truly multithreaded • Solid JIT & GC • Strong community & eco-system • And more …
  • 8. Why true multi-threading…
  • 9. Why Reactor + Spring?
  • 10. • Battle tested & widely adopted • Strong community & eco-system • Tooling and support • Async IO & Event Driven support!
  • 11. Reactor Pattern
  • 12. Fragmentation ahead!
  • 13. Fragmentation NIO frameworks • Java NIO • The Netty Project • Apache Mina • Project Grizzly
  • 14. Abstraction on top of I/O
  • 15. Abstraction of Future/Promise A brief history…
  • 16. Fragmentation Future/CompletionStage/Deferred/Mono/ListenableFuture/Promise/… • Java CompletionStage/Future/Stream - Early concepts popularized Java 5 by FutureTask. • Reactor - Spring de-facto. • RxJava - Rx 2 vs Rx 3 has major difference (https://github.com/ReactiveX/RxJava/wiki/ What%27s-different-in-3.0). • Vert.x - Has it’s own AsyncResult<T> with interoperability with CompletionStage • Mutiny - new kid on the block. • Quasar - inspired by Erlang and Go (Possibly dead). • Akka - Powerful actor & message driven. • Google Guava - ListenableFuture<T> 🤦 • And more 🤦🤦🤦
  • 17. Reactive manifesto to Rescue!
  • 18. Callback hell Hard to read/reason/debug code…
  • 19. What can we do?
  • 20. A timeline for async/await pattern • The async/await pattern was f irst added in F# in 2007 • Made it’s way to C# around 2011 • Python 3.5 added support in 2015 • Typescript 1.7 added support in 2015 • Javascript added it part of ECMAScript 2017
  • 21. Java async/await ?
  • 22. Project Loom! Return to virtual threads…
  • 23. Why Kotlin & Coroutines? WARNING: Code intensive slides ahead!
  • 24. Testbed Setup
  • 25. import lombok.AllArgsConstructor ; import lombok.Builder ; import lombok.Data ; import lombok.NoArgsConstructor ; import lombok.experimental.Accessors ; import org.springframework.data.annotation.Id ; @Dat a @Builder(toBuilder = true ) @Accessors(chain = true ) @NoArgsConstructor(force = true ) @AllArgsConstructo r public class GeoInfo { @I d private String ip ; private String city ; private String country ; private String lat ; private String lon ; }
  • 26. import org.springframework.data.annotation.I d data class GeoInfo ( @Id val ip: String = "" , val city: String? = null , val country: String? = null , val lat: String? = null , val lon: String? = nul l )
  • 27. private Mono<GeoInfo> callService(String ip) { return Mono.defer(() -> { if (ip == null) { return Mono.error(new IllegalArgumentException("IP can't be null")) ; } String getUrl = "/json/" + UriUtils.encode(ip, "utf-8") ; return httpClient.get( ) .uri(getUrl ) .retrieve( ) .toEntity(GeoInfo.class ) .publishOn(Schedulers.parallel() ) .flatMap(resp -> { if (!resp.getStatusCode().is2xxSuccessful()) { return Mono.error(new IOException("Error " + resp.getStatusCode())) ; } GeoInfo info = resp.getBody() ; if (info == null) { return Mono.error(new IOException("Empty response body")) ; } logger.info("Received response...") ; return Mono.just(info.toBuilder().ip(ip).build()) ; }) ; }) ; } WebClient.create(BASE_URL )
  • 28. private suspend fun callService(ip: String): GeoInfo { val getUrl = "/json/${UriUtils.encode(ip, "utf-8")} " logger.info("Calling external expensive service... {}", getUrl ) val resp = httpClient.get( ) .uri(getUrl ) .retrieve( ) .toEntity(GeoInfo::class.java ) .awaitSingle( ) if (!resp.statusCode.is2xxSuccessful) { throw IOException("Error ${resp.statusCode}" ) } return resp.body?.copy(ip = ip) ?: throw IOException("Empty response body" ) }
  • 29. Let’s add a database to save extra calls! databasedCached(ip, () -> callService(ip) )
  • 30. Reactive database?
  • 31. import org.springframework.data.r2dbc.repository.Query ; import org.springframework.data.repository.reactive.ReactiveCrudRepository ; import reactor.core.publisher.Mono ; public interface GeoIPRepo extends ReactiveCrudRepository<GeoInfo, String> { @Query("INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon) " + "ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lon " + "RETURNING *" ) Mono<GeoInfo> upsert(String ip, String city, String country, String lat, String lon) ; }
  • 32. private Mono<GeoInfo> databasedCached(String ip, Supplier<Mono<GeoInfo>> fetch) { return findViaDatabase(ip).switchIfEmpty ( fetch.get().flatMap(this::updateDatabase ) ) ; } private Mono<GeoInfo> findViaDatabase(String ip) { return dbRepo.findById(ip ) .publishOn(Schedulers.parallel() ) .doOnNext(info -> { logger.info("Found cache response in DB {}", info.toString()) ; } ) .onErrorResume(err -> { logger.error("Unable to fetch from DB falling back", err) ; return Mono.empty() ; }) ; } private Mono<GeoInfo> updateDatabase(GeoInfo info) { return Mono.defer(() -> { logger.info("Saving to DB... {}", info) ; return dbRepo.upsert ( info.getIp() , info.getCity() , info.getCountry() , info.getLat() , info.getLon( ) ).onErrorReturn(info) ; }).publishOn(Schedulers.parallel()) ; }
  • 34. private Mono<GeoInfo> databasedCached(String ip, Supplier<Mono<GeoInfo>> fetch) { return findViaDatabase(ip).switchIfEmpty ( fetch.get().flatMap(this::updateDatabase ) ) ; } private Mono<GeoInfo> findViaDatabase(String ip) { return dbRepo.findById(ip ) .publishOn(Schedulers.parallel() ) .doOnNext(info -> { logger.info("Found cache response in DB {}", info.toString()) ; } ) .onErrorResume(err -> { logger.error("Unable to fetch from DB falling back", err) ; return Mono.empty() ; }) ; } private Mono<GeoInfo> updateDatabase(GeoInfo info) { return Mono.defer(() -> { logger.info("Saving to DB... {}", info) ; return dbRepo.upsert ( info.getIp() , info.getCity() , info.getCountry() , info.getLat() , info.getLon( ) ).onErrorReturn(info) ; }).publishOn(Schedulers.parallel()) ; }
  • 35. import org.springframework.data.r2dbc.repository.Quer y import org.springframework.data.repository.reactive.ReactiveCrudRepositor y import reactor.core.publisher.Mon o interface GeoIPRepo : ReactiveCrudRepository<GeoInfo, String> { @Query ( """INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon ) ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lo n RETURNING * "" " ) fun upsert(ip: String, city: String?, country: String?, lat: String?, lon: String?): Mono<GeoInfo > }
  • 36. private suspend fun dbCached(ip: String, load: suspend () -> GeoInfo): GeoInfo { val cachedInfo = try { Optional.ofNullable(dbRepo.findById(ip).awaitSingleOrNull() ) } catch (e: R2dbcException) { logger.warn("Unable to load from DB cache...", e ) Optional.empty( ) } if (cachedInfo.isPresent) { logger.info("DB cache hit!!!" ) return cachedInfo.get( ) } logger.info("DB cache miss..." ) val loadedInfo = load( ) logger.info("Saving to DB cache..." ) try { dbRepo.upsert ( loadedInfo.ip , loadedInfo.city , loadedInfo.country , loadedInfo.lat , loadedInfo.lo n ).awaitSingle( ) } catch (err: R2dbcException) { logger.error("Unable to cache data", err ) } return loadedInf o }
  • 37. dbCached(ip) { callService(ip ) }
  • 38. suspend all the way…
  • 39. import org.springframework.data.r2dbc.repository.Quer y import org.springframework.data.repository.reactive.ReactiveCrudRepositor y interface GeoIPRepo : CoroutineCrudRepository<GeoInfo, String> { @Query ( """INSERT INTO geo_info VALUES(:ip, :city, :country, :lat, :lon ) ON CONFLICT (ip) DO UPDATE SET city = :city, country = :country, lat = :lat, lon = :lo n RETURNING * "" " ) suspend fun upsert(ip: String, city: String?, country: String?, lat: String?, lon: String?): GeoInfo ? }
  • 40. private suspend fun dbCached(ip: String, load: suspend () -> GeoInfo): GeoInfo { val cachedInfo = try { Optional.ofNullable(dbRepo.findById(ip) ) } catch (e: R2dbcException) { logger.warn("Unable to load from DB cache...", e ) Optional.empty( ) } if (cachedInfo.isPresent) { logger.info("DB cache hit!!!" ) return cachedInfo.get( ) } logger.info("DB cache miss..." ) val loadedInfo = load( ) logger.info("Saving to DB cache..." ) try { dbRepo.upsert ( loadedInfo.ip , loadedInfo.city , loadedInfo.country , loadedInfo.lat , loadedInfo.lo n ) } catch (err: R2dbcException) { logger.error("Unable to cache data", err ) } return loadedInf o }
  • 41. import org.springframework.web.bind.annotation.GetMappin g import org.springframework.web.bind.annotation.PathVariabl e import org.springframework.web.bind.annotation.RequestMappin g import org.springframework.web.bind.annotation.RestControlle r @RestControlle r @RequestMapping("/" ) class HomeController ( private val repo: ExtremeIPLooku p ) { @GetMapping("/find/{ip}" ) suspend fun locate(@PathVariable ip: String): GeoInfo { return withContext(Dispatchers.Default) { repo.find(ip ) } } }
  • 42. Dealing with async fragmentation… Almost…
  • 43. Dealing with async fragmentation… Out of box extensions • kotlinx-coroutines-jdk8 • kotlinx-coroutines-jdk9 • kotlinx-coroutines-reactive • kotlinx-coroutines-reactor • kotlinx-coroutines-rx2 • Kotlinx-coroutines-rx3 • kotlinx-coroutines-guava • Kotlinx-coroutines-play-service
  • 44. What about legacy thread blocking calls? mono.publishOn(Schedulers.boundedElastic() ) .map(v -> { // Do blocking operatio n } ) .publishOn(Schedulers.parallel()) ; withContext(Dispatchers.IO) { // Do blocking operation }
  • 45. Kotlin coroutines has more! • Control over structured concurrency using: • Coroutine scopes, contexts, and dispatchers • Dispatching constructs withContext, async, and launch • Flows, Channels, and Actors! • Spring Framework 5.2+ has full coroutine support • Awesome reactor interoperability with coroutines
  • 46. Keeping It Simple & Stupid. What are the key takeaways?
  • 47. Further reading • https://www.youtube.com/watch?v=YrrUCSi72E8 • https://kotlinlang.org/docs/coroutines-guide.html • https://kotlin.github.io/kotlinx.coroutines/index.html • https://spring.io/blog/2019/04/12/going-reactive-with-spring-coroutines- and-kotlin- f low • https://www.baeldung.com/kotlin/spring-boot-kotlin-coroutines • https://spring.io/guides/tutorials/spring-web f lux-kotlin-rsocket/ • https://kt.academy/article/cc-under-the-hood
  • 48. Thank you!