Reactor, WebFlux, Kotlin Coroutines ou Asynchrony avec un exemple simple



De nombreux services dans le monde moderne, pour la plupart, «ne font rien». Leurs tùches sont réduites aux demandes d'autres bases de données / services / caches et à l'agrégation de toutes ces données selon différentes rÚgles et différentes logiques métier. Par conséquent, il n'est pas surprenant que des langues telles que Golang apparaissent, avec un systÚme compétitif intégré pratique qui facilite l'organisation du code non bloquant.


Dans le monde JVM, les choses sont un peu plus compliquĂ©es. Il existe un grand nombre de frameworks et de bibliothĂšques qui bloquent les threads lorsqu'ils sont utilisĂ©s. Donc, stdlib lui-mĂȘme peut parfois faire la mĂȘme chose. Et Ă  Java, il n'y a pas de mĂ©canisme similaire Ă  celui des goroutins Ă  Golang.


Néanmoins, la JVM se développe activement et de nouvelles opportunités intéressantes apparaissent. Il y a Kotlin avec des coroutines, qui dans leur utilisation sont trÚs similaires aux goroutines Gorang (bien qu'elles soient implémentées d'une maniÚre complÚtement différente). Il y a JEP Loom, qui apportera des fibres à la JVM à l'avenir. L'un des frameworks Web les plus populaires - Spring - a récemment ajouté la possibilité de créer des services totalement non bloquants sur Webflux. Et avec la récente version de Spring boot 2.2, l'intégration avec Kotlin est encore meilleure.


Je propose, en utilisant l'exemple d'un petit service de transfert d'argent d'une carte à une autre, d'écrire une application sur Spring boot 2.2 et Kotlin pour intégration avec plusieurs services externes.


C'est bien si vous connaissez dĂ©jĂ  Java, Kotlin, Gradle, Spring, Spring boot 2, Reactor, Web flux, Tomcat, Netty, Kotlin oroutines, Gradle Kotlin DSL ou mĂȘme si vous avez un doctorat. Mais sinon, cela n'a pas d'importance. Le code sera simplifiĂ© au maximum, et mĂȘme si vous n'ĂȘtes pas du monde JVM, j'espĂšre que vous comprendrez tout.


Si vous prĂ©voyez d'Ă©crire un service vous-mĂȘme, assurez-vous que tout ce dont vous avez besoin est installĂ©:


  • Java 8+
  • Docker et Docker Compose;
  • cURL et de prĂ©fĂ©rence jq ;
  • Git
  • de prĂ©fĂ©rence un IDE pour Kotlin (Intellij Idea, Eclipse, VS, vim , etc.). Mais c'est possible dans un cahier.

Les exemples contiendront à la fois des blancs pour l'implémentation dans le service et une implémentation déjà écrite. Tout d'abord, exécutez l'installation et l'assemblage et examinez de plus prÚs les services et leurs API.


L'exemple des services et de l'API lui-mĂȘme est fourni Ă  des fins d'illustration uniquement; ne transfĂ©rez pas tous AS IS Ă  votre produit!

Tout d'abord, nous clonons le rĂ©fĂ©rentiel avec des services pour nous-mĂȘmes, l'intĂ©gration avec laquelle nous le ferons, et allons dans le rĂ©pertoire:


 git clone https://github.com/evgzakharov/spring-demo-services && cd spring-demo-services 

Dans un terminal sĂ©parĂ©, nous collectons toutes les applications en utilisant gradle , oĂč aprĂšs une construction rĂ©ussie, tous les services seront lancĂ©s en utilisant docker-compose .


 ./gradlew build && docker-compose up 

Pendant que tout est téléchargé et installé, envisagez un projet avec des services.



Une demande avec un token, des numéros de carte à transférer et le montant à transférer entre les cartes sera reçue à l'entrée du service (service de démonstration):


 { "authToken": "auth-token1", "cardFrom": "55593478", "cardTo": "55592020", "amount": "10.1" } 

Selon le jeton authToken , authToken devez accéder au service AUTH et obtenir userId , avec lequel vous pouvez ensuite faire une demande à USER et extraire toutes les informations supplémentaires sur l'utilisateur. AUTH retournera également des informations sur lequel des trois services nous pouvons accéder. Exemple de réponse de AUTH :


 { "userId": 158, "cardAccess": true, "paymentAccess": true, "userAccess": true } 

Pour transférer entre les cartes, allez d'abord avec chaque numéro de carte dans la CARD . En réponse aux demandes, nous recevrons cardId , puis avec eux nous envoyons une demande au PAYMENT et effectuons un virement. Et le dernier - une fois de plus, nous envoyons une demande au PAYMENT avec fromCardId et découvrons le solde actuel.


Pour émuler un petit retard dans les services, la valeur de la variable d'environnement TIMEOUT est lancée dans tous les conteneurs, dans lesquels le délai de réponse est défini en millisecondes. Et pour diversifier les réponses de AUTH , il est possible de faire varier la valeur de SUCCESS_RATE , qui contrÎle la probabilité d'une true réponse pour le service.


Fichier Docker-compose.yaml:


 version: '3' services: service-auth: build: service-auth image: service-auth:1.0.0 environment: - SUCCESS_RATE=1.0 - TIMEOUT=100 ports: - "8081:8080" service-card: build: service-card image: service-card:1.0.0 environment: - TIMEOUT=100 ports: - "8082:8080" service-payment: build: service-payment image: service-payment:1.0.0 environment: - TIMEOUT=100 ports: - "8083:8080" service-user: build: service-user image: service-user:1.0.0 environment: - TIMEOUT=100 ports: - "8084:8080" 

Pour tous les services, la redirection de port de 8081 à 8084 est effectuée pour les atteindre directement directement.


Passons maintenant à l'écriture du Demo service . Tout d'abord, essayons d'écrire l'implémentation aussi maladroite que possible, sans asynchronie et simultanéité. Pour ce faire, prenez Spring boot 2.2.1, Kotlin et un blanc pour le service. Nous clonons le référentiel et passons à la branche spring-mvc-start :


 git clone https://github.com/evgzakharov/demo-service && cd demo-service && git checkout spring-mvc-start 

AccĂ©dez au fichier demo.Controller . Il possĂšde la seule mĂ©thode processRequest vide pour laquelle une implĂ©mentation doit ĂȘtre Ă©crite.


  @PostMapping fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response { .. } 

Une demande de virement entre cartes sera reçue à l'entrée de la méthode.


 data class ServiceRequest( val authToken: String, val cardFrom: String, val cardTo: String, val amount: BigDecimal ) 

Pour ceux qui ne connaissent pas le printemps

Spring a une DI intégrée qui fonctionne sur la base d'annotations. Le DemoController est marqué avec l'annotation spéciale RestController : en plus d'enregistrer le bean dans le DI, il ajoute également son traitement en tant que contrÎleur. PostProcessor recherche toutes les méthodes marquées de l'annotation PostMapping et les ajoute en tant que noeud final pour le service avec la méthode POST .


Le gestionnaire crée également une classe proxy pour DemoController, dans laquelle tous les arguments nécessaires sont passés à la méthode processRequest . Dans notre cas, ce n'est qu'un argument, marqué avec l'annotation @RequestBody . Par conséquent, dans le proxy, cette méthode sera appelée avec le contenu JSON désérialisé dans la classe ServiceRequest .


Pour vous faciliter la tĂąche, toutes les mĂ©thodes d'intĂ©gration avec d'autres services ont dĂ©jĂ  Ă©tĂ© rĂ©alisĂ©es, il vous suffit de les connecter correctement. Il n'y a que cinq mĂ©thodes, une pour chaque action. Les appels vers d'autres services eux-mĂȘmes sont implĂ©mentĂ©s sur l'appel de blocage Spring RestTemplate .


Exemple de méthode pour appeler AUTH :


 private fun getAuthInfo(token: String): AuthInfo { log.info("getAuthInfo") return restTemplate.getForEntity("${demoConfig.auth}/{token}", AuthInfo::class.java, token) .body ?: throw RuntimeException("couldn't find user by token='$token'") } 

Passons à l'implémentation de la méthode. Les commentaires indiquent la procédure et la réponse attendue en sortie:


  @PostMapping fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response { //1) get auth info from service by token -> userId //2) find user info by userId from 1. //3) 4) find cards info for each card in serviceRequest // 5) make transaction for known cards by calling sendMoney(id1, id2, amount) // 6) after payment get payment info by fromCardId TODO("return SuccessResponse") // SuccessResponse( // amount = , // userName = , // userSurname = , // userAge = // ) } 

Tout d'abord, nous mettons en Ɠuvre la mĂ©thode le plus simplement possible, sans tenir compte du fait AUTH peut nous refuser l'accĂšs Ă  d'autres services. Essayez de le faire vous-mĂȘme. Lorsqu'il s'avĂšre (ou aprĂšs le passage Ă  la branche spring-mvc ), vous pouvez vĂ©rifier le fonctionnement du service comme suit:


implémentation à partir de la branche spring-mvc
 fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response { val authInfo = getAuthInfo(serviceRequest.authToken) val userInfo = findUser(authInfo.userId) val cardFromInfo = findCardInfo(serviceRequest.cardFrom) val cardToInfo = findCardInfo(serviceRequest.cardTo) sendMoney(cardFromInfo.cardId, cardToInfo.cardId, serviceRequest.amount) val paymentInfo = getPaymentInfo(cardFromInfo.cardId) return SuccessResponse( amount = paymentInfo.currentAmount, userName = userInfo.name, userSurname = userInfo.surname, userAge = userInfo.age ) } 

Démarrez le service (à partir du dossier du service de démonstration):


 ./gradlew bootRun 

Nous envoyons une demande au point final:


 ./demo-request.sh 

En réponse, nous obtenons quelque chose comme ceci:


 ➜ demo-service git:(spring-mvc) ✗ ./demo-request.sh + curl -XPOST http://localhost:8080/ -d @demo-payment-request.json -H 'Content-Type: application/json; charset=UTF-8' + jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 182 0 85 100 97 20 23 0:00:04 0:00:04 --:--:-- 23 { "amount": 989.9, "userName": "Vasia", "userSurname": "Pupkin", "userAge": 18, "status": true } 

Au total, vous devez effectuer 6 demandes pour mettre en Ɠuvre le service. Et Ă©tant donnĂ© que chacun d'eux rĂ©pond avec un retard de 100 ms, le temps total ne peut pas ĂȘtre infĂ©rieur Ă  600 ms. En rĂ©alitĂ©, il s'avĂšre environ 700 ms, en tenant compte de tous les frais gĂ©nĂ©raux. Jusqu'Ă  prĂ©sent, le code est assez simple, et si nous voulons maintenant ajouter une vĂ©rification de rĂ©ponse AUTH pour accĂ©der Ă  d'autres services, cela ne sera pas difficile Ă  faire (comme tout autre refactoring).


Mais rĂ©flĂ©chissons Ă  la façon dont vous pouvez accĂ©lĂ©rer l'exĂ©cution des requĂȘtes. Si vous ne prenez pas en compte la vĂ©rification de la rĂ©ponse de AUTH , alors nous avons 2 tĂąches indĂ©pendantes:


  • obtenir l' userId et demander des donnĂ©es Ă  l' USER ;
  • recevoir cardId pour chaque carte, effectuer un paiement et recevoir le montant total.

Ces tĂąches peuvent ĂȘtre effectuĂ©es indĂ©pendamment les unes des autres. Ensuite, le temps d'exĂ©cution total dĂ©pendra de la plus longue chaĂźne d'appels (dans ce cas, le second) et sera exĂ©cutĂ© au total pendant 300 ms + X ms de surcharge.


Étant donnĂ© que les appels eux-mĂȘmes bloquent, la seule façon d'exĂ©cuter des requĂȘtes parallĂšles est de les exĂ©cuter sur des threads sĂ©parĂ©s. Vous pouvez crĂ©er un thread sĂ©parĂ© pour chaque appel, mais cela coĂ»tera trĂšs cher. Une autre façon consiste Ă  exĂ©cuter des tĂąches sur ThreadPool. À premiĂšre vue, une telle solution semble appropriĂ©e et le temps va vraiment diminuer. Par exemple, nous pouvons exĂ©cuter des requĂȘtes sur CompletableFuture. Il vous permet d'exĂ©cuter des tĂąches en arriĂšre-plan en appelant des mĂ©thodes avec le suffixe async . Et si vous ne spĂ©cifiez pas de ThreadPool spĂ©cifique lors de l'appel de mĂ©thodes, les tĂąches seront lancĂ©es sur ForkJoinPool.commonPool() . Essayez d'Ă©crire une implĂ©mentation vous-mĂȘme ou accĂ©dez Ă  la branche spring-mvc-async .


Implémentation à partir de la branche spring-mvc-async
 fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response { val authInfoFuture = CompletableFuture.supplyAsync { getAuthInfo(serviceRequest.authToken) } val userInfoFuture = authInfoFuture.thenApplyAsync { findUser(it.userId) } val cardFromInfo = CompletableFuture.supplyAsync { findCardInfo(serviceRequest.cardFrom) } val cardToInfo = CompletableFuture.supplyAsync { findCardInfo(serviceRequest.cardTo) } val waitAll = CompletableFuture.allOf(cardFromInfo, cardToInfo) val paymentInfoFuture = waitAll .thenApplyAsync { sendMoney(cardFromInfo.get().cardId, cardToInfo.get().cardId, serviceRequest.amount) } .thenApplyAsync { getPaymentInfo(cardFromInfo.get().cardId) } val paymentInfo = paymentInfoFuture.get() val userInfo = userInfoFuture.get() log.info("result") return SuccessResponse( amount = paymentInfo.currentAmount, userName = userInfo.name, userSurname = userInfo.surname, userAge = userInfo.age ) } 

Si nous mesurons maintenant le temps de requĂȘte, il sera de l'ordre de 360 ​​ms. Par rapport Ă  la version originale, le temps total a diminuĂ© de prĂšs de 2 fois. Le code lui-mĂȘme est devenu un peu plus compliquĂ©, mais jusqu'Ă  prĂ©sent, il n'est toujours pas difficile de le modifier. Et si ici nous voulons ajouter un contrĂŽle de rĂ©ponse d' AUTH , alors ce n'est pas difficile.


Mais que faire si nous avons un grand nombre de demandes entrantes pour le service lui-mĂȘme? Dites environ 1000 demandes simultanĂ©es? Avec cette approche, il s'avĂšre assez rapidement que tous les threads ThreadPool sont occupĂ©s Ă  effectuer des appels de blocage. Et nous arrivons Ă  la conclusion que la version actuelle ne convient pas non plus.


Il ne reste plus qu'Ă  faire quelque chose avec les appels de service eux-mĂȘmes. Vous pouvez modifier les requĂȘtes et les rendre non bloquantes. Ensuite, les mĂ©thodes d'appel des services renverront CompletableFuture, Flux, Observable, Deferred, Promise ou un objet similaire sur lequel construire une chaĂźne d'attentes. Avec cette approche, nous n'avons pas besoin de faire des appels sur des flux sĂ©parĂ©s - il suffira d'en avoir un (ou au moins un petit pool sĂ©parĂ© de flux) que nous avons dĂ©jĂ  empruntĂ© pour traiter les demandes.


Pouvons-nous maintenant supporter la lourde charge du service? Pour rĂ©pondre Ă  cette question, examinez de prĂšs Tomcat, qui est utilisĂ© dans Spring boot 2.2.1 dans le starter org.springframework.boot:spring-boot-starter-web . Il est construit de sorte qu'un thread de ThreadPool est allouĂ© pour chaque demande entrante pour son traitement. Et en l'absence de flux libres, les nouvelles demandes deviendront une «file d'attente» d'attente. Mais notre service lui-mĂȘme envoie uniquement des demandes Ă  d'autres services. Allouer un flux entier en dessous et le bloquer jusqu'Ă  ce que les rĂ©ponses de tout le monde arrivent, semble, pour le dire doucement, superflu.


Heureusement, Spring a récemment permis d'utiliser un serveur Web non bloquant basé sur Netty ou Undertow. Pour ce faire, il vous suffit de remplacer le spring-boot-starter-web spring-boot-starter-webflux et de modifier légÚrement la méthode de traitement des demandes dans laquelle la demande et la réponse seront "encapsulées" en Mono. Cela est dû au fait que Webflux est construit sur la base de Reactor, et donc maintenant dans la méthode dont vous avez besoin pour construire une chaßne de transformations mono.

Essayez d'Ă©crire votre propre implĂ©mentation non bloquante de la mĂ©thode. Pour ce faire, accĂ©dez Ă  la branche spring-webflux-start . Veuillez noter que le dĂ©marreur de Spring Boot a changĂ©, oĂč la version avec Webflux est maintenant utilisĂ©e, et l'implĂ©mentation des demandes Ă  d'autres services qui ont Ă©tĂ© réécrites pour utiliser WebClient non bloquant a Ă©galement changĂ©.


Exemple de méthode pour appeler AUTH:


 private fun getAuthInfo(token: String): Mono<AuthInfo> { log.info("getAuthInfo") return WebClient.create().get() .uri("${demoConfig.auth}/$token") .retrieve() .bodyToMono(AuthInfo::class.java) } 

L'implĂ©mentation du premier exemple est insĂ©rĂ©e dans le contenu de la mĂ©thode processRequest dans un commentaire. Essayez de le réécrire vous-mĂȘme sur Reactor. Comme la derniĂšre fois, faites d'abord la version sans prendre en compte les chĂšques d' AUTH , puis voyez comme il est difficile de les ajouter:


 fun processRequest(@RequestBody serviceRequest: Mono<ServiceRequest>): Mono<Response> { // val authInfo = getAuthInfo(serviceRequest.authToken) // // val userInfo = findUser(authInfo.userId) // // val cardFromInfo = findCardInfo(serviceRequest.cardFrom) // val cardToInfo = findCardInfo(serviceRequest.cardTo) // // sendMoney(cardFromInfo.cardId, cardToInfo.cardId, serviceRequest.amount) // // val paymentInfo = getPaymentInfo(cardFromInfo.cardId) // // log.info("result") // // return SuccessResponse( // amount = paymentInfo.currentAmount, // userName = userInfo.name, // userSurname = userInfo.surname, // userAge = userInfo.age // ) TODO() } 

AprÚs avoir traité cela, vous pouvez comparer avec mon implémentation de la spring-webflux :


Implémentation à partir de la branche spring-webflux
 fun processRequest(@RequestBody serviceRequest: Mono<ServiceRequest>): Mono<Response> { val cacheRequest = serviceRequest.cache() val userInfoMono = cacheRequest.flatMap { getAuthInfo(it.authToken) }.flatMap { findUser(it.userId) } val cardFromInfoMono = cacheRequest.flatMap { findCardInfo(it.cardFrom) } val cardToInfoMono = cacheRequest.flatMap { findCardInfo(it.cardTo) } val paymentInfoMono = cardFromInfoMono.zipWith(cardToInfoMono) .flatMap { (cardFromInfo, cardToInfo) -> cacheRequest.flatMap { request -> sendMoney(cardFromInfo.cardId, cardToInfo.cardId, request.amount).map { cardFromInfo } } }.flatMap { getPaymentInfo(it.cardId) } return userInfoMono.zipWith(paymentInfoMono) .map { (userInfo, paymentInfo) -> log.info("result") SuccessResponse( amount = paymentInfo.currentAmount, userName = userInfo.name, userSurname = userInfo.surname, userAge = userInfo.age ) } } 

Convenez que l'écriture d'une implémentation (par rapport à l'approche de blocage précédente) est devenue plus difficile. Et si nous voulons ajouter des chÚques «oubliés» d' AUTH , ce ne sera pas si facile à faire.


C'est l'essence mĂȘme de l'approche rĂ©active. Il est idĂ©al pour construire des chaĂźnes de transformation non ramifiĂ©es. Mais si la ramification apparaĂźt, le code n'est plus aussi simple.


Les coroutines Kotlin, qui sont trĂšs amicales avec n'importe quel code asynchrone / rĂ©actif, peuvent vous aider ici. De plus, il existe un grand nombre de wrappers Ă©crits pour Reactor , CompletableFuture , etc. Mais mĂȘme si vous ne trouvez pas le bon, vous pouvez toujours l'Ă©crire vous-mĂȘme, en utilisant des constructeurs spĂ©ciaux.


Réécrivons nous-mĂȘmes l'implĂ©mentation sur les coroutines. Pour ce faire, accĂ©dez Ă  la branche spring-webflux-coroutines-start . Les dĂ©pendances nĂ©cessaires y sont ajoutĂ©es dans build.gradle.kts:


 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$kotlinCoroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$kotlinCoroutinesVersion") 

Et la méthode processRequest change un processRequest :


 suspend fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response = coroutineScope { //TODO() } 

Il n'a plus besoin de Mono et se traduit simplement en une fonction de suspension (grĂące Ă  l'intĂ©gration de Spring et Kotlin). Étant donnĂ© que nous allons crĂ©er des coroutines supplĂ©mentaires dans la mĂ©thode, nous devrons crĂ©er un coroutineScope scout enfant (pour comprendre les raisons de la crĂ©ation d'une Ă©tendue supplĂ©mentaire, voir le post de Roman Elizarov sur la concurrence structurĂ©e ). Veuillez noter que les autres appels de service n'ont pas changĂ© du tout. Ils renvoient le mĂȘme Mono sur lequel la mĂ©thode de suspend waitFirst peut ĂȘtre appelĂ©e pour «attendre» le rĂ©sultat de la requĂȘte.


Si les coroutines sont encore un nouveau concept pour vous, alors il y a un merveilleux guide avec une description détaillée. Essayez d'écrire votre propre implémentation de la méthode processRequest ou accédez à la branche spring-webflux-coroutines :


implémentation à partir de la branche spring-webflux-coroutines
 suspend fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response = coroutineScope { log.info("start") val userInfoDeferred = async { val authInfo = getAuthInfo(serviceRequest.authToken).awaitFirst() findUser(authInfo.userId).awaitFirst() } val paymentInfoDeferred = async { val cardFromInfoDeferred = async { findCardInfo(serviceRequest.cardFrom).awaitFirst() } val cardToInfoDeferred = async { findCardInfo(serviceRequest.cardTo).awaitFirst() } val cardFromInfo = cardFromInfoDeferred.await() sendMoney(cardFromInfo.cardId, cardToInfoDeferred.await().cardId, serviceRequest.amount).awaitFirst() getPaymentInfo(cardFromInfo.cardId).awaitFirst() } val userInfo = userInfoDeferred.await() val paymentInfo = paymentInfoDeferred.await() log.info("result") SuccessResponse( amount = paymentInfo.currentAmount, userName = userInfo.name, userSurname = userInfo.surname, userAge = userInfo.age ) } 

Vous pouvez comparer le code avec l'approche réactive. Avec les coroutines, vous n'avez pas besoin de réfléchir à l'avance à tous les points de branchement. Nous pouvons simplement appeler les méthodes d' await et dériver les tùches asynchrones dans async aux bons endroits. Le code reste aussi similaire que possible à la version originale simple, qui n'est pas du tout difficile à modifier. Et un facteur important est que les coroutines sont simplement intégrées dans du code réactif.


Vous aimerez peut-ĂȘtre mĂȘme davantage l'approche rĂ©active pour cette tĂąche, mais la plupart des personnes interrogĂ©es la trouvent plus difficile. En gĂ©nĂ©ral, les deux approches rĂ©solvent leur problĂšme et vous pouvez utiliser celle que vous aimez. Soit dit en passant, rĂ©cemment Ă  Kotlin, il est Ă©galement possible de crĂ©er des coroutines «froides» avec Flow, qui sont trĂšs similaires Ă  Reactor. Certes, ils sont encore au stade expĂ©rimental, mais maintenant vous pouvez regarder l'implĂ©mentation actuelle et l'essayer dans votre code.


Je veux terminer ici et enfin laisser des liens utiles:



J'espĂšre que vous Ă©tiez intĂ©ressĂ© et que vous avez rĂ©ussi Ă  Ă©crire vous-mĂȘme une implĂ©mentation de la mĂ©thode pour toutes les mĂ©thodes. Et, bien sĂ»r, je veux croire que vous aimez plus l'option avec les coroutines =)


Merci Ă  tous ceux qui ont lu jusqu'au bout!

Source: https://habr.com/ru/post/fr477052/


All Articles