Introdução ao núcleo do reator

Introdução ao núcleo do reator

1. Introdução

Reactor Core é uma biblioteca Java 8 que implementa o modelo de programação reativa. É construído em cima doReactive Streams Specification, um padrão para a construção de aplicações reativas.

Do fundo do desenvolvimento de Java não reativo, tornar-se reativo pode ser uma curva de aprendizado bastante acentuada. Isso se torna mais desafiador ao compará-lo com a API Java 8Stream, pois podem ser confundidos com as mesmas abstrações de alto nível.

Neste artigo, tentaremos desmistificar esse paradigma. Daremos pequenos passos no Reactor até que tenhamos construído uma imagem de como compor código reativo, estabelecendo a base para artigos mais avançados que virão em uma série posterior.

2. Especificação de fluxos reativos

Antes de examinarmos o Reator, devemos examinar a Especificação de Fluxos Reativos. É isso que o Reactor implementa e estabelece as bases para a biblioteca.

Essencialmente, Reactive Streams é uma especificação para processamento de stream assíncrono.

Em outras palavras, um sistema em que muitos eventos estão sendo produzidos e consumidos de forma assíncrona. Pense em um fluxo de milhares de atualizações de estoque por segundo entrando em um aplicativo financeiro e para que ele tenha que responder a essas atualizações em tempo hábil.

Um dos principais objetivos disso é resolver o problema da contrapressão. Se tivermos um produtor que está emitindo eventos para um consumidor mais rapidamente do que pode processá-los, eventualmente o consumidor ficará sobrecarregado com eventos, ficando sem recursos do sistema. Contrapressão significa que nosso consumidor deve poder informar ao produtor quantos dados enviar para evitar isso, e é isso que está descrito na especificação.

3. Dependências do Maven

Antes de começar, vamos adicionar nossas dependênciasMaven:


    io.projectreactor
    reactor-core
    3.0.5.RELEASE



    ch.qos.logback
    logback-classic
    1.1.3

Também estamos adicionandoLogback como uma dependência. Isso ocorre porque registraremos a saída do Reactor para entender melhor o fluxo de dados.

4. Produzindo um Fluxo de Dados

Para que um aplicativo seja reativo, a primeira coisa que ele deve poder fazer é produzir um fluxo de dados. Isso pode ser algo como o exemplo de atualização de ações que demos anteriormente. Sem esses dados, não teríamos nada a que reagir, e é por isso que este é um primeiro passo lógico. O Núcleo reativo nos fornece dois tipos de dados que nos permitem fazer isso.

4.1. Flux

A primeira maneira de fazer isso é com umFlux.. É um stream que pode emitir0..n elementos. Vamos tentar criar um simples:

Flux just = Flux.just("1", "2", "3");

Nesse caso, temos um fluxo estático de três elementos.

4.2. Mono

A segunda maneira de fazer isso é com umMono,, que é um fluxo de elementos0..1. Vamos tentar instanciar um:

Mono just = Mono.just("foo");

Ele se parece e se comporta quase exatamente da mesma forma queFlux, mas desta vez estamos limitados a não mais de um elemento.

4.3. Por que não apenas Flux?

Antes de experimentar mais, vale destacar porque temos esses dois tipos de dados.

Primeiramente, deve-se notar que tanto aFluxeMono são implementações da interface Reactive StreamsPublisher. Ambas as classes são compatíveis com a especificação, e poderíamos usar esta interface em seu lugar:

Publisher just = Mono.just("foo");

Mas, na verdade, conhecer essa cardinalidade é útil. Isso ocorre porque algumas operações só fazem sentido para um dos dois tipos e porque podem ser mais expressivas (imaginefindOne() em um repositório).

5. Inscrever-se em um stream

Agora que temos uma visão geral de alto nível de como produzir um fluxo de dados, precisamos assiná-lo para emitir os elementos.

5.1. Coletando Elementos

Vamos usar o métodosubscribe() para coletar todos os elementos em um fluxo:

List elements = new ArrayList<>();

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(elements::add);

assertThat(elements).containsExactly(1, 2, 3, 4);

Os dados não começarão a fluir até que façamos a assinatura. Observe que adicionamos alguns registros também, isso será útil quando olharmos o que está acontecendo nos bastidores.

5.2. O Fluxo dos Elementos

Com o registro em vigor, podemos usá-lo para visualizar como os dados estão fluindo através do nosso fluxo:

20:25:19.550 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | request(unbounded)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onComplete()

Primeiro de tudo, tudo está sendo executado no thread principal. Não vamos entrar em detalhes sobre isso, pois veremos mais detalhes sobre a simultaneidade posteriormente neste artigo. Isso simplifica as coisas, pois podemos lidar com tudo em ordem.

Agora vamos percorrer a sequência que registramos um por um:

  1. onSubscribe() - É chamado quando assinamos nosso stream

  2. request(unbounded) – Quando chamamossubscribe, nos bastidores estamos criando umSubscription. Esta assinatura solicita elementos do fluxo. Neste caso, o padrão éunbounded,, o que significa que solicita todos os elementos disponíveis

  3. onNext() – Isso é chamado em cada elemento

  4. onComplete() – É chamado por último, após receber o último elemento. Na verdade, há umonError() também, que seria chamado se houvesse uma exceção, mas, neste caso, não há

Este é o fluxo apresentado na interfaceSubscriber como parte da especificação de streams reativos e, na realidade, é o que foi instanciado nos bastidores em nossa chamada paraonSubscribe().. É um método útil, mas para melhor entender o que está acontecendo, vamos fornecer uma interfaceSubscriber diretamente:

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(new Subscriber() {
    @Override
    public void onSubscribe(Subscription s) {
      s.request(Long.MAX_VALUE);
    }

    @Override
    public void onNext(Integer integer) {
      elements.add(integer);
    }

    @Override
    public void onError(Throwable t) {}

    @Override
    public void onComplete() {}
});

Podemos ver que cada estágio possível no fluxo acima é mapeado para um método na implementação deSubscriber. Acontece queFlux nos forneceu um método auxiliar para reduzir esse detalhamento.

5.3. Comparação com Java 8Streams

Ainda pode parecer que temos algo sinônimo de Java 8Stream fazendo coleta:

List collected = Stream.of(1, 2, 3, 4)
  .collect(toList());

Só nós não.

A principal diferença é que o Reactive é um modelo push, enquanto o Java 8Streams é um modelo pull. In reactive approach. events are pushed to the subscribers as they come in.

A próxima coisa a notar é que um operador de terminalStreams é apenas isso, terminal, puxando todos os dados e retornando um resultado. Com o Reactive, poderíamos ter um fluxo infinito vindo de um recurso externo, com vários assinantes anexados e removidos ad hoc. Também podemos fazer coisas como combinar fluxos, fluxos do acelerador e aplicar contrapressão, que abordaremos a seguir.

6. Contrapressão

A próxima coisa que devemos considerar é a contrapressão. No nosso exemplo, o assinante está dizendo ao produtor para enviar todos os elementos de uma só vez. Isso pode acabar se tornando esmagador para o assinante, consumindo todos os seus recursos.

Backpressure is when a downstream can tell an upstream to send it fewer data in order to prevent it from being overwhelmed.

Podemos modificar nossa implementação deSubscriber para aplicar contrapressão. Vamos dizer ao upstream para enviar apenas dois elementos por vez usandorequest():

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(new Subscriber() {
    private Subscription s;
    int onNextAmount;

    @Override
    public void onSubscribe(Subscription s) {
        this.s = s;
        s.request(2);
    }

    @Override
    public void onNext(Integer integer) {
        elements.add(integer);
        onNextAmount++;
        if (onNextAmount % 2 == 0) {
            s.request(2);
        }
    }

    @Override
    public void onError(Throwable t) {}

    @Override
    public void onComplete() {}
});

Agora, se executarmos nosso código novamente, veremos querequest(2) é chamado, seguido por duas chamadasonNext() e, em seguida,request(2) novamente.

23:31:15.395 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
23:31:15.397 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.397 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onComplete()

Essencialmente, isso é contrapressão de tração reativa. Estamos solicitando que o upstream envie apenas uma certa quantidade de elementos e somente quando estivermos prontos. Se imaginarmos que estávamos sendo transmitidos tweets do twitter, caberia a montante decidir o que fazer. Se os tweets chegassem, mas não houvesse solicitações do downstream, o upstream poderia descartar itens, armazená-los em um buffer ou alguma outra estratégia.

7. Operando em um Stream

Também podemos executar operações nos dados em nosso fluxo, respondendo a eventos como acharmos melhor.

7.1. Mapeamento de dados em um fluxo

Uma operação simples que podemos executar é aplicar uma transformação. Neste caso, vamos apenas dobrar todos os números em nosso fluxo:

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .subscribe(elements::add);

map() será aplicado quandoonNext() for chamado.

7.2. Combinando Dois Streams

Podemos então tornar as coisas mais interessantes combinando outro fluxo com este. Vamos tentar isso usando a funçãozip():

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .zipWith(Flux.range(0, Integer.MAX_VALUE),
    (one, two) -> String.format("First Flux: %d, Second Flux: %d", one, two))
  .subscribe(elements::add);

assertThat(elements).containsExactly(
  "First Flux: 2, Second Flux: 0",
  "First Flux: 4, Second Flux: 1",
  "First Flux: 6, Second Flux: 2",
  "First Flux: 8, Second Flux: 3");

Aqui, estamos criando outroFlux que continua aumentando em um e fazendo streaming junto com o original. Podemos ver como eles funcionam juntos, inspecionando os logs:

20:04:38.064 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:04:38.065 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
20:04:38.066 [main] INFO  reactor.Flux.Range.2 - | onSubscribe([Synchronous Fuseable] FluxRange.RangeSubscription)
20:04:38.066 [main] INFO  reactor.Flux.Range.2 - | onNext(0)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(1)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(2)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(3)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onComplete()
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | cancel()
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | cancel()

Observe como agora temos uma assinatura porFlux. As chamadasonNext() também são alternadas, de modo que o índice de cada elemento no fluxo corresponderá quando aplicamos a funçãozip().

8. Hot Streams

Atualmente, nos concentramos principalmente em fluxos frios. São fluxos estáticos e de comprimento fixo, fáceis de lidar. Um caso de uso mais realista para reativo pode ser algo que acontece infinitamente.

Por exemplo, poderíamos ter um fluxo de movimentos do mouse que precisam ser reagidos constantemente ou um feed do twitter. Esses tipos de fluxos são chamados fluxos quentes, pois estão sempre em execução e podem ser assinados a qualquer momento, perdendo o início dos dados.

8.1. Criando umConnectableFlux

Uma maneira de criar um fluxo quente é convertendo um fluxo frio em um. Vamos criar umFlux que dure para sempre, enviando os resultados para o console, o que simularia um fluxo infinito de dados vindos de um recurso externo:

ConnectableFlux publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .publish();


Ao chamarpublish(), recebemos umConnectableFlux.. Isso significa que chamarsubscribe() não fará com que ele comece a emitir, o que nos permite adicionar várias assinaturas:

publish.subscribe(System.out::println);
publish.subscribe(System.out::println);

Se tentarmos executar esse código, nada acontecerá. Não é até chamarmosconnect(), queFlux vai começar a emitir. Não importa se estamos assinando ou não.

8.2. Limitação

Se executarmos nosso código, nosso console ficará sobrecarregado com o log. Isso simula uma situação em que muitos dados estão sendo transmitidos aos nossos consumidores. Vamos tentar contornar isso com a limitação:

ConnectableFlux publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .sample(ofSeconds(2))
  .publish();


Aqui, introduzimos um métodosample() com um intervalo de dois segundos. Agora, os valores serão enviados apenas ao nosso assinante a cada dois segundos, o que significa que o console será muito menos agitado.

Claro, existem várias estratégias para reduzir a quantidade de dados enviados downstream, como janelamento e armazenamento em buffer, mas elas serão deixadas de fora do escopo deste artigo.

9. Concorrência

Todos os exemplos acima foram executados no thread principal. No entanto, podemos controlar em qual thread nosso código será executado, se quisermos. A interfaceScheduler fornece uma abstração em torno do código assíncrono, para o qual muitas implementações são fornecidas para nós. Vamos tentar se inscrever em um tópico diferente para principal:

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .subscribeOn(Schedulers.parallel())
  .subscribe(elements::add);

O agendadorParallel fará com que nossa assinatura seja executada em um thread diferente, o que podemos provar olhando os logs:

20:03:27.505 [main] DEBUG reactor.util.Loggers$LoggerFactory - Using Slf4j logging framework
20:03:27.529 [parallel-1] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | request(unbounded)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(1)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(2)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(3)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(4)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onComplete()

A simultaneidade fica mais interessante do que isso, e valerá a pena explorá-la em outro artigo.

10. Conclusão

Neste artigo, fornecemos uma visão geral de alto nível de ponta a ponta do Reactive Core. Explicamos como podemos publicar e assinar fluxos, aplicar contrapressão, operar em fluxos e também lidar com dados de forma assíncrona. Esperançosamente, isso deve estabelecer uma base para escrevermos aplicativos reativos.

Os artigos posteriores desta série abordarão simultaneidade mais avançada e outros conceitos reativos. Há também outro artigo cobrindoReactor with Spring.

O código-fonte do nosso aplicativo está disponível emover on GitHub; este é um projeto Maven que deve ser executado como está.