ZIO & Cats Effect: uma aliança de sucesso

O Cats Effect se tornou uma espécie de “Fluxos Reativos” para o mundo funcional do Scala, permitindo combinar todo o ecossistema diversificado de bibliotecas.

Muitas excelentes bibliotecas: http4s, fs2, doobie - são implementadas apenas com base nas classes de tipo do Cats Effect. E bibliotecas como ZIO e Monix, por sua vez, fornecem instâncias dessas classes de tipos para seus tipos de efeitos. Apesar de alguns problemas que serão corrigidos na versão 3.0, o Cats Effect ajuda muitos colaboradores de código aberto a dar suporte orgânico a todo o ecossistema funcional da linguagem Scala. Os desenvolvedores que usam o Cats Effect enfrentam uma escolha difícil: qual implementação de efeitos usar em seus aplicativos.

Hoje existem três alternativas:

  • Cats IO, implementação de referência;
  • Monix, o tipo de dados Task e sua reatividade no código;
  • ZIO, o tipo de dados ZIO e seu escopo de segmentação cruzada.

Neste post, tentarei provar a você que, para criar seu aplicativo usando o Cats Effect, o ZIO é uma boa escolha com soluções e recursos de design bastante diferentes da implementação de referência no Cats IO.

1. Melhor arquitetura MTL / sem tags-final


MTL (Monad Transformers Library) é um estilo de programação no qual as funções são polimórficas por seu tipo de efeito e expressam seus requisitos por meio de uma "restrição de classe de tipo". Em Scala, isso geralmente é chamado de estilo final sem etiqueta (embora não seja a mesma coisa), especialmente quando a classe de tipo não possui leis.

É sabido que é impossível definir uma instância global para classes clássicas de tipo MTL como Writer e State, bem como para tipos de efeito como Cats IO. O problema é que instâncias dessas classes de tipos para esses tipos de efeitos requerem acesso a um estado mutável, que não pode ser criado globalmente, porque a criação de um estado mutável também é um efeito.

Para um melhor desempenho, no entanto, é importante evitar "transformadores de mônada" e fornecer a implementação Write e State diretamente, além do tipo de efeito principal.

Para conseguir isso, os programadores Scala usam um truque: eles criam instâncias (mas limpas) no nível superior de seus programas com efeitos e, em seguida, as fornecem no programa como implicações locais:

Ref.make[AppState](initialAppState).flatMap(ref => implicit val monadState = new MonadState[Task, AppState] { def get: Task[AppState] = ref.get def set(s: AppState): Task[Unit] = ref.set(s).unit } myProgram ) 

Apesar de útil, esse truque ainda é uma "muleta". Em um mundo ideal, todas as instâncias de classes de tipos podem ser coerentes (uma instância por tipo) e não serem criadas localmente, gerando efeitos, e então se envolvem magicamente em valores implícitos para uso por métodos subseqüentes.

Um ótimo recurso do MTL / tagless-final é que você pode definir diretamente a maioria das instâncias sobre o tipo de dados ZIO usando o ambiente ZIO.

Aqui está uma maneira de criar uma definição global do MonadState para um tipo de dados ZIO:

 trait State[S] { def state: Ref[S] } implicit def ZIOMonadState[S, R <: State[S], E]: MonadState[ZIO[R, E, ?], S] = new MonadState[ZIO[R, E, ?], S] { def get: ZIO[R, E, S] = ZIO.accessM(_.state.get) def set(s: S): ZIO[R, E, Unit] = ZIO.accessM(_.state.set(s).unit) } 

Uma instância agora é definida globalmente para qualquer ambiente que suporte pelo menos State[S] .

Da mesma forma para FunctorListen , também conhecido como MonadWriter :

 trait Writer[W] { def writer: Ref[W] } implicit def ZIOFunctorListen[W: Semigroup, R <: Writer[W], E]: FunctorListen[ZIO[R, E, ?], W] = new FunctorListen[ZIO[R, E, ?], W] { def listen[A](fa: ZIO[R, E, A]): ZIO[R, E, (A, W)] = ZIO.accessM(_.state.get.flatMap(w => fa.map(a => a -> w))) def tell(w: W): ZIO[R, E, W] = ZIO.accessM(_.state.update(_ |+| w).unit) } 

E, é claro, podemos fazer o mesmo com o MonadError :

 implicit def ZIOMonadError[R, E]: MonadError[ZIO[R, E, ?], E] = new MonadError[ZIO[R, E, ?], E]{ def handleErrorWith[A](fa: ZIO[R, E, A])(f: E => ZIO[R, E, A]): ZIO[R, E, A] = fa catchAll f def raiseError[A](e: E): ZIO[R, E, A] = ZIO.fail(e) } 

Essa técnica é facilmente aplicável a outras classes de tipos, incluindo classes de tipo final sem tag, instâncias das quais pode exigir efeitos de geração (alterações, configurações), funções de teste que geram efeitos (combinando efeitos do ambiente com final sem tag) ou qualquer outra coisa facilmente acessível a partir do ambiente .

Não há mais transformações monádicas lentas! Digamos "não" para criar efeitos ao inicializar instâncias da classe de classe, para implicações locais. Não são necessárias mais muletas. Imersão direta em pura programação funcional.

2. Economizando recursos para meros mortais


Um dos primeiros recursos do ZIO foi a intercepção - a capacidade do tempo de execução do ZIO interromper instantaneamente qualquer efeito executável e garantir a liberação de todos os recursos. Uma implementação grosseira desse recurso atingiu o Cats IO.

Haskell chamou essa funcionalidade de exceção assíncrona, que permite criar e usar com eficiência a latência, operações paralelas e competitivas eficientes e cálculos globalmente ideais. Essas interrupções não apenas trazem grandes benefícios, mas também representam tarefas complexas no campo de suporte ao acesso seguro aos recursos.

Programadores são usados ​​para rastrear erros em programas através de análises simples. Isso também pode ser feito com o ZIO, que usa um sistema de tipos para ajudar a detectar erros. Mas interrupção é outra coisa. Um efeito criado a partir de muitos outros efeitos pode ser interrompido em qualquer borda.

Considere o seguinte efeito:

 for { handle <- openFile(file) data <- readFile(handle) _ <- closeFile(handle) } yield data 

A maioria dos desenvolvedores não ficará surpresa com esse cenário: closeFile não será executado se o readFile travar. Felizmente, o sistema de efeitos tem uma ensuring ( guarantee no Efeito Gatos) que permite adicionar um manipulador final ao efeito finalizador, semelhante ao finalmente.

Portanto, o principal problema do código acima pode ser facilmente resolvido:

 for { handle <- openFile(file) data <- readFile(handle).ensuring(closeFile(handle)) } yield () 

Agora, o efeito tornou-se "resistente a quedas", no sentido de que, se o readFile interrompido, o arquivo ainda será fechado. E se o readFile for bem-sucedido, o arquivo também será fechado. Em todos os casos, o arquivo será fechado.

Mas ainda não é bem assim. Interrupção significa que o efeito pode ser interrompido em qualquer lugar, mesmo entre openFile e openFile . Se isso acontecer, o arquivo aberto não será fechado e ocorrerá um vazamento de recursos.

O padrão de obter e liberar um recurso é tão amplo que o ZIO introduziu um operador de bracket, que também apareceu no Cats Effect 1.0. A instrução Bracket protege contra interrupções: se o recurso for recebido com êxito, a liberação ocorrerá mesmo que o efeito do recurso seja interrompido. Além disso, nem o recebimento nem a liberação do recurso podem ser interrompidos, garantindo assim a segurança do recurso.

Usando o colchete, o exemplo acima ficaria assim:

 openFile(file).bracket(closeFile(_))(readFile(_)) 

Infelizmente, o colchete encapsula apenas um padrão de consumo de recursos (bastante geral). Existem muitos outros, especialmente com estruturas de dados competitivas, cujo acesso deve estar acessível para interrupções; caso contrário, são possíveis vazamentos.

Em geral, todo o trabalho de interrupção se resume a duas coisas principais:

  • evitar interrupções em algumas áreas que podem ser interrompidas;
  • permitir a interrupção em áreas que podem congelar.

O ZIO tem a capacidade de implementar ambos. Por exemplo, podemos desenvolver nossa própria versão do bracket usando abstrações ZIO de baixo nível:

 ZIO.uninterruptible { for { a <- acquire exit <- ZIO.interruptible(use(a)) .run.flatMap(exit => release(a, exit) .const(exit)) b <- ZIO.done(exit) } yield b } 

Nesse código, use(a) é a única parte que pode ser interrompida. O código circundante garante a execução da release em qualquer caso.

A qualquer momento, você pode verificar se há uma oportunidade para interrupções. Para isso, são necessárias apenas duas operações primitivas (o restante é derivado delas).

Esse modelo de interrupção composicional com recursos completos permite implementar não apenas uma implementação de colchete simples, mas também implementar outros cenários no gerenciamento de recursos, nos quais é encontrado um equilíbrio entre as vantagens e desvantagens das interrupções.

O Cats IO fornece apenas uma operação para controlar interrupções: o combinador não cancelável. Faz todo o bloco de código ininterrupto. Embora essa operação raramente seja usada, pode levar a um vazamento ou bloqueio de recursos.

Ao mesmo tempo, é possível definir um primitivo dentro do Cats IO, que permite obter mais controle sobre interrupções. A implementação muito complicada de Fabio Labella acabou sendo extremamente lenta.

O ZIO permite escrever código com interrupções, operando em um nível alto com operadores declarativos compostos, e não força você a escolher entre complexidade severa combinada com baixo desempenho e vazamentos de bloqueio.

Além disso, a recém-adicionada memória transacional de software no ZIO permite ao usuário escrever declarativamente estruturas de dados e códigos que são automaticamente assíncronos, competitivos e permitem interrupções.

3. Finalizadores garantidos


O bloco try / finalmente em muitas linguagens de programação fornece as garantias necessárias para escrever código síncrono sem vazar recursos.

Em particular, esse bloco garante o seguinte: se um bloco try iniciar a execução, o bloco final será executado quando o try parar.

Esta garantia se aplica a:

  • existem blocos "try / finally" aninhados;
  • há erros no "bloco try";
  • há erros no bloco finalmente aninhado.

A operação de "garantia" do ZIO pode ser usada como try / finalmente:

 val effect2 = effect.ensuring(cleanup) 

O ZIO fornece as seguintes garantias para "effect.ensuring (finalizer)": se "effect" começar a ser executado, o "finalizer" começará a execução quando "effect" parar.

Como try / finalmente, essas garantias permanecem nos seguintes casos:

  • Existem composições "garantidas" aninhadas;
  • há erros no "efeito";
  • há erros no "finalizador" aninhado.

Além disso, a garantia é mantida mesmo que o efeito seja interrompido (as garantias no “suporte” são semelhantes, de fato, o “suporte” é implementado em “garantia”).

O tipo de dados Cats IO fornece outra garantia mais fraca. Para "effect.guarantee (finalizer)", ele é enfraquecido da seguinte forma: se "effect" começar a ser executado, "finalizer" começará a execução quando "effect" parar, se o efeito do problema não for inserido em "effect".

Uma garantia mais fraca também é encontrada na implementação do "suporte" no Cats IO.

Para obter um vazamento de recurso, basta usar o efeito usado dentro do efeito "garante" ou "bracket.use", componha-o com algo como isto:

 //   `interruptedFiber` -    val bigTrouble = interruptedFiber.join 

Quando bigTrouble é inserido dessa maneira em outro efeito, o efeito se torna ininterrupto - nenhum "finalizador" definido na "garantia" ou a limpeza de recursos através do "colchete" não será executada. Tudo isso leva a uma drenagem de recursos, mesmo quando há um "finalizador" no bloco.

Por exemplo, o "finalizador" no código a seguir nunca começará a execução:

 (IO.unit >> bigTrouble).guarantee(IO(println("Won't be executed!!!«))) 

Avaliando o código sem levar em consideração o contexto global, é impossível determinar se um efeito, como "bigTrouble", será inserido em qualquer lugar do efeito "use" da operação "bracket" ou dentro do bloco "finalizer".

Portanto, você não poderá descobrir se o programa Cats IO funcionará com vazamentos de recursos ou com blocos "finalizadores" ausentes sem avaliar o programa inteiro. O programa inteiro só pode ser avaliado manualmente e esse processo é sempre acompanhado por erros que não podem ser verificados pelo compilador. Além disso, esse processo deve ser repetido sempre que ocorrerem alterações importantes no código.

O ZIO possui uma implementação personalizada de "garantia" do Cats Effect, "GuaranteCase" e "bracket". As implementações usam semântica ZIO nativa (não semântica de IO de gatos), o que nos permite avaliar possíveis problemas com vazamentos de recursos aqui e agora, sabendo que em todas as situações os finalizadores serão iniciados e os recursos serão liberados.

4. Comutação estável


O Efeito Cats tem o método "evalOn" de "ContextShift", que permite mover a execução de algum código para outro contexto de execução.

Isso é útil por vários motivos:

  • muitas bibliotecas clientes forçam você a fazer algum trabalho no pool de threads;
  • As bibliotecas da interface do usuário exigem que algumas atualizações ocorram no encadeamento da interface do usuário;
  • alguns efeitos requerem isolamento em conjuntos de encadeamentos adaptados a seus recursos específicos.

A operação "EvalOn" executa o efeito onde deve ser executada e, em seguida, retorna ao contexto de execução original. Por exemplo:

 cs.evalOn(kafkaContext)(kafkaEffect) 

Nota: O Cats IO possui uma construção semelhante de "deslocamento", que permite alternar para um contexto diferente sem voltar atrás, mas, na prática, esse comportamento raramente é necessário; portanto, "evalOn" é o preferido.

A implementação do ZIO do “evalOn” (feita no “bloqueio” primitivo do ZIO) fornece as garantias necessárias para entender exclusivamente onde o efeito funciona - o efeito sempre será executado em um contexto específico.

O Cats IO tem uma garantia diferente e mais fraca - o efeito será executado em um determinado contexto até a primeira operação assíncrona ou comutação interna.

Considerando um pequeno pedaço de código, é impossível saber com certeza se um efeito assíncrono (ou comutação aninhada) será incorporado ao efeito que será alternado, porque a assincronia não é exibida nos tipos.

Portanto, como no caso de segurança de recursos, para entender onde o efeito Cats IO será lançado, é necessário estudar todo o programa. Na prática, e pela minha experiência, os usuários de Cats IO ficam surpresos quando, ao usar o "evalOn" em um contexto, é descoberto subseqüentemente que a maior parte do efeito foi acidentalmente executada em outro.

O ZIO permite que você determine onde os efeitos devem ser acionados e confie que isso acontecerá em todos os casos, não importa como os efeitos sejam incorporados a outros efeitos.

5. Segurança de mensagens de erro


Qualquer efeito que suporte simultaneidade, simultaneidade ou acesso seguro a recursos será executado em um modelo de erro linear: em geral, nem todos os erros podem ser salvos.

Isso é válido para o `Throwable`, um tipo de erro fixo incorporado no Cats IO e o tipo de erro polimórfico suportado pelo ZIO.

Exemplos de situações com vários erros únicos:

  • O finalizador lança uma exceção;
  • dois efeitos (decrescentes) são combinados em execução paralela;
  • dois efeitos (caindo) em um estado de corrida;
  • o efeito interrompido cai antes de deixar a seção protegida contra interrupções.

Como nem todos os erros são salvos, o ZIO fornece uma estrutura de dados "Causa [E]" com base em um semi-anel livre (uma abstração da álgebra abstrata, seu conhecimento não é suposto aqui), o que permite conectar erros seriais e paralelos a qualquer tipo de erro. Durante todas as operações (incluindo limpeza para um efeito caído ou interrompido), o ZIO agrega erros na estrutura de dados “Causa [E]”. Essa estrutura de dados está disponível a qualquer momento. Como resultado, o ZIO sempre armazena todos os erros: eles estão sempre disponíveis, podem ser registrados, estudados e transformados conforme exigido pelos requisitos de negócios.

Gatos IO escolheu um modelo com informações de perda de erro. Enquanto o ZIO conectará os dois erros por meio da Causa [E], o Cats IO "perderá" uma das mensagens de erro, por exemplo, chamando "e.printStackTrace ()" no erro que ocorrer.

Por exemplo, um erro no "finalizador" neste código será perdido.

 IO.raiseError(new Error("Error 1")).guarantee(IO.raiseError(new Error("Error 2«))) 

Essa abordagem para rastrear erros significa que você não pode localizar e processar localmente todo o espectro de erros que ocorrem devido à combinação de efeitos. O ZIO permite que você use qualquer tipo de erro, incluindo "Jogável" (ou subtipos mais específicos, como "IOExceptio" ou outra hierarquia de exceção personalizada), garantindo que nenhum erro seja perdido durante a execução do programa.

6. Assincronia sem deadlocks


O ZIO e o Cats IO fornecem um construtor que permite que você pegue o código com um retorno de chamada e envolva-o com efeito

Esse recurso é fornecido através da classe de pipe Async no Cats Effect:

 val effect: Task[Data] = Async[Task].async(k => getDataWithCallbacks( onSuccess = v => k(Right(v)), onFailure = e => k(Left(e)) )) 

Isso cria um efeito assíncrono que, quando executado, espera até que o valor apareça e depois continua, e tudo isso será óbvio para o usuário do efeito. Portanto, a programação funcional é tão atraente para o desenvolvimento de código assíncrono.

Observe que, assim que o código de retorno de chamada se torna um efeito, a função de retorno de chamada (aqui é chamada `k`) é chamada. Essa função de retorno de chamada é encerrada com um valor de sucesso / erro. Quando essa função de retorno de chamada é chamada, a execução do efeito (pausada anteriormente) é retomada.

O ZIO garante que o efeito continuará a execução no conjunto de encadeamentos de tempo de execução se o efeito não tiver sido atribuído a nenhum contexto especial específico ou a outro contexto ao qual o efeito foi anexado.

Cats IO retoma o efeito no thread de retorno de chamada. A diferença entre essas opções é bastante profunda: o encadeamento que causa o retorno de chamada não espera que o código de retorno de chamada seja executado para sempre, mas permite apenas um pequeno atraso antes do retorno do controle. Por outro lado, o Cats IO não oferece tal garantia: o encadeamento de chamada, o retorno de chamada de lançamento, podem congelar, aguardando um tempo indefinido quando o controle de execução retornar.

As versões anteriores das estruturas de dados competitivas no Cats Effect ("Adiado", "Semáforo") retomaram os efeitos que não retornavam o controle da execução ao segmento de chamada. Como resultado, problemas relacionados a conflitos e um agendador de execução quebrado foram descobertos neles. Embora todos esses problemas tenham sido encontrados, eles são corrigidos apenas para estruturas de dados competitivas no Cats Effect.

O código do usuário que usa uma abordagem semelhante à do Cats IO enfrentará esses problemas, porque essas tarefas são não determinísticas, os erros podem ocorrer muito raramente, em tempo de execução, tornando a depuração e a detecção de problemas um processo difícil.

O ZIO oferece proteção imediata e um agendador de tarefas normal e também faz com que o usuário escolha explicitamente o comportamento do Cats IO (por exemplo, usando "unsafeRun" em "Promise", que terminou com um efeito assíncrono retomado).

Embora nenhuma das soluções seja adequada para absolutamente todos os casos, e o ZIO e o Cats IO forneçam flexibilidade suficiente para resolver todas as situações (de maneiras diferentes), escolher o ZIO significa usar o "Async" sem preocupações e forçar você a colocar o código do problema em "unsafeRun", que é conhecido por causar impasse

7. Compatível com o futuro


O uso de "Future" da biblioteca padrão Scala é uma realidade para um grande número de bases de código. O ZIO vem com um método "fromFuture", que fornece um contexto de execução pronto:

 ZIO.fromFuture(implicit ec => // Create some Future using `ec`: ??? ) 

Quando esse método é usado para envolver o Future em um efeito, o ZIO pode definir onde o Future será executado e outros métodos, como evalOn, transferirão o Future corretamente para o contexto de execução desejado. O Cats IO aceita "Future", que foi criado com um "ExecutionContext" externo. Isso significa que o Cats IO não pode mover a execução do Future de acordo com os requisitos dos métodos evalOn ou shift. Além disso, isso sobrecarrega o usuário na determinação do contexto de execução para o futuro, o que significa seleção restrita e um ambiente separado.

Como o ExecutionContext fornecido pode ser ignorado, o ZIO pode ser representado como a soma dos recursos de E / S de gatos, garantindo uma interação mais suave e precisa com o Future no caso geral, mas ainda existem exceções.

8. Bloqueio de E / S


Como foi mostrado no artigo “ Pool de threads. Práticas recomendadas com o ZIO ”, para aplicativos de servidor, são necessários pelo menos dois conjuntos separados para obter a máxima eficiência:

  • pool fixo para efeitos assíncronos / da CPU;
  • dinâmico, com a possibilidade de aumentar o número de threads de bloqueio.

A decisão de executar todos os efeitos em um conjunto de encadeamentos fixo levará um dia a um impasse, enquanto o acionamento de todos os efeitos em um conjunto dinâmico pode levar à perda de desempenho.

Na JVM, o ZIO fornece duas operações que suportam efeitos de bloqueio:

  • Operador "Blocking (effect"), que alterna a execução de um certo efeito no pool de threads de bloqueio que possuem boas predefinições que podem ser alteradas, se desejado);
  • "EffectBlocking (effect)" é um operador que converte um código de bloqueio com efeitos colaterais em um efeito puro, cuja interrupção interrompe a execução da maior parte do código de bloqueio.

, , , «blocking». , - , , «effectBlocking» , ZIO ( ).

Cats IO , . , «blocking», «evalOn», , , .

( ZIO) (, ), .

9.


, Scala, :

  • «ReaderT»/ «Kleisli», ;
  • «EitherT», ( «OptionT», «EitherT» «Unit» ).

, (, http4s «Kleisli» «OptionT»). («effect totation»), ZIO «reader» «typed error» ZIO. «reader» «typed error» , ZIO , . , «Task[A]», «reader» «typed errors».

ZIO () - . , ZIO , .

Cats IO . , , «reader» «typed errors» «state», «writer» , .

ZIO 8 Cats IO . , Scala .

10.


ZIO , , . , Scala, .

ZIO 2000 , «typed errors» , — 375 . Scala , . , , .

:

  • ;
  • ;
  • , ;
  • .

. , - , .

- . , . ZIO . Cats IO , , ZIO ( , ).

11.


ZIO , , - .

  • , : «ZIO. succeed» «Applicative[F].pure», «zip» «Apply[F].product», «ZIO.foreach» «Traverse[F].traverse».
  • (Cats, Cats Effect, Scalaz ).
  • , ( «Runtime», Cats Effect - Cats Effect). — Cats IO.
  • .
  • . : "zip"/"zipPar", "ZIO.foreach"/"ZIO.foreachPar", "ZIO.succeed"/"ZIO.succeedLazy«.
  • , «». ZIO IDE.
  • Scala ZIO : «ZIO.fromFuture», «ZIO.fromOption», «ZIO.fromEither», «ZIO.fromTry».
  • «».

, Scala, , ZIO , , , ZIO, . Cats IO , Cats.

, , , ( , , ).

12.


ZIO — - , .

:

  • , «Ref», «Promise», «Queue», «Semaphore» «Stream» //;
  • STM, , , ;
  • «Schedule», ;
  • «Clock», «Random», «Console» «System» , ;
  • , .

- Cats IO . Cats IO , ( ) .

Conclusão


Cats Effect Scala-, , .

, Cats Effect, , Cats Effect : Cats IO, Monix, Zio.

, . , , , : ZIO Cats Effect .

Se você quiser saber mais sobre as tecnologias e ferramentas de programação Scala ou descobrir mais detalhes importantes - ótimas notícias para você. No final de novembro, Moscou sediará a primeira conferência na Rússia dedicada inteiramente a Scala. A Agenda ScalaConf já foi publicada, contém 18 relatórios interessantes de especialistas reconhecidos, incluindo John A De Goes pessoalmente.

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


All Articles