ZIO 2 en Scala: efectos funcionales, fibers y ZLayer para aplicaciones concurrentes

ZIO es una librería para escribir aplicaciones en Scala que gestiona efectos, concurrencia, recursos y errores de forma funcional. La versión 2, publicada en abril de 2022, cambió bastante la API respecto a ZIO 1 y hoy es la base de muchos proyectos Scala serios. Si vienes de programación imperativa, al principio puede parecer que das muchas vueltas para hacer cosas simples, pero la ganancia está en que el compilador te ayuda a no olvidar casos de error y en que la concurrencia se vuelve mucho más predecible.

El tipo ZIO[R, E, A]

Todo en ZIO gira alrededor de este tipo. R es el entorno que necesita el efecto (dependencias), E es el tipo de error que puede producir y A es el tipo del valor de éxito. Un ZIO[Any, Nothing, Int] es un efecto que no necesita nada del entorno, nunca falla y produce un Int.

import zio.*

val safeDiv: ZIO[Any, String, Int] =
  ZIO.attempt(10 / 2)
    .mapError(_ => "División por cero")
    .map(_.toInt)

val program: ZIO[Any, Nothing, Unit] =
  safeDiv.foldZIO(
    err => ZIO.succeed(println(s"Error: $err")),
    n   => ZIO.succeed(println(s"Resultado: $n"))
  )

ZIO.attempt captura cualquier excepción JVM y la convierte en el canal de error. ZIO.succeed envuelve un valor puro. La diferencia con lanzar excepciones es que el tipo E aparece en la firma: quien llame a tu función sabe que puede fallar y con qué tipo de error.

Fibers: concurrencia sin callbacks

ZIO usa fibers, que son hilos virtuales gestionados por el runtime de ZIO. No son threads del sistema operativo, así que puedes tener millones de ellas sin problema. Las operaciones básicas son .fork para lanzar una fiber y .join para esperar su resultado:

import zio.*

val task1 = ZIO.succeed(Thread.sleep(100)).as("tarea1")
val task2 = ZIO.succeed(Thread.sleep(200)).as("tarea2")

val concurrent = for
  fiber1 <- task1.fork
  fiber2 <- task2.fork
  r1     <- fiber1.join
  r2     <- fiber2.join
yield (r1, r2)

Para correr dos efectos en paralelo y quedarte con el que termine antes, tienes .race. Para ejecutar una colección de efectos concurrentemente, ZIO.foreachPar. ZIO gestiona automáticamente la cancelación de las fibers que ya no hacen falta, lo que evita leaks de recursos habituales en código concurrente manual.

ZLayer: inyección de dependencias funcional

Una de las partes más características de ZIO 2 es ZLayer, el sistema de inyección de dependencias. En lugar de un framework externo o de pasar dependencias a mano, defines layers que describen cómo construir cada servicio:

import zio.*

trait Database:
  def query(sql: String): ZIO[Any, Throwable, List[String]]

case class LiveDatabase() extends Database:
  def query(sql: String): ZIO[Any, Throwable, List[String]] =
    ZIO.attempt(List(s"resultado de: $sql"))

object LiveDatabase:
  val layer: ZLayer[Any, Nothing, Database] =
    ZLayer.succeed(LiveDatabase())

val app: ZIO[Database, Throwable, Unit] = for
  db      <- ZIO.service[Database]
  results <- db.query("SELECT * FROM users")
  _       <- ZIO.succeed(results.foreach(println))
yield ()

object Main extends ZIOAppDefault:
  def run = app.provide(LiveDatabase.layer)

El compilador comprueba que todas las dependencias de app están satisfechas al llamar a .provide. Si te falta algún layer, error en compilación. Esto es mucho más seguro que la mayoría de frameworks de DI basados en reflexión.

ZStream: streaming funcional

ZIO incluye ZStream para procesar secuencias de datos de forma lazy y con backpressure integrado. Es útil para leer ficheros grandes, procesar eventos de una cola o cualquier flujo de datos:

import zio.*
import zio.stream.*

val stream: ZStream[Any, Throwable, Int] =
  ZStream.fromIterable(1 to 1000)
    .filter(_ % 2 == 0)
    .map(_ * 3)
    .take(10)

val result: ZIO[Any, Throwable, Chunk[Int]] =
  stream.runCollect

ZIO Test: tests como valores

ZIO viene con su propio framework de testing donde los tests son valores ZIO. No hay estado global, los tests son composables y puedes probar efectos asíncronos sin trucos:

import zio.*
import zio.test.*

object MySpec extends ZIOSpecDefault:
  def spec = suite("División segura")(
    test("devuelve el cociente correcto") {
      for result <- safeDiv
      yield assertTrue(result == 5)
    },
    test("completa sin errores") {
      assertCompletes
    }
  )

Si ya usas ZIO en producción, tener el testing integrado en el mismo modelo de efectos simplifica mucho las cosas. Puedes testear código que usa Clock, Console o cualquier servicio del entorno con implementaciones de prueba que ZIO proporciona.

ZIO vs Cats Effect

La comparativa inevitable. Cats Effect 3 (que vemos en otro artículo de esta serie) tiene un enfoque más tipeclase y más cerca del estilo de Haskell. ZIO es más directo, con errores tipados en el propio tipo y una experiencia de desarrollo más integrada. Para equipos nuevos en programación funcional, ZIO suele ser más fácil de adoptar; para quienes vienen de Haskell o del ecosistema Typelevel, Cats Effect puede resultar más familiar.

Ambas son opciones sólidas para aplicaciones Scala modernas. Lo importante es que cualquiera de las dos te da una base mucho más robusta que manejar Future[Either[Error, A]] a mano, que era el estado del arte antes de que estas librerías maduraran.

Imagen: Pexels / Myburgh Roux

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP