Cats Effect se ha convertido en una especie de "Corrientes reactivas" para el mundo funcional de Scala, lo que le permite combinar todo el ecosistema diverso de bibliotecas.
Muchas bibliotecas excelentes: http4s, fs2, doobie, se implementan solo en base a las clases de tipos de Cats Effect. Y las bibliotecas como ZIO y Monix, a su vez, proporcionan instancias de estas clases de tipos para sus tipos de efectos. A pesar de algunos problemas que se solucionarán en la versión 3.0, Cats Effect ayuda a muchos contribuyentes de código abierto a soportar orgánicamente todo el ecosistema funcional del lenguaje Scala. Los desarrolladores que usan Cats Effect se enfrentan a una elección difícil: qué implementación de efectos usar para sus aplicaciones.
Hoy hay tres alternativas:
- Cats IO, implementación de referencia;
- Monix, el tipo de datos de la tarea y su reactividad en el código;
- ZIO, el tipo de datos ZIO y su alcance de subprocesamiento cruzado.
En esta publicación, intentaré demostrarle que para crear su aplicación utilizando Cats Effect, ZIO es una buena opción con soluciones de diseño y capacidades que son bastante diferentes de la implementación de referencia en Cats IO.
1. Mejor arquitectura MTL / Tagless-Final
MTL (Monad Transformers Library) es un estilo de programación en el que las funciones son polimórficas por su tipo de efecto y expresan sus requisitos a través de una "restricción de clase de tipo". En Scala, esto a menudo se denomina estilo final sin etiqueta (aunque no es lo mismo), especialmente cuando la clase de tipo no tiene leyes.
Es bien sabido que no es posible definir una instancia global para clases de tipo MTL clásicas como Writer y State, ni para tipos de efectos como Cats IO. El problema es que las instancias de estas clases de tipos para estos tipos de efectos requieren acceso a un estado mutable, que no se puede crear globalmente, porque crear un estado mutable también es un efecto.
Sin embargo, para obtener el mejor rendimiento, es importante evitar los "transformadores de mónada" y proporcionar la implementación de Escritura y Estado directamente, además del tipo de efecto principal.
Para lograr esto, los programadores de Scala usan un truco: crean instancias (pero limpias) en el nivel superior de sus programas con efectos y luego las proporcionan más en el programa como implicaciones locales:
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 )
A pesar de que tal truco es útil, sigue siendo una "muleta". En un mundo ideal, todas las instancias de clases de tipos podrían ser coherentes (una instancia por tipo), y no crearse localmente, generando efectos, luego envolverse mágicamente en valores implícitos para su uso por métodos posteriores.
Una gran característica de MTL / tagless-final es que puede definir directamente la mayoría de las instancias además del tipo de datos ZIO utilizando el entorno ZIO.
Aquí hay una forma de crear una definición global de MonadState para un tipo de datos 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) }
Ahora se define una instancia de forma global para cualquier entorno que admita al menos
State[S]
.
De manera similar para
FunctorListen
, también conocido 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) }
Y, por supuesto, podemos hacer lo mismo con
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) }
Esta técnica es fácilmente aplicable a otras clases de tipos, incluidas las clases de tipo final sin etiqueta, cuyas instancias pueden requerir generar efectos (cambios, configuraciones), probar funciones que generan efectos (combinando efectos ambientales con final sin etiqueta) o cualquier otra cosa fácilmente accesible desde el entorno .
¡No más transformaciones monádicas lentas! Digamos "no" a la creación de efectos al inicializar instancias de la clase clase, a las implicaciones locales. No se necesitan más muletas. Inmersión directa en programación puramente funcional.
2. Ahorro de recursos para simples mortales.
Una de las primeras características de ZIO fue la interacción: la capacidad del tiempo de ejecución de ZIO para interrumpir instantáneamente cualquier efecto ejecutable y garantizar la liberación de todos los recursos. Una implementación cruda de esta característica golpeó a Cats IO.
Haskell llamó a tal funcionalidad excepción asíncrona, que le permite crear y usar de manera eficiente latencia, operaciones paralelas y competitivas eficientes, y cálculos óptimos a nivel mundial. Dichas interrupciones no solo traen grandes beneficios, sino que también plantean tareas complejas en el campo de apoyar el acceso seguro a los recursos.
Los programadores están acostumbrados a rastrear errores en programas a través de un análisis simple. Esto también se puede hacer con ZIO, que utiliza un sistema de tipos para ayudar a detectar errores. Pero la interrupción es otra cosa. Un efecto creado a partir de muchos otros efectos puede interrumpirse en cualquier borde.
Considere el siguiente efecto:
for { handle <- openFile(file) data <- readFile(handle) _ <- closeFile(handle) } yield data
La mayoría de los desarrolladores no se sorprenderán de este escenario:
closeFile
no se ejecutará si se bloquea
readFile
. Afortunadamente, el sistema de efectos tiene una
ensuring
(
guarantee
en Efecto Gatos) que le permite agregar un controlador final al efecto finalizador, similar a finalmente.
Entonces, el problema principal del código anterior se puede resolver fácilmente:
for { handle <- openFile(file) data <- readFile(handle).ensuring(closeFile(handle)) } yield ()
Ahora el efecto se ha vuelto "resistente a caídas", en el sentido de que si se rompe el archivo
readFile
, el archivo seguirá cerrado. Y si
readFile
tiene éxito, el archivo también se cerrará. En todos los casos, el archivo se cerrará.
Pero todavía no del todo. Interrupción significa que el efecto se puede interrumpir en todas partes, incluso entre
openFile
y
readFile
. Si esto sucede, el archivo abierto no se cerrará y se producirá una pérdida de recursos.
El patrón de obtener y liberar un recurso está tan extendido que ZIO introdujo un operador de soporte, que también apareció en Cats Effect 1.0. La instrucción Bracket protege contra interrupciones: si el recurso se recibe con éxito, se producirá la liberación incluso si se interrumpe el efecto que usa el recurso. Además, ni el recibo ni la liberación del recurso pueden interrumpirse, lo que proporciona una garantía de seguridad del recurso.
Usando el soporte, el ejemplo anterior se vería así:
openFile(file).bracket(closeFile(_))(readFile(_))
Desafortunadamente, el soporte encapsula solo un patrón de consumo de recursos (bastante general). Hay muchos otros, especialmente con estructuras de datos competitivas, cuyo acceso debe ser accesible para las interrupciones, de lo contrario son posibles las fugas.
En general, todo trabajo de interrupción se reduce a dos cosas principales:
- prevenir interrupciones en algunas áreas que pueden ser interrumpidas;
- permita la interrupción en áreas que pueden congelarse.
ZIO tiene la capacidad de implementar ambos. Por ejemplo, podemos desarrollar nuestra propia versión de soporte utilizando abstracciones ZIO de bajo nivel:
ZIO.uninterruptible { for { a <- acquire exit <- ZIO.interruptible(use(a)) .run.flatMap(exit => release(a, exit) .const(exit)) b <- ZIO.done(exit) } yield b }
En este código, el
use(a)
es la única parte que se puede interrumpir. El código circundante garantiza la ejecución de la
release
en cualquier caso.
En cualquier momento, puede verificar si existe la posibilidad de interrupciones. Para esto, solo se necesitan dos operaciones primitivas (todas las demás se derivan de ellas).
Este modelo de interrupción de composición con todas las funciones le permite implementar no solo una implementación simple de soporte, sino también implementar otros escenarios en la gestión de recursos, en los que se encuentra un equilibrio entre las ventajas y desventajas de las interrupciones.
Cats IO proporciona solo una operación para controlar las interrupciones: el combinador no cancelable. Hace que todo el bloque de código sea ininterrumpido. Aunque esta operación rara vez se usa, puede provocar una pérdida de recursos o bloqueos.
Al mismo tiempo, resulta que puede definir una primitiva dentro de Cats IO, que le permite lograr un mayor control sobre las interrupciones. La implementación muy complicada de Fabio Labella resultó ser extremadamente lenta.
ZIO le permite escribir código con interrupciones, operando a un alto nivel con declaraciones compuestas declarativas, y no lo obliga a elegir entre una complejidad severa combinada con un bajo rendimiento y fugas de bloqueo.
Además, la memoria transaccional de software recientemente agregada en ZIO permite al usuario escribir de forma declarativa estructuras de datos y código que son automáticamente asíncronos, competitivos y permiten interrupciones.
3. Finalizadores garantizados
El bloque try / finally en muchos lenguajes de programación proporciona las garantías necesarias para escribir código sincrónico sin pérdida de recursos.
En particular, este bloque garantiza lo siguiente: si un bloque try comienza a ejecutarse, el bloque finalmente se ejecutará cuando el try se detenga.
Esta garantía se aplica a:
- hay bloques anidados de "prueba / finalmente";
- hay errores en el "bloque de prueba";
- hay errores en el bloque anidado finalmente.
La operación "asegurar" de ZIO se puede usar como probar / finalmente:
val effect2 = effect.ensuring(cleanup)
ZIO ofrece las siguientes garantías para "effect.ensuring (finalizer)": si se comenzó a ejecutar "effect", entonces "finalizer" comenzará a ejecutarse cuando se detenga "effect".
Como prueba / finalmente, estas garantías permanecen en los siguientes casos:
- Hay composiciones "aseguradoras" anidadas;
- hay errores en el "efecto";
- hay errores en el "finalizador" anidado.
Además, la garantía se mantiene incluso si el efecto se interrumpe (las garantías en el "soporte" son similares, de hecho, el "soporte" se implementa en "garantizar").
El tipo de datos Cats IO proporciona otra garantía más débil. Para "effect.guarantee (finalizer)", se debilita de la siguiente manera: si "effect" comenzó a ejecutarse, "finalizer" comenzará a ejecutarse cuando "effect" se detenga, si el efecto del problema no se inserta en "effect".
Una garantía más débil también se encuentra en la implementación del "soporte" en Cats IO.
Para obtener una fuga de recursos, simplemente use el efecto usado dentro del efecto "garantía" o "soporte.use", compóngalo con algo como esto:
Cuando bigTrouble se inserta de esta manera en otro efecto, el efecto se vuelve ininterrumpido: no se ejecutarán "finalizadores" a través de la "garantía" o no se ejecutará la limpieza de los recursos a través del "soporte". Todo esto conduce a una pérdida de recursos, incluso cuando hay un "finalizador" en el bloque.
Por ejemplo, el "finalizador" en el siguiente código nunca comenzará a ejecutarse:
(IO.unit >> bigTrouble).guarantee(IO(println("Won't be executed!!!«)))
Al evaluar el código sin tener en cuenta el contexto global, es imposible determinar si un efecto, como "bigTrouble", se insertará en cualquier parte del efecto "uso" de la operación "soporte" o dentro del bloque "finalizador".
Por lo tanto, no podrá averiguar si el programa Cats IO funcionará con pérdidas de recursos o bloques "finalizadores" faltantes sin evaluar todo el programa. Todo el programa solo puede evaluarse manualmente, y este proceso siempre va acompañado de errores que el compilador no puede verificar. Además, este proceso debe repetirse cada vez que ocurran cambios importantes en el código.
ZIO tiene una implementación personalizada de "garantía" de Cats Effect, "garantíaCase" y "soporte". Las implementaciones usan semántica nativa de ZIO (no semántica de Cats IO), lo que nos permite evaluar posibles problemas con fugas de recursos aquí y ahora, sabiendo que en todas las situaciones se lanzarán finalizadores y se liberarán recursos.
4. Conmutación estable
Cats Effect tiene el método "evalOn" de "ContextShift", que le permite mover la ejecución de algún código a otro contexto de ejecución.
Esto es útil por varias razones:
- muchas bibliotecas de clientes lo obligan a trabajar en su grupo de subprocesos;
- Las bibliotecas de IU requieren que se realicen algunas actualizaciones en el subproceso de IU;
- Algunos efectos requieren aislamiento en grupos de subprocesos adaptados a sus características específicas.
La operación "EvalOn" ejecuta el efecto donde debe ejecutarse y luego vuelve al contexto de ejecución original. Por ejemplo:
cs.evalOn(kafkaContext)(kafkaEffect)
Nota: Cats IO tiene una construcción similar de "cambio", que le permite cambiar a un contexto diferente sin tener que retroceder, pero en la práctica, este comportamiento rara vez es necesario, por lo que se prefiere "evalOn".
La implementación de ZIO de "evalOn" (realizada en el "bloqueo" primitivo de ZIO) proporciona las garantías necesarias para comprender de forma única dónde funciona el efecto: el efecto siempre se ejecutará en un contexto específico.
Cats IO tiene una garantía diferente y más débil: el efecto se ejecutará en un determinado contexto hasta la primera operación asincrónica o la conmutación interna.
Teniendo en cuenta un pequeño fragmento de código, es imposible saber con certeza si un efecto asincrónico (o una conmutación anidada) se integrará en el efecto que cambiará, porque la asincronía no se muestra en tipos.
Por lo tanto, como en el caso de la seguridad de los recursos, para comprender dónde se lanzará el efecto Cats IO, es necesario estudiar todo el programa. En la práctica, y desde mi experiencia, los usuarios de Cats IO se sorprenden cuando, al usar "evalOn" en un contexto, posteriormente se descubre que la mayor parte del efecto se realizó accidentalmente en otro.
ZIO le permite determinar dónde deben activarse los efectos y confiar en que sucederá en todos los casos, sin importar cómo se incorporen los efectos a otros efectos.
5. Seguridad de los mensajes de error.
Cualquier efecto que admita concurrencia, concurrencia o acceso seguro a los recursos se encontrará con un modelo de error lineal: en general, no se pueden guardar todos los errores.
Esto es cierto tanto para 'Throwable', un tipo de error fijo integrado en Cats IO como para el tipo de error polimórfico compatible con ZIO.
Ejemplos de situaciones con múltiples errores únicos:
- El finalizador lanza una excepción;
- dos efectos (descendentes) se combinan en ejecución paralela;
- dos efectos (caídas) en un estado de carrera;
- el efecto interrumpido cae antes de dejar la sección protegida de interrupciones.
Dado que no se guardan todos los errores, ZIO proporciona una estructura de datos "Causa [E]" basada en un semired libre (una abstracción del álgebra abstracta, su conocimiento no se supone aquí), que permite conectar errores en serie y paralelos para cualquier tipo de error. Durante todas las operaciones (incluida la limpieza de un efecto caído o interrumpido), ZIO agrega errores en la estructura de datos "Causa [E]". Esta estructura de datos está disponible en cualquier momento. Como resultado, ZIO siempre almacena todos los errores: siempre están disponibles, se pueden registrar, estudiar y transformar según lo requieran los requisitos comerciales.
Cats IO eligió un modelo con pérdida de información de error. Mientras que ZIO conectará los dos errores a través de la Causa [E], Cats IO "perderá" uno de los mensajes de error, por ejemplo, llamando al "e.printStackTrace ()" en el error que ocurre.
Por ejemplo, se perderá un error en el "finalizador" en este código.
IO.raiseError(new Error("Error 1")).guarantee(IO.raiseError(new Error("Error 2«)))
Este enfoque para rastrear errores significa que no puede localizar y procesar localmente todo el espectro de errores que ocurren debido a la combinación de efectos. ZIO le permite usar cualquier tipo de error, incluido "Throwable" (o subtipos más específicos como "IOExceptio" u otra jerarquía de excepción personalizada), asegurando que no se pierdan errores durante la ejecución del programa.
6. Asincronía sin puntos muertos
Tanto ZIO como Cats IO proporcionan un constructor que le permite tomar código con una devolución de llamada y envolverlo en efecto
Esta característica se proporciona a través de la clase de tubería Async en Efecto Gatos:
val effect: Task[Data] = Async[Task].async(k => getDataWithCallbacks( onSuccess = v => k(Right(v)), onFailure = e => k(Left(e)) ))
Esto crea un efecto asincrónico que, cuando se ejecuta, esperará hasta que aparezca el valor y luego continuará, y todo esto será obvio para el usuario del efecto. Por lo tanto, la programación funcional es tan atractiva para desarrollar código asincrónico.
Tenga en cuenta que tan pronto como el código de devolución de llamada se convierta en un efecto, se llama a la función de devolución de llamada (aquí se llama `k`). Esta función de devolución de llamada sale con un valor de éxito / error. Cuando se llama a esta función de devolución de llamada, se reanuda la ejecución del efecto (previamente pausado).
ZIO garantiza que el efecto reanudará la ejecución en el grupo de subprocesos de tiempo de ejecución si el efecto no se asignó a ningún contexto especial en particular, oa otro contexto al que se adjuntó el efecto.
Cats IO reanuda el efecto en el hilo de devolución de llamada. La diferencia entre estas opciones es bastante profunda: el hilo que causa la devolución de llamada no espera que el código de devolución de llamada se ejecute para siempre, pero solo permite un ligero retraso antes de que regrese el control. Por otro lado, Cats IO no ofrece tal garantía en absoluto: el hilo de llamada, la devolución de llamada de inicio, puede congelarse, esperando un tiempo indefinido cuando regrese el control de ejecución.
Las versiones anteriores de las estructuras de datos competitivas en Cats Effect ("Diferido", "Semáforo") reanudaron los efectos que no devolvieron el control de ejecución al hilo de llamada. Como resultado, se descubrieron problemas relacionados con puntos muertos y un programador de ejecución roto. Aunque se han encontrado todos estos problemas, solo se corrigen para estructuras de datos competitivas en Cats Effect.
El código de usuario que utiliza un enfoque similar al de Cats IO se meterá en tales problemas, ya que tales tareas no son deterministas, los errores solo pueden ocurrir muy raramente en tiempo de ejecución, lo que hace que la depuración y la detección de problemas sean un proceso difícil.
ZIO proporciona protección de punto muerto y un programador de tareas normal fuera de la caja, y también hace que el usuario elija explícitamente el comportamiento de Cats IO (por ejemplo, usando "unsafeRun" en "Promise", que terminó en un efecto asincrónico reanudado).
Aunque ninguna de las soluciones es adecuada para absolutamente todos los casos, y ZIO y Cats IO proporcionan suficiente flexibilidad para resolver todas las situaciones (de diferentes maneras), elegir ZIO significa usar "Async" sin preocupaciones y lo obliga a poner el código del problema en "unsafeRun", que se sabe que causa un punto muerto
7. Compatible con el futuro
Usar "Future" de la biblioteca estándar de Scala es una realidad para una gran cantidad de bases de código. ZIO viene con un método "fromFuture", que proporciona un contexto de ejecución listo:
ZIO.fromFuture(implicit ec =>
Cuando este método se utiliza para ajustar Future en un efecto, ZIO puede establecer dónde se ejecutará Future, y otros métodos, como evalOn, transferirán correctamente Future al contexto de ejecución deseado. Cats IO acepta "Future", que se creó con un "ExecutionContext" externo. Esto significa que Cats IO no puede mover la ejecución de Future de acuerdo con los requisitos de los métodos de evaluación o cambio. Además, esto carga al usuario con la determinación del contexto de ejecución para el Futuro, lo que significa una selección limitada y un entorno separado.
Dado que se puede ignorar el ExecutionContext proporcionado, ZIO se puede representar como la suma de las características de Cats IO, garantizando una interacción más fluida y precisa con Future en el caso general, pero todavía hay excepciones.
8. Bloqueo de IO
Como se mostró en el artículo "
Grupo de subprocesos. Mejores prácticas con ZIO ”, para aplicaciones de servidor, se requieren al menos dos grupos separados para una máxima eficiencia:
- grupo fijo para CPU / efectos asincrónicos;
- dinámico, con la posibilidad de aumentar el número de hilos de bloqueo.
La decisión de ejecutar todos los efectos en un grupo de subprocesos fijo algún día conducirá a un punto muerto, mientras que desencadenar todos los efectos en un grupo dinámico puede conducir a la pérdida de rendimiento.
En la JVM, ZIO proporciona dos operaciones que admiten efectos de bloqueo:
- Operador "Bloqueo (efecto)", que cambia la ejecución de un cierto efecto en el conjunto de subprocesos de bloqueo que tienen buenos ajustes preestablecidos que se pueden cambiar si se desea);
- «effectBlocking(effect)» , , .
, , , «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 , ( ) .
Conclusión
Cats Effect Scala-, , .
, Cats Effect, , Cats Effect : Cats IO, Monix, Zio.
, . , , , : ZIO Cats Effect .
Scala — . , Scala. ScalaConf , 18 , John A De Goes .