
Muitos serviços no mundo moderno, na maioria das vezes, "não fazem nada". Suas tarefas são reduzidas a solicitações de outros bancos de dados / serviços / caches e agregação de todos esses dados de acordo com várias regras e lógica de negócios. Portanto, não é de surpreender que idiomas como o Golang apareçam, com um sistema competitivo embutido conveniente que facilita a organização de códigos sem bloqueio.
No mundo da JVM, as coisas são um pouco mais complicadas. Há um grande número de estruturas e bibliotecas que bloqueiam threads quando usadas. Portanto, o próprio stdlib pode fazer a mesma coisa às vezes. E em Java não há mecanismo semelhante às goroutines em Golang.
No entanto, a JVM está se desenvolvendo ativamente e novas oportunidades interessantes aparecem. Existe o Kotlin com corotinas, que em seu uso são muito semelhantes às goroutinas Gorang (embora elas sejam implementadas de uma maneira completamente diferente). Existe o JEP Loom, que trará fibras para a JVM no futuro. Uma das estruturas da Web mais populares - Spring - recentemente adicionou a capacidade de criar serviços completamente sem bloqueio no Webflux. E com o recente lançamento do Spring boot 2.2, a integração com o Kotlin é ainda melhor.
Proponho, usando o exemplo de um pequeno serviço para transferir dinheiro de um cartão para outro, escrever um aplicativo no Spring boot 2.2 e no Kotlin para integração com vários serviços externos.
É bom que você já esteja familiarizado com Java, Kotlin, Gradle, Spring, Spring boot 2, Reator, fluxo da Web , Tomcat, Netty, Kotlin ororines, Gradle Kotlin DSL ou mesmo tenha um doutorado. Mas se não, isso não importa. O código será simplificado ao máximo e, mesmo que você não seja do mundo da JVM, espero que tudo fique claro para você.
Se você planeja escrever um serviço, verifique se tudo o que você precisa está instalado:
- Java 8+
- Docker e Docker Compose;
- cURL e preferencialmente jq ;
- Git
- de preferência um IDE para Kotlin (Intellij Idea, Eclipse, VS,
vim etc.). Mas é possível em um notebook.
Os exemplos conterão os dois espaços em branco para a implementação no serviço e uma implementação já escrita. Primeiro, execute a instalação e montagem e observe mais de perto os serviços e suas APIs.
O exemplo de serviços e a própria API são feitos apenas para fins ilustrativos; não transfira tudo AS IS
para o seu produto!
Primeiro, clonamos o repositório com serviços para nós mesmos, a integração com a qual faremos isso e vamos para o diretório:
git clone https://github.com/evgzakharov/spring-demo-services && cd spring-demo-services
Em um terminal separado, coletamos todos os aplicativos usando gradle
, onde, após uma compilação bem-sucedida, todos os serviços serão lançados usando o docker-compose
.
./gradlew build && docker-compose up
Enquanto tudo é baixado e instalado, considere um projeto com serviços.

Uma solicitação com um token, número de cartão para transferência e a quantia a ser transferida entre cartões será recebida na entrada do serviço (serviço de demonstração):
{ "authToken": "auth-token1", "cardFrom": "55593478", "cardTo": "55592020", "amount": "10.1" }
De acordo com o token authToken
, authToken
precisa acessar o serviço AUTH
e obter userId
, com o qual pode fazer uma solicitação ao USER
e extrair todas as informações adicionais sobre o usuário. AUTH
também retornará informações sobre quais dos três serviços podemos acessar. Resposta de amostra de AUTH
:
{ "userId": 158, "cardAccess": true, "paymentAccess": true, "userAccess": true }
Para transferir entre cartões, vá primeiro com cada número de cartão no CARD
. Em resposta a solicitações, receberemos cardId
; em seguida, enviaremos uma solicitação para PAYMENT
e faremos uma transferência. E a última - mais uma vez, enviamos uma solicitação para PAYMENT
com fromCardId
e descobrimos o saldo atual.
Para emular um pequeno atraso nos serviços, o valor da variável de ambiente TIMEOUT é lançado em todos os contêineres, nos quais o atraso da resposta é definido em milissegundos. E para diversificar as respostas do AUTH
, é possível variar o valor de SUCCESS_RATE
, que controla a probabilidade de uma resposta true
para o serviço.
Arquivo 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"
Para todos os serviços, o encaminhamento de porta de 8081 a 8084 é feito para alcançá-los facilmente.
Vamos passar a escrever o Demo service
. Primeiro, vamos tentar escrever a implementação o mais desastrada possível, sem assincronia e simultaneidade. Para fazer isso, use o Spring boot 2.2.1, Kotlin e um espaço em branco para o serviço. Clonamos o repositório e vamos para o ramo spring-mvc-start
:
git clone https://github.com/evgzakharov/demo-service && cd demo-service && git checkout spring-mvc-start
Vá para o arquivo demo.Controller
. Ele tem o único método processRequest
vazio para o qual uma implementação deve ser gravada.
@PostMapping fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response { .. }
Um pedido de transferência entre cartões será recebido na entrada do método.
data class ServiceRequest( val authToken: String, val cardFrom: String, val cardTo: String, val amount: BigDecimal )
Para quem não conhece o SpringO Spring possui um DI interno que funciona com base em anotações. O DemoController é marcado com a anotação RestController
especial: além de registrar o bean no DI, ele também adiciona seu processamento como um controlador. O PostProcessor localiza todos os métodos marcados com a anotação PostMapping
e os adiciona como um ponto de extremidade para o serviço com o método POST
.
O manipulador também cria uma classe de proxy para o DemoController, na qual todos os argumentos necessários são passados para o método processRequest
. No nosso caso, esse é apenas um argumento, marcado com a anotação @RequestBody
. Portanto, no proxy, esse método será chamado com o conteúdo JSON desserializado na classe ServiceRequest
.
Para facilitar, todos os métodos de integração com outros serviços já foram criados, basta conectá-los corretamente. Existem apenas cinco métodos, um para cada ação. As chamadas para outros serviços são implementadas na chamada de bloqueio do Spring RestTemplate
.
Exemplo de método para chamar 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'") }
Vamos seguir para a implementação do método. Os comentários indicam o procedimento e qual resposta é esperada na saída:
@PostMapping fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response {
Primeiro, implementamos o método da maneira mais simples possível, sem levar em conta que o AUTH
pode nos negar acesso a outros serviços. Tente fazer você mesmo. Quando ocorre (ou após alternar para a ramificação spring-mvc
), você pode verificar a operação do serviço da seguinte maneira:
implementação do ramo 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 ) }
Inicie o serviço (na pasta de serviço de demonstração):
./gradlew bootRun
Enviamos uma solicitação para o endpoint:
./demo-request.sh
Em resposta, temos algo parecido com isto:
➜ 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 }
No total, você precisa fazer 6 solicitações para implementar o serviço. E, dado que cada um deles responde com um atraso de 100 ms, o tempo total não pode ser inferior a 600 ms. Na realidade, são cerca de 700 ms, levando em consideração toda a sobrecarga. Até agora, o código é bastante simples e, se agora queremos adicionar uma verificação de resposta AUTH
para acessar outros serviços, isso não será difícil de fazer (como qualquer outra refatoração).
Mas vamos pensar em como você pode acelerar a execução da consulta. Se você não levar em consideração a verificação da resposta do AUTH
, teremos 2 tarefas independentes:
- obtendo
userId
e solicitando dados do USER
; - receber
cardId
para cada cartão, efetuar um pagamento e receber o valor total.
Essas tarefas podem ser executadas independentemente uma da outra. O tempo total de execução dependerá da cadeia de chamadas mais longa (neste caso, a segunda) e será executado no total por 300 ms + X ms de sobrecarga.
Dado que as chamadas em si estão bloqueando, a única maneira de executar solicitações paralelas é executá-las em threads separados. Você pode criar um segmento separado para cada chamada, mas será muito caro. Outra maneira é executar tarefas no ThreadPool. À primeira vista, essa solução parece apropriada e o tempo realmente diminui. Por exemplo, podemos executar consultas no CompletableFuture. Ele permite executar tarefas em segundo plano chamando métodos com o postfix async
. E se você não especificar um ThreadPool específico ao chamar métodos, as tarefas serão iniciadas em ForkJoinPool.commonPool()
. Tente escrever uma implementação você mesmo ou vá para o ramo spring-mvc-async
.
Implementação a partir da ramificação 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 ) }
Se agora medirmos o tempo de solicitação, ele estará na região de 360 ms. Comparado com a versão original, o tempo total diminuiu quase 2 vezes. O código em si se tornou um pouco mais complicado, mas até agora ainda não é difícil modificá-lo. E se aqui queremos adicionar uma verificação de resposta do AUTH
, isso não é difícil.
Mas e se tivermos um grande número de solicitações de entrada para o próprio serviço? Diga cerca de 1000 solicitações simultâneas? Com essa abordagem, verifica-se rapidamente que todos os threads do ThreadPool estão ocupados fazendo chamadas de bloqueio. E chegamos à conclusão de que a versão atual também não é adequada.
Resta apenas fazer algo com o serviço chama a si mesmos. Você pode modificar as consultas e torná-las sem bloqueio. Em seguida, os métodos para chamar os serviços retornarão CompletableFuture, Flux, Observable, Adiado, Promise ou um objeto semelhante no qual criar uma cadeia de expectativas. Com essa abordagem, não precisamos fazer chamadas em fluxos separados - será suficiente ter um (ou pelo menos um pequeno conjunto separado de fluxos) que já emprestamos para processar solicitações.
Podemos agora suportar a carga pesada no serviço? Para responder a essa pergunta, observe atentamente o Tomcat, usado no Spring boot 2.2.1 no starter org.springframework.boot:spring-boot-starter-web
. Ele é criado para que um thread do ThreadPool seja alocado para cada solicitação de entrada para seu processamento. E, na ausência de fluxos livres, novos pedidos se tornarão uma "fila" de espera. Mas nosso serviço em si envia apenas solicitações para outros serviços. Alocar um fluxo inteiro sob ele e bloqueá-lo até que todas as respostas cheguem, parece, para dizer o mínimo, supérfluas.
Felizmente, o Spring recentemente tornou possível o uso de um servidor da web sem bloqueio baseado no Netty ou no Undertow. Para fazer isso, você só precisa alterar o spring-boot-starter-web
para spring-boot-starter-webflux
spring-boot-starter-web
spring-boot-starter-webflux
e alterar ligeiramente o método para processar solicitações nas quais a solicitação e a resposta serão "agrupadas" no Mono. Isso se deve ao fato de o Webflux ser construído com base no Reator e, portanto, agora no método você precisa criar uma cadeia de transformações Mono.
Tente escrever sua própria implementação sem bloqueio do método. Para fazer isso, vá para o ramo spring-webflux-start
. Observe que o iniciador do Spring Boot foi alterado, onde a versão com o Webflux agora é usada, e a implementação de solicitações para outros serviços que foram reescritos para usar o WebClient
sem bloqueio também foi alterada.
Exemplo de método para chamar AUTH:
private fun getAuthInfo(token: String): Mono<AuthInfo> { log.info("getAuthInfo") return WebClient.create().get() .uri("${demoConfig.auth}/$token") .retrieve() .bodyToMono(AuthInfo::class.java) }
A implementação do primeiro exemplo é inserida no conteúdo do método processRequest
em um comentário. Tente reescrever você mesmo no Reactor. Como da última vez, primeiro faça a versão sem levar em conta as verificações do AUTH
e depois veja como é difícil adicioná-las:
fun processRequest(@RequestBody serviceRequest: Mono<ServiceRequest>): Mono<Response> {
Depois de lidar com isso, você pode comparar com minha implementação no spring-webflux
:
Implementação a partir do ramo 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 ) } }
Concorde que agora escrever uma implementação (em comparação com a abordagem de bloqueio anterior) se tornou mais difícil. E se queremos adicionar verificações "esquecidas" do AUTH
, isso não será tão fácil de fazer.
Essa é a essência da abordagem reativa. É ótimo para a construção de cadeias de processamento não ramificadas. Mas se a ramificação aparecer, o código não será mais tão simples.
As corotinas da Kotlin, que são muito amigáveis com qualquer código assíncrono / reativo, podem ajudar aqui. Além disso, há um grande número de invólucros por escrito para o Reator , o CompletableFuture etc. Mas mesmo que você não encontre o caminho certo, sempre poderá escrevê-lo, usando construtores especiais.
Vamos reescrever a implementação em corotinas. Para fazer isso, vá para a spring-webflux-coroutines-start
. As dependências necessárias são adicionadas a ele em 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")
E o método processRequest
muda um processRequest
:
suspend fun processRequest(@RequestBody serviceRequest: ServiceRequest): Response = coroutineScope {
Ele não precisa mais do Mono e se traduz simplesmente em uma função de suspensão (graças à integração do Spring e Kotlin). Considerando que criaremos coroutines adicionais no método, precisaremos criar um escoteiro infantil coroutineScope
(para entender os motivos da criação de um escopo adicional, consulte a publicação de Roman Elizarov sobre simultaneidade estruturada ). Observe que outras chamadas de serviço não foram alteradas. Eles retornam o mesmo Mono no qual o método de suspend
waititFirst pode ser chamado para "aguardar" o resultado da consulta.
Se as corotinas ainda são um novo conceito para você, existe um guia maravilhoso com uma descrição detalhada. Tente escrever sua própria implementação do método processRequest
ou vá para o ramo spring-webflux-coroutines
:
implementação do ramo 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 ) }
Você pode comparar o código com a abordagem reativa. Com as corotinas, você não precisa pensar em todos os pontos de ramificação com antecedência. Podemos simplesmente chamar métodos de await
e ramificar tarefas assíncronas em async
nos lugares certos. O código permanece o mais semelhante possível à versão direta original, que não é nada difícil de mudar. E um fator importante é que as corotinas são simplesmente incorporadas ao código reativo.
Você pode até gostar mais da abordagem reativa para esta tarefa, mas muitas das pessoas pesquisadas acham mais difícil. Em geral, ambas as abordagens resolvem o problema e você pode usar a que mais gosta. A propósito, recentemente em Kotlin também há a oportunidade de criar corotinas "frias" com o Flow, que são muito semelhantes ao Reator. É verdade que eles ainda estão no estágio experimental, mas agora você pode examinar a implementação atual e experimentá-la no seu código.
Quero terminar aqui e finalmente deixar links úteis:
Espero que você tenha se interessado e tenha conseguido escrever uma implementação do método para todos os métodos. E, é claro, eu quero acreditar que você gosta da opção com corotinas mais =)
Obrigado a todos que leram até o fim!