Uso avançado de quasar para Kotlin

Uso avançado de quasar para Kotlin

1. Introdução

Werecently looked at Quasar, o que nos dá ferramentas para tornar a programação assíncrona mais acessível e mais eficiente. Vimos o básico do que podemos fazer com ele, permitindo conversas leves e passagem de mensagens.

Neste tutorial, veremos algumas coisas mais avançadas que podemos fazer com o Quasar para levar nossa programação assíncrona ainda mais longe.

2. Atores

Actors são uma prática de programação bem conhecida para programação simultânea, especialmente popular em Erlang. Quasar allows us to define Actors, which are the fundamental building blocks of this form of programming.

Os atores podem:

  • Iniciar outros atores

  • Envie mensagens para outros atores

  • Receba mensagens de outros atores aos quais eles reagem

Essas três peças de funcionalidade nos fornecem tudo o que precisamos para criar nosso aplicativo.

No Quasar,an actor is represented as a strand - normalmente uma fibra, mas threads também são uma opção se necessário - com um canal para receber mensagens e algum suporte especial para gerenciamento de ciclo de vida e tratamento de erros.

2.1. Adicionando Atores à Construção

Atores não são um conceito central no Quasar. Em vez disso,we need to add the dependency that gives us access to them:


    co.paralleluniverse
    quasar-actors
    0.8.0

É importante que usemos a mesma versão dessa dependência que qualquer outra dependência do Quasar em uso.

2.2. Criando Atores

We create an actor by subclassing the Actor class, fornecendo um nome e umMailboxConfige implementando o métododoRun():

val actor = object : Actor("noopActor", MailboxConfig(5, Channels.OverflowPolicy.THROW)) {
    @Suspendable
    override fun doRun(): String {
        return "Hello"
    }
}

Tanto o nome quanto a configuração da caixa de correio são opcionais - se não especificarmos uma configuração de caixa de correio, o padrão é uma caixa de correio ilimitada.

Note that we need to mark the methods in the actor as @Suspendable manually. Kotlin não exige que declaremos exceções, o que significa que não declaramos oSuspendException t que está na classe base que estamos estendendo. Isso significa que o Quasar não considera nossos métodos suspensos sem um pouco mais de ajuda.

Depois de criar um ator, precisamos iniciá-lo - usando o métodospawn() para iniciar uma nova fibra ouspawnThread() para iniciar um novo encadeamento. Além da diferença entre uma fibra e um fio, esses dois funcionam da mesma maneira.

Uma vez que criamos o ator, podemos tratá-lo da mesma maneira que qualquer outro fio. Isso inclui poder chamarjoin() para aguardar a conclusão da execução eget() para recuperar o valor dele:

actor.spawn()

println("Noop Actor: ${actor.get()}")

2.3. Enviando mensagens para atores

Quando geramos um novo ator, os métodosspawn()espawnThread() retornam uma instânciaActorRef. We can use this to interact with the actor itself, by sending messages for it to receive.

OActorRef implementa a interfaceSendPort e, como tal, podemos usá-lo da mesma forma que usaríamos para produzir a metade de umChannel. Isso nos dá acesso aos métodossendetrySend que podemos usar para passar mensagens para o ator:

val actorRef = actor.spawn()

actorRef.send(1)

2.4. Recebendo mensagens com atores

Agora que podemos passar mensagens para o ator, precisamos ser capazes de fazer as coisas com eles. We do this inside our doRun() method on the actor itself, where we can call the receive() method to get the next message to process:

val actor = object : Actor("simpleActor", null) {
    @Suspendable
    override fun doRun(): Void? {
        val msg = receive()
        println("SimpleActor Received Message: $msg")
        return null
    }
}

O métodoreceive() bloqueará dentro do ator até que uma mensagem esteja disponível e então permitirá que o ator processe essa mensagem conforme necessário.

Freqüentemente, os atores são projetados para receber muitas mensagens e processá-las todas. Como tal, os atores normalmente terão um loop infinito dentro do métododoRun() que processará todas as mensagens que chegam:

val actor = object : Actor("loopingActor", null) {
    @Suspendable
    override fun doRun(): Void? {
        while (true) {
            val msg = receive()

            if (msg > 0) {
                println("LoopingActor Received Message: $msg")
            } else {
                break
            }
        }

        return null
    }
}

Isso continuará processando as mensagens recebidas até recebermos o valor 0.

2.5. Envio de mensagens muito rápido

In some cases, the actor will process messages slower than they are sent to it. Isso fará com que a caixa de correio fique cheia e, potencialmente, fique cheia.

A diretiva de caixa de correio padrão tem uma capacidade ilimitada. Podemos configurar isso quando criamos o ator, no entanto, fornecendo umMailboxConfig. Quasar também oferece uma configuração de como reagir quando a caixa de correio estourar, mas no momento, isso não está implementado.

Em vez disso, o Quasar usará a política deTHROW, independentemente do que especificarmos:

Actor("backlogActor",
    MailboxConfig(1, Channels.OverflowPolicy.THROW)) {
}

Se especificarmos um tamanho de caixa de correio e ela estourar, o métodoreceive() dentro do ator fará com que o ator aborte lançando uma exceção.

Isso não é algo com que possamos lidar de forma alguma:

try {
    receive()
} catch (e: Throwable) {
    // This is never reached
}

Quando isso acontecer,the get() method from outside the actor will also throw an exception, but this can be handled. Neste caso, obteremos umExecutionException que envolve umQueueCapacityExceededException com um rastreamento de pilha apontando para o métodosend() que adicionou a mensagem de estouro.

Se soubermos que estamos trabalhando com um ator que tem um tamanho de caixa de correio limitado,we can use the trySend() method to send messages to it instead. This won’t cause the actor to fail, mas, em vez disso, informaremos se a mensagem foi enviada com sucesso ou não:

val actor = object : Actor("backlogTrySendActor",
  MailboxConfig(1, Channels.OverflowPolicy.THROW)) {
    @Suspendable
    override fun doRun(): String {
        TimeUnit.MILLISECONDS.sleep(500);
        println("Backlog TrySend Actor Received: ${receive()}")

        return "No Exception"
    }
}

val actorRef = actor.spawn()

actorRef.trySend(1) // Returns True
actorRef.trySend(2) // Returns False

2.6. Lendo mensagens muito rápido

Caso contrário,we might have an actor that is trying to read messages faster than they are being provided. Normalmente, isso é bom - o ator bloqueará até que uma mensagem esteja disponível e depois a processará.

Em algumas situações, porém, queremos ser capazes de lidar com isso de outras maneiras.

Quando se trata de receber mensagens, temos três opções disponíveis:

  • Bloquear indefinidamente até que uma mensagem esteja disponível

  • Bloquear até que uma mensagem esteja disponível ou até que ocorra um tempo limite

  • Não bloqueie de todo

Até agora, usamos o métodoreceive(), que bloqueia para sempre.

Se necessário,we can provide timeout details to the receive() method. Isso fará com que ele seja bloqueado apenas por aquele período antes de retornar - seja a mensagem recebida ounull se o tempo limite expirar:

while(true) {
    val msg = receive(1, TimeUnit.SECONDS)
    if (msg != null) {
        // Process Message
    } else {
        println("Still alive")
    }
}

Em raras ocasiões, podemos desejar não bloquear de forma alguma e, em vez disso, retornar imediatamente com uma mensagem ounull. Podemos fazer isso com o métodotryReceive() - como um espelho para o métodotrySend() que vimos acima:

while(true) {
    val msg = tryReceive()
    if (msg != null) {
        // Process Message
    } else {
        print(".")
    }
}

2.7. Filtrando mensagens

So far, our actors have received every single message that was sent to them. No entanto, podemos ajustar isso, se desejado.

Nosso métododoRun() foi projetado para representar a maior parte da funcionalidade do ator, e o métodoreceive() chamado a partir dele nos dará o próximo método com o qual trabalhar.

We can also override a method called filterMessage() that will determine if we should process any given message or not. O métodoreceive() chama isso para nós e, se retornarnull, a mensagem não é passada para o ator. Por exemplo, o seguinte filtrará todas as mensagens que são números ímpares:

override fun filterMessage(m: Any?): Int? {
    return when (m) {
        is Int -> {
            if (m % 2 == 0) {
                m
            } else {
                null
            }
        } else -> super.filterMessage(m)
    }
}

O métodofilterMessage() também é capaz de transformar as mensagens conforme elas chegam. O valor que retornamos é o valor fornecido ao ator, portanto, ele atua comofilteremap. A única restrição é que o tipo de retorno deve corresponder ao tipo de ator esperado da mensagem.

Por exemplo, o seguinte filtrará todos os números ímpares, mas multiplicará todos os números pares por 10:

override fun filterMessage(m: Any?): Int? {
    return when (m) {
        is Int -> {
            if (m % 2 == 0) {
                m * 10
            } else {
                null
            }
        }
        else -> super.filterMessage(m)
    }
}

2.8. Vinculando atores e tratamento de erros

Até agora, todos os nossos atores trabalharam estritamente isolados. We do have the ability to have actors that watch each other so that one can react to events in the other. Podemos fazer isso de maneira simétrica ou assimétrica, conforme desejado.

No momento, o único evento que podemos lidar é quando um ator sai - deliberadamente ou porque falhou por algum motivo.

Quando vinculamos os atores usando o métodowatch(), estamos permitindo que um ator - o observador - seja informado dos eventos do ciclo de vida do outro - o observado. Este é um caso estritamente unilateral, e o ator observado não é notificado de nada sobre o observador:

val watcherRef = watcher.spawn()
val watchedRef = watched.spawn()
watcher.watch(watchedRef)

Alternativamente, podemos usar o métodolink(), que é a versão simétrica. Nesse caso, os dois atores são informados dos eventos do ciclo de vida no outro, em vez de ter um observador e um ator assistido:

val firstRef = first.spawn()
val secondRef = second.spawn()
first.watch(secondRef)

Nos dois casos, o efeito é o mesmo. Any lifecycle events that occur in the watched actor will cause a special message — of type LifecycleMessage — to be added to the input channel of the watcher actor. Isso então é processado pelo métodofilterMessage() conforme descrito anteriormente.

A implementação padrão irá então passar isso para o métodohandleLifecycleMessage() em nosso ator, que pode então processar essas mensagens conforme necessário:

override fun handleLifecycleMessage(m: LifecycleMessage?): Int? {
    println("WatcherActor Received Lifecycle Message: ${m}")
    return super.handleLifecycleMessage(m)
}

Aqui, há uma diferença sutil entrelink()ewatch(). Comwatch(), o métodohandleLifecycleMessage() padrão não faz nada mais do que remover as referências do ouvinte, enquanto comlink(), ele também lança uma exceção que será recebida na mensagemdoRun() em resposta à chamadareceive().

Isso significa que usarlink() automaticamente faz com que nosso métododoRun() dos atores vejam uma exceção quando quaisquer atores vinculados saem, enquantowatch() nos força a implementar o métodohandleLifecycleMessage() a ser capaz de reagir à mensagem.

2.9. Registro e recuperação de atores

Até agora, só interagimos com os atores imediatamente depois de criá-los, portanto, fomos capazes de usar as variáveis ​​no escopo para interagir com eles. Sometimes, though, we need to be able to interact with actors a long way from where we spawned them.

Uma maneira de fazer isso é usando práticas de programação padrão - passar a variávelActorRef para que tenhamos acesso a ela de onde precisamos.

Quasar nos dá outra maneira de conseguir isso. Podemos registrar atores com umActorRegistry central e acessá-los pelo nome mais tarde:

val actorRef = actor.spawn()
actor.register()

val retrievedRef = ActorRegistry.getActor>("theActorName")

assertEquals(actorRef, retrievedRef)

Isso pressupõe que demos um nome ao ator quando o criamos e o registramos com esse nome. Se o ator não foi nomeado - por exemplo, se o primeiro argumento do construtor foinull - então podemos passar um nome para o métodoregister():

actor.register("renamedActor")

ActorRegistry.getActor() é estático para que possamos acessá-lo de qualquer lugar em nosso aplicativo.

If we try to retrieve an actor using a name that isn’t known, Quasar will block until such an actor does exist. Isso pode durar potencialmente para sempre, então também podemos dar um tempo limite quando estivermos recuperando o ator para evitar isso. Isso retornaránull no tempo limite, caso o ator solicitado não seja encontrado:

val retrievedRef = ActorRegistry.getActor>("unknownActor", 1, TimeUnit.SECONDS)

Assert.assertNull(retrievedRef)

3. Modelos de ator

Até agora, escrevemos nossos atores a partir dos primeiros princípios. No entanto, existem vários padrões comuns que são usados ​​repetidamente. Como tal, a Quasar empacotou esses pacotes de maneira que possamos reutilizá-los facilmente.

Esses modelos geralmente são chamados de comportamentos, emprestando a terminologia para o mesmo conceito usado em Erlang.

Many of these templates are implemented as subclasses of Actor and of ActorRef, which add additional features for us to use. Isso fornecerá métodos adicionais dentro da classeActor para substituir ou chamar de dentro de nossa funcionalidade implementada, e métodos adicionais na classeActorRef para o código de chamada interagir com o ator.

3.1. Request/Reply

A common use case for actors is that some calling code will send them a message, and then the actor will do some work and send some result back. O código de chamada então recebe a resposta e continua trabalhando com ela. O Quasar nos dáRequestReplyHelper para nos permitir alcançar ambos os lados facilmente.

Para usar isso, nossas mensagens devem ser todas subclasses da classeRequestMessage. Isso permite que o Quasar armazene informações adicionais para obter a resposta de volta ao código de chamada correto:

data class TestMessage(val input: Int) : RequestMessage()

Como código de chamada, podemos usarRequestReplyHelper.call() para enviar uma mensagem ao ator e, em seguida, obter a resposta ou uma exceção de volta conforme apropriado:

val result = RequestReplyHelper.call(actorRef, TestMessage(50))

Dentro do próprio ator, recebemos a mensagem, a processamos e usamosRequestReplyHelper.reply() para enviar o resultado de volta:

val actor = object : Actor() {
    @Suspendable
    override fun doRun(): Void {
        while (true) {
            val msg = receive()

            RequestReplyHelper.reply(msg, msg.input * 100)
        }

        return null
    }
}

3.2. Servidor

The ServerActor is an extension to the above where the request/reply capabilities are part of the actor itself. Isso nos dá a capacidade de fazer uma chamada síncrona para o ator e obter uma resposta dela - usando o métodocall() - ou fazer uma chamada assíncrona para o ator onde não precisamos uma resposta - usando o métodocast().

Implementamos essa forma de ator usando a classeServerActore passando uma instância deServerHandler para o construtor. Isso é genérico nos tipos de mensagem a serem manipulados para uma chamada síncrona, retornados de uma chamada síncrona e manipulados para uma chamada assíncrona.

Quando implementamos aServerHandler, temos vários métodos que precisamos implementar:

  • init - Lidar com o ator inicializando

  • terminate - Lidar com o desligamento do ator

  • handleCall - lida com uma chamada síncrona e retorna a resposta

  • handleCast - lida com uma chamada assíncrona

  • handleInfo - lida com uma mensagem que não éCall nemCast

  • handleTimeout - tratar quando não recebemos nenhuma mensagem por um período configurado

A maneira mais fácil de fazer isso é criar uma subclasseAbstractServerHandler, que tem implementações padrão de todos os métodos. Isso nos dá a capacidade de implementar apenas os bits necessários para o nosso caso de uso:

val actor = ServerActor(object : AbstractServerHandler() {
    @Suspendable
    override fun handleCall(from: ActorRef<*>?, id: Any?, m: Int?): String {
        println("Called with message: " + m + " from " + from)
        return m.toString() ?: "None"
    }

    @Suspendable
    override fun handleCast(from: ActorRef<*>?, id: Any?, m: Float?) {
        println("Cast message: " + m + " from " + from)
    }
})

Our handleCall() and handleCast() methods get called with the message to handle mas também recebem uma referência de onde veio a mensagem e um ID único para identificar a chamada, caso sejam importantes. Tanto a fonteActorRef a ID são opcionais e podem não estar presentes.

Gerar umServerActor nos retornará uma instânciaServer. Esta é uma subclasse deActorRef que nos fornece funcionalidade adicional paracall()ecast(), para enviar mensagens conforme apropriado e um método para desligar o servidor:

val server = actor.spawn()

val result = server.call(5)
server.cast(2.5f)

server.shutdown()

3.3. Servidor proxy

O padrãoServer nos dá uma maneira específica de lidar com mensagens e respostas fornecidas. An alternative to this is the ProxyServer, which has the same effect but in a more usable form. Isso usaJava dynamic proxies para nos permitir implementar interfaces Java padrão usando atores.

Para implementar esse padrão, precisamos definir uma interface que descreva nossa funcionalidade:

@Suspendable
interface Summer {
    fun sum(a: Int, b: Int) : Int
}

Pode ser qualquer interface Java padrão, com quaisquer funções que precisamos.

Em seguida, passamos uma instância disso para o construtorProxyServerActor para criar o ator:

val actor = ProxyServerActor(false, object : Summer {
    override fun sum(a: Int, b: Int): Int {
        return a + b
    }
})

val summerActor = actor.spawn()

O booleano também passado paraProxyServerActor é um sinalizador para indicar se deve usar a vertente do ator para métodosvoid ou não. Se definido comotrue, a vertente de chamada será bloqueada até que o método seja concluído, mas não haverá retorno dele.

O Quasar garantirá que executemos as chamadas de método dentro do ator, conforme necessário, e não no segmento de chamada. The instance returned from spawn() or spawnThread() implements both Server — as seen above — and our interface, thanks to the power of Java dynamic proxies:

// Calling the interface method
val result = (summerActor as Summer).sum(1, 2)

// Calling methods on Server
summerActor.shutdown()

Internamente, o Quasar implementa umProxyServerActor usando o comportamentoServer que vimos anteriormente, e podemos usá-lo da mesma maneira. O uso de proxies dinâmicos simplesmente facilita a obtenção de métodos de chamada.

3.4. Fontes de Eventos

The event source pattern allows us to create an actor where messages sent to it get handled by several event handlers. Esses manipuladores são adicionados e removidos conforme necessário. Isso segue o padrão que vimos várias vezes para manipular eventos assíncronos. A única diferença real aqui é que nossos manipuladores de eventos são executados no segmento de ator e não no segmento de chamada.

Criamos umEventSourceActor sem nenhum código especial e o iniciamos executando da maneira padrão:

val actor = EventSourceActor()
val eventSource = actor.spawn()

Depois que o ator for gerado, podemos registrar os manipuladores de eventos. O corpo desses manipuladores é então executado no fio do ator, mas eles são registrados fora dele:

eventSource.addHandler { msg ->
    println(msg)
}

Kotlin allows us to write our event handlers as lambda functions, e, portanto, usar todas as funcionalidades que temos aqui. Isso inclui acessar valores de fora da função lambda, mas eles serão acessados ​​pelas diferentes vertentes - portanto, precisamos ter cuidado ao fazer isso, como em qualquer cenário multiencadeado:

val name = "example"
eventSource.addHandler { msg ->
    println(name + " " + msg)
}

Também obtemos o maior benefício do código de manipulação de eventos, pois podemos registrar quantos manipuladores precisarmos sempre que precisarmos, cada um dos quais focado em sua única tarefa. Todos os manipuladores executam no mesmo segmento - aquele em que o ator executa -, portanto, os manipuladores precisam levar isso em consideração com o processamento que realizam.

Como tal, seria comum que esses manipuladores fizessem qualquer processamento pesado passando para outro ator.

3.5. Máquina de estados finitos

A finite-state machine is a standard construct where we have a fixed number of possible states, and where the processing of one state can switch to a different one. Podemos representar muitos algoritmos dessa maneira.

O Quasar nos permite modelar uma máquina de estado finito como ator, para que o próprio ator mantenha o estado atual e cada estado seja essencialmente um manipulador de mensagens.

Para implementar isso, temos que escrever nosso ator como uma subclasse deFiniteStateMachineActor. Em seguida, temos quantos métodos precisamos, cada um dos quais manipulará uma mensagem e retornará o novo estado para a transição para:

@Suspendable
fun lockedState() : SuspendableCallable> {
    return receive {msg ->
        when (msg) {
            "PUSH" -> {
                println("Still locked")
                lockedState()
            }
            "COIN" -> {
                println("Unlocking...")
                unlockedState()
            }
            else -> TERMINATE
        }
    }
}

Em seguida, também precisamos implementar o métodoinitialState() para dizer ao ator por onde começar:

@Suspendable
override fun initialState(): SuspendableCallable> {
    return SuspendableCallable { lockedState() }
}

Cada um de nossos métodos de estado fará o que for necessário e, em seguida, retornará um dos três valores possíveis, conforme necessário:

  • O novo estado a ser usado

  • O token especialTERMINATE, que indica que o ator deve desligar

  • null, que indica para não consumir esta mensagem específica - neste caso, a mensagem está disponível para o próximo estado para o qual fazemos a transição

4. Streams reativos

Reactive Streams é um padrão relativamente novo que está se tornando popular em muitas linguagens e plataformas. Essa API permite a interoperação entre várias bibliotecas e estruturas que suportam E / S assíncrona - incluindo RxJava, Akka e Quasar, entre outras.

The Quasar implementation allows us to convert between Reactive streams and Quasar channels, o que torna possível fazer com que os eventos desses fluxos sejam alimentados nos fluxos ou mensagens dos fluxos alimentando os fluxos.

Os fluxos reativos têm o conceito dePublishereSubscriber. . Um editor é algo que pode publicar mensagens para assinantes. Por outro lado, o Quasar usa os conceitos deSendPorteReceivePort, onde usamosSendPort para enviar mensagens eReceivePort para receber essas mesmas mensagens. O Quasar também tem o conceito deTopic, que é simplesmente um mecanismo que nos permite enviar mensagens a vários canais.

Esses são conceitos semelhantes, e o Quasar nos permite converter um para o outro.

4.1. Adicionando Streams Reativos ao Build

Os fluxos reativos não são um conceito central no Quasar. Em vez disso,we need to add a dependency that gives us access to them:


    co.paralleluniverse
    quasar-reactive-streams
    0.8.0

É importante que usemos a mesma versão desta dependência como de quaisquer outras dependências Quasar em uso. Também é importante que a dependência seja consistente com as APIs de fluxos reativos que estamos usando no aplicativo. Por exemplo,quasar-reactive-streams:0.8.0 depende dereactive-streams:1.0.2.

Se já não dependemos de fluxos reativos, isso não é uma preocupação. Só precisamos nos preocupar com isso se já estivermos dependendo de streams reativos, uma vez que nossa dependência local substituirá aquela da qual o Quasar depende.

4.2. Publicação em um fluxo reativo

Quasar gives us the ability to convert a Channel to a Publisher, de modo que podemos gerar mensagens usando um canal Quasar padrão, mas o código receptor pode tratá-lo como umPublisher reativo:

val inputChannel = Channels.newChannel(1)
val publisher = ReactiveStreams.toPublisher(inputChannel)

Depois de fazer isso, podemos tratar nossoPublisher como se fosse qualquer outraPublisher instance, o que significa que o código do cliente não precisa estar ciente do Quasar, ou mesmo que o código é assíncrono.

Quaisquer mensagens enviadas parainputChannel são adicionadas a este fluxo, de forma que possam ser puxadas pelo assinante.

Neste ponto, só podemos ter um único assinante no nosso fluxo. Tentar adicionar um segundo assinante lançará uma exceção.

If we want to support multiple subscribers, then we can use a Topic instead. Parece o mesmo do final do Reactive Streams, mas acabamos com umPublisher que oferece suporte a vários assinantes:

val inputTopic = Topic()
val publisher = ReactiveStreams.toPublisher(inputTopic)

4.3. Inscrever-se em um fluxo reativo

The opposite side of this is converting a Publisher to a Channel. Isso nos permite consumir mensagens de um fluxo Reativo usando canais Quasar padrão como se fosse qualquer outro canal:

val channel = ReactiveStreams.subscribe(10, Channels.OverflowPolicy.THROW, publisher)

Isso nos dá uma parteReceivePort de um canal. Uma vez feito, podemos tratá-lo da mesma forma que qualquer outro canal, usando construções padrão do Quasar para consumir mensagens dele. Essas mensagens são originárias do fluxo Reativo, de onde quer que isso venha.

5. Conclusão

Vimos algumas técnicas mais avançadas que podemos obter usando o Quasar. These allow us to write better, more maintainable asynchronous code, and to more easily interact with streams that come out of different asynchronous libraries.

Exemplos de alguns dos conceitos que cobrimos aqui podem ser encontradosover on GitHub.