ZIO IV: modulación por capas

Finalizamos la serie de entradas de ZIO con la presente entrada, ZIO IV: modulación por capas, en la cual presentaré cómo la librería ZIO permite definir módulos funcionales conectados horizontal o verticalmente. Las entradas publicadas hasta la fecha son las siguientes:

El ejemplo práctico ha realizar consistirá en resolver un problema básico de ingeniero de datos del ámbito de BigData. Todo ingeniero de datos debe de dar solución a una ingesta de datos, transformar los datos conforme a unas reglas de negocio y, para finalizar, almacenar o cargar los datos transformados en un data lake o cualquier tipo de almacén de datos; este proceso, se denomina ETL (Extract, Transform and Load). Así, el caso de uso consiste en realizar un proceso de extracción, transformación y carga de datos solicitado por un actor el cual puede ser un sistema o una persona física.

La solución está compuesta por cuatro elementos: el primero, un módulo con la funcionalidad encargada de la extracción de datos; el segundo, el módulo transformador, encargado de transformar los datos en función de unas reglas de negocio; el tercero, el módulo cargador, encargado de realizar la carga de los datos transformados al almacén de datos; y, para finalizar, el elemento coordinador de las operaciones de los módulos el cual contendrá la definición de las secuencias del programa ETL. En el ejemplo, los procesos de extracción y carga son tareas simbólicas ya que el objetivo del ejercicio reside en el desarrollo de los módulos. Desde un punto de vista gráfico la vista de elementos y los intercambios de mensajes entre ellos queda definido en el siguiente diagrama de secuencia UML siguiente:

El diagrama de secuencia anterior define los elementos que intervienen desde un punto de vista del intercambio de mensajes los cuales son: Extractor, el cual realiza las operaciones de extracción con la función extractData(); Transformed, el cual realiza la transformación de los datos con la función doTransformer(); Loader, el cual realiza las operaciones de carga con la función doLoader(); ModuleLayer, el cual contiene el programa que define las operaciones de coordinación del resto de elementos utilizados en la función run(); y, por último, un actor que activa el inicio de las operaciones.

Una vez identificados las entidades abstractas y el intercambio de mensajes, estamos en disposición de profundizar en los elementos físicos que intervienen en la solución y, para ello, emplearemos un diagrama de clases UML para definir la vista estática de la solución. Así, el diagrama de clases con la arquitectura software es la siguiente:

Comenzando en la parte superior del diagrama, tenemos los elementos que definen el extractor. Se define un objeto con nombre Extractor el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de extracción con la función extractData; esta función, retorna un elemento de la librería ZIO de tipo IO el cual tiene los siguientes tipos: como valor erróneo, retorna una excepción de tipo ExtractException; y, como retorno de éxito, retorna un ADT de tipo ExtractDataResult. El objeto Extractor tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asociado a la clase que implementa el servicio ExtractorImpl. Para finalizar, se define un objeto de paquete para enlazar las funciones del objeto Extractor. El snippet del código es el siguiente:

  type Extractor = Has[Extractor.Service]
  object Extractor {
    trait Service {
      def extractData(): IO[ExtractorException, ExtractDataResult]
    }

    case class ExtractorImpl() extends Extractor.Service {
      override def extractData(): IO[ExtractorException, ExtractDataResult] =
        ZIO.succeed(OkExtract(id = 1, name = "Test1", result = true))
    }

    val live: ZLayer[Any, Nothing, Extractor] = ZLayer.succeed(ExtractorImpl())
  }
[...]
 import ModuleLayerExample4Module.Extractor
 package object extractor {
   def extractData = ZIO.accessM[Extractor](_.get.extractData())
 }

Continuando en la parte media del diagrama tenemos los elementos que definen el transformador Transformer. Se define un objeto con nombre Transformer el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de transformación con la función doTransformer; esta función, retorna un elemento de la librería ZIO de tipo IO el cual tiene los siguientes tipos: como valor erróneo, retorna una excepción de tipo TransformerException; y, como retorno de éxito, retorna un ADT de tipo TransformedResult. El objeto Transformer tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asociado a la clase que implementa el servicio TransformedImpl. Para finalizar, se define un objeto para enlazar las funciones del objeto Transformer. El snippet del código es el siguiente:

  type Transformer = Has[Transformer.Service]
  object Transformer {
    trait Service {
      def doTransformer(data: ExtractDataResult): IO[TransformedException, TransformedResult]
    }

    case class TransformerImpl() extends Transformer.Service {
      override def doTransformer(data: ExtractDataResult): IO[TransformedException, TransformedResult] =
        data match {
          case dataIn: OkExtract => ZIO.succeed(OkTransformed(id = dataIn.id, name = dataIn.name, result = true))
          case _                 => ZIO.fail(BasicTransformedException())
        }
    }

    val live: ZLayer[Any, Nothing, Transformer] = ZLayer.succeed(TransformerImpl())
  }
[...]
import ModuleLayerExample4Module.Transformer
package object transformer {
  def transformer(data: ExtractDataResult) = ZIO.accessM[Transformer](_.get.doTransformer(data))
}

En la parte inferior del diagrama tenemos los elementos que definen el cargador Loader. Se define un objeto con nombre Loader el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de carga con la función doLoader; esta función, retorna un elemento de la librería ZIO de tipo Task con un ADT de tipo LoaderResult. El tipo Task de ZIO es aquel tipo definido para tareas asíncronas. El objeto Loader tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asocoado a la clase que implementa el servicio LoaderImpl. Para finalizar, se define un objeto de paquete para enlazar las funciones del objeto Transformer. El snippet del código es el siguiente:

  type Loader = Has[Loader.Service]
  object Loader {
    trait Service {
      def doLoader(data: TransformedResult): Task[LoaderResult]
    }

    case class LoaderImpl() extends Loader.Service {
      override def doLoader(data: TransformedResult): Task[LoaderResult] =
        data match {
          case dataIn: OkTransformed =>
            ZIO.fromFuture(implicit ec => loaderData(dataIn)).mapError(msg => new ErrorLoaderException())
          case _ => ZIO.fail(BasicLoaderException())
        }
    }

    val live: ZLayer[Any, Nothing, Loader] = ZLayer.succeed(LoaderImpl())
  }
[...]
import ModuleLayerExample4Module.Loader
package object loader {
  def loader(data: TransformedResult) = ZIO.accessM[Loader](_.get.doLoader(data))
}

En la parte izquierda del diagrama, se define el módulo controlador ModuleLayerExample4 con el cual declaramos el programa con las definiciones de las operaciones del proceso ETL. El snippet del módulo es el siguiente:

  type Services = Extractor with Transformer with Loader with Logging

  // Log layer
  val envLog =
    Logging.console(
      logLevel = LogLevel.Info,
      format = LogFormat.ColoredLogFormat()
    ) >>> Logging.withRootLoggerName("ModuleLayerExample4")

  val appEnvironment = envLog >+> Extractor.live >+> Transformer.live >+> Loader.live

  def program(): ZIO[Services, Throwable, Boolean] = {
    (for {
      _               <- log.info("[START]")
      dataExtracted   <- extractData
      _               <- log.info(s"[extrated done] data = ${dataExtracted}")
      dataTransformed <- transformer(dataExtracted)
      _               <- log.info(s"[transformed done] data = ${dataTransformed}")
      dataLoaded      <- loader(dataTransformed).catchAllCause(cause => log.info(s"Exception Loader=${cause.prettyPrint}"))
      _               <- log.info(s"[loaded done] data = ${dataLoaded}")
      _               <- log.info(s"[END]")

    } yield { true }) orElse ZIO.succeed(false)

  }

  override def run(args: List[String]): URIO[ZEnv, ExitCode] = {
    (program()
      .catchAllCause(cause => putStrLn(s"Exception=${cause.prettyPrint}"))
      .exitCode)
      .provideCustomLayer(appEnvironment)

  }

Lo primero que se define es el tipo Services el cual contiene las funciones a utilizar; en nuestro caso, definimos un tipo con un conjunto de tipos: Extractor, Transforamer, Loader y Logging definidos previamente. El objetivo de este tipo es definir todas aquellas funciones que estarán disponibles en el programa a declarar, en nuestro caso, el programa que declara la funcionalidad del proceso ETL, así, podremos «inyectar» al programa las funciones que necesitemos.

A continuación, se define la referencia al log y al entorno de ejecución del programa, es decir, define aquellos elementos que contienen las implementaciones de las funciones a utilizar.

Para finalizar se define la función que contiene el programa con las operaciones de la ETL. La función retorna un tipo ZIO con la siguiente composición: como entorno de ejecución, tiene un tipo de tipo Services; como tipo de retorno de error define un tipo Throwable; y, como tipo de resultado de éxito, retorna un tipo Boolean.

Dado que el módulo ModuleLayerExample4 es un objeto de la clase zio.App se debe de definir e implementar la función run() la cual realiza la invocación del la función del programa ETL suministrando las capas de los módulos definidas en el elemento appEnvironment.

Al lector interesado puede acceder al código en el siguiente enlace.

La utilización de la librería ZIO permite tener programas modulares, declarativos y seguros. No tenemos que preocuparnos de realizar una inyección de dependencias sino que hay que definir conjunto de tipos con la funcionalidad necesarias la cual utilizaremos en los programas; y, sobre todo, aclarar el proceso de diseño y desarrollo ya que permite definir los componentes o módulos que intervienen en la solución y sus relaciones. Una vez que se tienen claros los módulos y las firmas de los métodos nos permite sin haber desarrollado cada función una estructura de la solución final.

ZIO III: testing

Continuamos con al serie de la librería ZIO. En la entrada que estamos tratando, ZIO III: testing, me centraré en la definición de test. Las entradas publicadas hasta la fechas son las siguientes:

Los ejemplos mostrados en las entradas anteriores, se han realizado utilizando aserciones de test de prueba o bien mediante código no definido en un test. Para definir test y aserciones claras y concisas, definiremos unos patrones y ejemplos en los siguientes apartados, lo cuáles son:

  • Ejemplo de plantillas.
  • Generación de propiedades en los test.
  • Ejemplo de aserciones.

1.- Ejemplo de plantillas

Las pruebas unitarias tienen que ser categorizadas por funcionalidad y, para conseguir categorias funciones de test, empleamos la función suite. La función suite permite definir pruebas agrupados por una funcionalidad a probar. El conjunto de todas las agrupaciones forman las pruebas de una entidad.

Un requerimiento para la definición de test es que cada clase de test debe de heredar de la clase DefaultRunnableSpec la cual proporciona todos los módulos de ZIO; como por ejemplo: Clock o Random. Un ejemplo de test es el descrito en la siguiente entrada:

import zio.test._
import zio.clock.nanoTime
import Assertion._

import zio.test.DefaultRunnableSpec

object TemplateZioTest extends DefaultRunnableSpec {

    val suite1 = suite("suite1")(
      testM("s1.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s1.t2") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    val suite2 = suite("suite2")(
      testM("s2.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s2.t2") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s2.t3") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    val suite3 = suite("suite3")(
      testM("s3.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    def spec = suite("All test")(suite1, suite2, suite3)

}

2.- Generación de propiedades en los test.

En cierto tipo de test requerimos de datos para ejecutar las pruebas. Los datos pueden ser generados de forma automática por generadores los cuales pueden generar datos primitivos, case class o bien objetos. La entidad para la generación de datos es la entidad Gen definida en zio.test.Gen.

Un requisito fundamental es la necesidad de utilizar el módulo Random con Sized en la definición de los generadores.
Las dependencias de los módulos de los ejemplos es el siguiente:

import zio.test.Assertion.{equalTo, isTrue}
import zio.test.{DefaultRunnableSpec, Gen, Sized, assert, check, suite, testM}
import zio.random.Random
import zio.test.magnolia._
  • Ejemplo de generación de tipos primitivos.

Para la generación de tipos primitivos invocaremos a la función anyXXX, siendo XXX un tipo primitivo, en la definición de test. Un ejemplo de uso de generadores primitivos es el que se define en el siguiente snippet.

testM("Gen Int") {
   check(Gen.anyInt, Gen.anyInt, Gen.anyInt) { (x, y, z) =>
     assert((x + y) + z)(equalTo(x + (y + z)))
   }
},
  • Ejemplo de generación de una case class.

Sea una case class que represente una entidad con nombre Point. Para poder definir una generador de la clase Point, utilizamos la entidad DeriveGen cuyo tipo sea la case class Point. La definición de la clase y el generador de la clase Point es la siguiente:

final case class Point(x: Double, y: Double) {
   def isValid(): Boolean = true
}
val genPoint: Gen[Random with Sized, Point] = DeriveGen[Point]

Para definir el test de la entidad Point con su generador utilizaremos la función check como se muestra en el siguiente ejemplo:

testM("Gen Point") {
  check(genPoint) { (point) =>
     assert(point.isValid())(equalTo(true))
   }
},
  • Ejemplo de generación de objetos.

De la misma manera que el caso anterior para definir un generador de unos objetos a partir de un trait, se realiza de la misma manera. En el siguiente ejemplo, se define el test en donde se utiliza un generador de objetos basados en la definición de un trait:

sealed trait Color {
  def isValid(): Boolean = true
}
case object Red   extends Color
case object Green extends Color
case object Blue  extends Color
val genColor: Gen[Random with Sized, Color] = DeriveGen[Color]

testM("Gen Color") {
   check(genColor) { (color: Color) =>
      assert(color.isValid())(isTrue)
   }
}

3.- Ejemplo de aserciones.

La capacidad de poder verificar todo tipo de dato en una prueba permite definir con más exactitud la ejecución de una prueba. En ZIO empleamos las funciones definidas en la entidad zio.test.Assertion; como pueden ser: equalTo, hasField, isRight,etc…

A continuación, muestro unos ejemplos de pruebas con diferentes tipos de aserciones:

  • Ejemplo de un String.

Supongamos que necesitamos verificar el resultado de un efecto cuyo resultado es un String y, del valor del resultado,
necesitamos verificar que contenga un determinado valor y finalice con otro. La aserción la realizamos empleando la función assert y las funciones containsString y endsWithString de la siguiente manera:

testM("Assertion examples: string") {
  for {
    word <- IO.succeed("The StringTest")
  } yield {
    assert(word)(
       Assertion.containsString("StringTest") &&
          Assertion.endsWithString("Test")
     )
   }
},
  • Ejemplo de un Either.

Supongamos que necesitamos verificar el resultado de un efecto cuyo resultado es un Either. El esquema del test es parecido al anterior pero empleando funciones específicas para el contenedor binario. El ejemplo del snippet es el siguiente:

testM("Assertion examples: either") {
  for {
     either <- IO.succeed(Right(Some(2)))
  } yield {
     assert(either)(isRight(isSome(equalTo(2))))
  }
},
  • Ejemplo de una case class.

Supongamos que necesitamos verificar el resultado de un efecto que retorna una entidad definida en una case class. El esquema del test es como los anteriores pero utilizando la función hasField para acceder a los atributos de la entidad. El ejemplo del snippet es el siguiente:

testM("Assertion examples: case class") {
   final case class Address(country: String, city: String)
   final case class User(name: String, age: Int, address: Address)

   for {
      test <- IO.succeed(User("Nat", 25, Address("France", "Paris")))
   } yield {
      assert(test)(
        hasField("age", (u: User) => u.age, isGreaterThanEqualTo(18)) &&
          hasField("country", (u: User) => u.address.country, not(equalTo("USA")))
      )
   }
},

En la siguiente entrada, ZIO IV: modularización, me centraré en la definición de módulos funcionales.

ZIO II: manejo de errores y recursos

En la entrada anterior, ZIO I: presentación, presenté la librería ZIO y ejemplos con la creación de efectos y operaciones básicas. En la presente entrada, ZIO II: manejo de errores y recursos, describiré cómo podemos manejar errores en la ejecución de efectos con ZIO y el manejo de recursos.

ZIO

La estructura de la entrada está compuesta de los siguientes apartados:

  1. Manejo de errores.
  2. Manejo de recursos.

1.- Manejo de errores

Dada la definición de un efecto en ZIO, sabemos cómo proporcionar el entorno y ejecutar dicho efecto; pero, tenemos que dar respuesta a la siguiente pregunta: ¿cómo podemos realizar el control de la ejecución si se produce un error en la ejecución del efecto? La respuesta es sencilla, el control del efecto se realiza capturando y controlando las excepciones que se puedan originar, así como, si se produce un error tener la posibilidad de poder volver a ejecutar el efecto.

  • Tratamiento de error con el contenedor binario Either.

La primera estrategia es empleando un contenedor binario Either mediante la función either en la cual podemos tener los siguientes valores: en Left, el valor de error; o bien en right, el resultado correcto. Este primer ejemplo es el más sencillo porque se asemeja al control de errores de una función.

El ejemplo más básico de la definición de un ejemplo es el siguiente:

val zeither: UIO[Either[String, Int]] = IO.fail("Boom!").either
val result: Either[String, Int]       = Runtime.default.unsafeRun(zeither)
assertResult(Left("Boom!"))(result)
  • Tratamiento de error en un efecto con el tipo explícito.

Supongamos que tenemos un efecto cuyo posible error lo conocemos; supongamos que el efecto, es la lectura de un fichero y, como conocemos, el error en el tratamiento de un fichero es la generación de una excepción de tipo IOException. La solución consiste en la definición de un efecto en el que definamos el tipo de error y su resultado; en concreto, la solución consiste en definir un efecto cuyo tipo de error es una excepción de tipo IOException y su resultado es un tipo List[String] definiendo un tipo UIO[IOException, List[String]].

En el siguiente ejemplo, se define una función que realiza la lectura de un fichero mediante un efecto de tipo UIO, su ejecución y verificación de tratamiento.

def readFile(nameFile: String): UIO[List[String]] = {
  IO.succeed(Source.fromFile(nameFile).getLines().toList)
}
val readFileResult: IO[IOException, List[String]] = readFile(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String]                = Runtime.default.unsafeRun(readFileResult)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)
  • Tratamiento de errores con la función catchAll.

Supongamos que realizamos la lectura de un fichero y queremos capturar todas las posibles excepciones que se puedan producir; para este escenario, utilizamos la función catchAll definida en ZIO. En el siguiente ejemplo, realizamos la lectura de las líneas de un fichero cuyo nombre es pasado por parámetro y, con la función catchAll, capturamos todas las excepciones. Si se produce una excepción entonces realizamos la lectura de un fichero cuyos datos son valores por defecto. El snippet de ejemplo es el siguiente:

 def readFileCatchAll(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).catchAll {
     case _ => {
       val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
       readFile(uriFile.getPath)
     }
   }
 }

 val readFileOK: Task[List[String]] = readFileCatchAll(getURIFileTest(nameFile).getPath)
 val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
 assert(resultReadFileOK.isEmpty === false)
 assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

 val readFileKO: Task[List[String]] = readFileCatchAll("errorFile.data")
 val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
 assert(resultReadFileKO.isEmpty === false)
 assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un error con la función catchSome.

Supongamos que queremos capturar un tipo determinado de excepción, en este supuesto utilizamos la función catchSome. En el siguiente ejemplo, se muestra el mismo ejemplo del apartado anterior pero realizando el tratamiento para la excepción FileNotFoundException. El snippet del ejemplo es el siguiente:

 def readFileOrDefault(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).catchSome {
     case _: FileNotFoundException => {
       val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
       readFile(uriFile.getPath)
     }
   }
 }

val readFileOK: Task[List[String]] = readFileOrDefault(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileOrDefault("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Ejecución de un efecto alternativo con la función orElse.

Supongamos que queremos ejecutar un efecto y, suponiendo que se produzca un error en el efecto, deseamos que se ejecute un efecto secundario; para este supuesto, utilizamos la función orElse. En el siguiente snippet de código se muestra el ejemplo con la función orElse.

def readFileFallback(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).orElse {
     val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
     readFile(uriFile.getPath)
    }
}

val readFileOK: Task[List[String]] = readFileFallback(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFallback("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un efecto de forma no pura.

Supongamos que queremos retornar el resultado y no realizar un tratamiento específico, es decir, si el efecto se ejecuta sin problemas retornamos el resultado; pero, si se produce un error retornamos un resultado del tipo esperado; para este supuesto, utilizamos la función fold. En el siguiente snippet de código se muestra el ejemplo con la función fold:

def readFileFold(nameFile: String): Task[List[String]] = {
  ZIO(Source.fromFile(nameFile).getLines().toList).fold(_ => List("OK"), data => data)
}

val readFileOK: Task[List[String]] = readFileFold(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFold("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un efecto de forma pura.

El caso contrario al ejemplo anterior es definir un efecto para el caso de éxito y caso de error mediante la función foldM. En el siguiente snippet de código se muestra el ejemplo con la función foldM.

def readFileFoldM(nameFile: String): Task[List[String]] = {
  ZIO(Source.fromFile(nameFile).getLines().toList)
    .foldM(_ => ZIO.succeed(List("OK")), data => ZIO.succeed(data))
}

val readFileOK: Task[List[String]] = readFileFoldM(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFoldM("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento con reintento de ejecución.

Supongamos que queremos reintentar ejecutar un efecto si se produce un error un número determinado de veces y,
si dado ese número de reintentos no tenemos éxito, capturar la excepción y retornar un efecto con un resultado por defecto; para este caso, utilizamos la función retry para definir un número de reintentos con un Schedule y la función catchAll. El snippet de código con el ejemplo es el siguiente:

import zio.clock.Clock
  [...]
  def readFileRetrying(nameFile: String): ZIO[Clock, Throwable, List[String]] = {
    ZIO(Source.fromFile(nameFile).getLines().toList)
      .retry(Schedule.recurs(5))
      .catchAll { case _ =>
        ZIO.succeed(List("OK"))
      }
}

val readFileOK: ZIO[Clock, Throwable, List[String]] = readFileRetrying(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String]                  = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: ZIO[Clock, Throwable, List[String]] = readFileRetrying("errorFile.data")
val resultReadFileKO: List[String]                  = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)

Para el lector interesado en el código de los ejemplos, puede acceder al mismo a través del siguiente enlace.

2.- Manejo de recursos

Para el manejo de recursos es necesario definir un patrón estructural basado en la estructura try/finally. Supongamos que definimos un efecto y, una vez que finaliza su ejecución, queremos ejecutar un segundo efecto de finalización; para ello, utilizamos la función ensuring. Un ejemplo de patron try/finally con efectos en ZIO es el siguiente:

val finalizer2: UIO[Unit] = UIO.effectTotal(println("finally"))
val operation: UIO[Unit] = IO.succeed(println("Finalizing 2!")).ensuring(finalizer2)
val resultOperation      = Runtime.default.unsafeRun(operation)
assertResult(())(resultOperation)

Otra forma de aplicar el patrón try/finally es utilizando la función bracket en la cual se realiza una adquisición de un recurso, una tratamiento y un cierre de recurso. Un ejemplo de utilización de función bracket con un fichero es el siguiente:

def readFileBracket(nameFile: String): Task[List[String]] =
  UIO(Source.fromFile(nameFile)).bracket(bufferedSource => UIO(bufferedSource.close())) { file =>
    UIO(file.getLines().toList)
  }

val file: Task[List[String]] = readFileBracket(getURIFileTest(nameFile).getPath)
val resultFile               = Runtime.default.unsafeRun(file)
assertResult(List("1 2 3", "4 5 6"))(resultFile)

Para el lector interesado en el código de los ejemplos, puede acceder al mismo a través del siguiente enlace.

En el siguiente ejemplo, ZIO III: testing, describiré unos patrones para la realización de test con ZIO.

ZIO I: presentación

Inicio una serie de entradas de la librería ZIO. En la presente entrada, ZIO I: presentación, realizaré una presentación y realizaré unos ejemplos básicos introductorios.

ZIO es aquella librería en Scala para ejecutar tareas asíncronas y tareas de programación concurrente la cual es una librería funcional pura. La librería está inspirada en la mónada IO de Haskell.

El tipo de dato ZIO está compuesta por tres parámetros como sigue: ZIO[R, E, A]. Los tipos tienen la siguiente definición semántica:

  • R , Tipo de entorno.- El efecto requiere un tipo de entorno representado por R. Si el tipo está definido como Any, significa que no tiene requerimiento porque no necesitas un valor.
  • E , Tipo de fallo.- El efecto puede terminar en error con un tipo definido en E. Si puede terminar con error, se define con el tipo Throwable; si no puede terminar con error, se define con el tipo Nothing.
  • A, Tipo de éxito.- El efecto puede terminar con un tipo de éxito representado por el tipo A. Si el tipo es Unit, significa que el efecto no retorna información; si el tipo es Nothing, significa que el efecto está corriendo de forma indefinida

Unos ejemplos de definición de un tipo ZIO pueden ser los siguientes:

  • ZIO[Any, IOException, String].- Definición de un tipo que no tiene un requerimiento, retorna un valor de tipo String y, si se produce un error, retorna un elemento de tipo IOException.
  • ZIO[String, Throwable, Int].- Definición de un tipo con un requerimiento de tipo String, retorna un valor de tipo entero y, si se produce un error, retorna un elemento de tipo Throwable.

El requerimiento hay que visualizarlo como el valor de entrada al efecto para que sea procesado.

La librería ZIO define un conjunto de alias para poder trabajar de forma sencilla. Los alias definidos son las siguientes:

  • IO[E, A].- IO es el alias de ZIO[Any, E, A]. Define un efecto que no tiene requerimientos, el error puede ser de tipo E y el resultado es de tipo A.
  • UIO[A].- UIO es el alias de ZIO[Any, Nothing, A]. Define un efecto que no tiene requerimientos,
  • URIO[R, A].- URIO es el alias de ZIO[R, Nothing, A]. Define un efecto que tiene un requerimiento de tipo R, no puede tener un error y el resultado es de tipo A.
  • Task[A].- Task es el alias de ZIO[Any, Throwable, A]. Define un efecto que no tiene un requerimiento, el tipo de error es de tipo Throwable y el resultado es de tipo A.
  • RIO[R, A].- RIO es el alias de ZIO[R, Throwable, A]. Define un efecto que tiene un requerimiento de tipo R, el tipo de error es de tipo Throwable y el resultado de de tipo A.

La definición de las dependencias de la librería ZIO en un proyecto gestionado con sbt son las siguientes:

val zio = "1.0.3"
lazy val zio_core  = "dev.zio" %% "zio" % Versions.zio
lazy val zio_streams  = "dev.zio" %% "zio-streams" % Versions.zio
lazy val zio_test = "dev.zio" %% "zio-test"  % Versions.zio % "test"
lazy val zio_test_sbt = "dev.zio" %% "zio-test-sbt"  % Versions.zio % "test"
lazy val zio_test_magnolia = "dev.zio" %% "zio-test-magnolia" % Versions.zio % "test" 

La estructura de la entrada está compuesta de los siguientes apartados:

  1. Creación de efectos.
  2. Operaciones básicas.

1.- Creación de efectos

En el presente apartado, mostraré ejemplos básicos para la definición de efectos con ZIO. Son ejemplos muy simples pero son aclaratorios para dar los primeros pasos. La definición de efectos se muestra en los siguientes puntos:

  • Efecto succeed.- empleamos la función succeed para crear un efecto cuyo resultado es exitoso.
val int42 = for {
   intS1 <- ZIO.succeed(42)
} yield (intS1)
val resultInt42 = Runtime.default.unsafeRun(int42)
assert(42 === resultInt42)
  • Efecto fail.- empleamos la función fail para crear un efecto cuyo resultado no es satisfactorio.
val f1: zio.URIO[Any, Either[String, Nothing]] = ZIO.fail("Uh oh!").either
val resultFailf1: Either[String, Nothing]      = Runtime.default.unsafeRun(f1)
assertResult(resultFailf1)(Left("Uh oh!"))
  • Efecto effectTotal.- empleamos la función effectTotal cuando estamos seguro que el efecto no tiene un efecto de lado.
val effectTotal: Task[Long] = ZIO.effectTotal(System.currentTimeMillis())
val resultEffectTotal: Long = Runtime.default.unsafeRun(effectTotal)
assert(resultEffectTotal > 0)
  • Efecto fromOption.- empleamos la función fromOption para crear un efecto a partir de un tipo Option.
val zoption: IO[Option[Nothing], Int] = ZIO.fromOption(Some(2))
val resultZOption: Int                = Runtime.default.unsafeRun(zoption)
assert(2 === resultZOption)
  • Efecto fromEither.- empleamos la función fromEither para crear un efecto a partir de un tipo Either.
val zeither: IO[Either[Exception, String], String] = ZIO.fromEither(Right("Success"))
val resultZeither: String                                         = Runtime.default.unsafeRun(zeither)
assert("Success" === resultZeither)
  • Efecto fromTry.- empleamos la función fromTry para crear un efecto a partir de un tipo Try.
 val ztry: Task[Int] = ZIO.fromTry(Try(40 / 2))
 val resultZTry: Int = Runtime.default.unsafeRun(ztry)
 assert(20 === resultZTry)
  • Efecto fromFunction.- empleamos la función fromFuction para crear un efecto a partir de una función.
val zfun: URIO[Int, Int] = ZIO.fromFunction((i: Int) => i * i)
val resultZfun: Int      = Runtime.default.unsafeRun(zfun.provide(5))
assert(25 === resultZfun)
  • Efecto fromFuture.- empleamos la función fromFuture para crear un efecto a partir de un Future.
lazy val future = Future.successful("Hi!")
val zfuture: Task[String] = ZIO.fromFuture { implicit ec =>
   future.map(_ => "Goodbye!")
}
val resultZFuture: String = Runtime.default.unsafeRun(zfuture)
assert("Goodbye!" === resultZFuture)
  • Efecto desde un efecto de lado.- Definimos una función con un efecto de lado, en el ejemplo, escribir un texto en la consola.
def putStrLn(line: String): UIO[Unit] =
  ZIO.effectTotal(println(line))
val resultPut: Unit = Runtime.default.unsafeRun(putStrLn("Test"))
assert(resultPut === ())

Para el lector interesado, puede acceder al código de los ejemplos del apartado mediante el siguiente enlace.

2.- Operaciones básicas

Una vez visto cómo podemos crear efectos en ZIO, estamos en disposición de mostrar ejemplos de operaciones básicas. Las operaciones consisten en la declaración de un programa con un conjunto de operaciones; esas operaciones, pueden ser puras, o bien, pueden tener efectos de lado, por ejemplo: la lectura desde consola, o bien, escribir en la salida estándar. El primer ejemplo que voy a mostrar es la definición de una función que muestre un mensaje por pantalla y la lectura de consola. Para realizar el programa, definiremos dos funciones: getStrlLn, función que realiza la lectura por consola; putStrLn, función que escribe un mensaje en la consola. La definición de las funciones son las siguientes:

val getStrLn: Task[String] = ZIO.effect(StdIn.readLine())
def putStrLn(line: String): UIO[Unit] = ZIO.effectTotal(println(line))

Una vez definido las funciones básicas, definimos el programa que realiza la concatenación de las funciones anteriores mediante la función exampleChaining y, como segunda opción, definimos el mismo programa utilizando for comprehension. Los snippet de las funciones son las siguientes:

def exampleChaining(): Unit = {
      val operation1 = getStrLn.flatMap(input => putStrLn(s"-->${input}"))
      Runtime.default.unsafeRun(operation1)
}

def exampleForComprenhensions(): Unit = {
      val program = for {
        _    <- putStrLn("Nombre")
        name <- getStrLn
        _    <- putStrLn(s"Value=${name}")
      } yield ()
      Runtime.default.unsafeRun(program)
}

Otra forma de encadenar efectos es utilizando la función zip, zipRight, o bien, zipLeft. En el siguiente ejemplo, se muestan ejemplos parecidos a los anteriores con la función zipRight y su alias *>. Hay que destacar que la función zipRight realiza la concatenación de efectos y, además, ejecuta una función map para tratar el resultado del primer efecto. El snippet del código es el siguiente:

def exampleZipping(): Unit = {
      val zipRight1               = putStrLn("Name Right 1?").zipRight(getStrLn)
      val resultZipRight1: String = Runtime.default.unsafeRun(zipRight1)
      println(s"=>${resultZipRight1}")
      val zipRight2               = putStrLn("Name Right 2?") *> getStrLn
      val resultZipRight2: String = Runtime.default.unsafeRun(zipRight2)
      println(s"=>${resultZipRight2}")
}

Para el lector interesado, puede acceder al código de los ejemplos del apartado mediante el siguiente enlace.

En la siguiente entrada, ZIO II: manejo de errores y recursos, continuaremos describiendo la librería ZIO centrándonos en cómo se manejan errores y recursos mediante.

Numpy III

Terminamos la serie de entradas de la librería Numpy. En la presente entrada, Numpy III, presento unos ejemplos finales para el uso de la librería. Además de la librería Numpy, utilizaré la librería matplotlib para la visualización de datos.

Para el lector interesado, puede acceder a las entradas previas en los siguientes enlaces:

Los ejemplos de utilización de la librería Numpy a presentar en esta entrada son los siguientes:

  1. Generación aleatoria de números.
  2. Transformación de arrays de una dimensión a otra.
  3. Álgebra linear.
  4. Operaciones sobre conjuntos.
  5. Serialización de arrays.

1.- Generación aleatoria de números.

Para la generación de números aleatorios emplearemos la función randn; para la definición de la semilla de generación, emplearemos la función seed; y, para definir un objeto de estado para la generación de números, emplearemos el objeto RandomState.

Para definir una distribución, empleados la función scatter del módulo matplotlib.pyplot; y, para representar desde un punto de vista gráfico, se emplea la función show. El snippet de código de ejemplo es el siguiente:

seed = np.random.seed(123)
randn = np.random.randn(3)
print(f'randn=\n{randn}\n')

rang1 = np.random.RandomState(seed=123)
array_rang1 = rang1.randn(3)
print(f'array_rang1=\n{array_rang1}\n')

rang2 = np.random.RandomState(seed=123)
array_rang2 = rang2.randn(100, 2)  # Retorna una distribución normal
print(f'array_rang2=\n{array_rang2}\n')

plt.scatter(array_rang2[:, 0], array_rang2[:, 1])

rang3 = np.random.RandomState(seed=123)
normal_distribution = 2. * rang3.randn(100, 2) + 5.
plt.scatter(normal_distribution[:, 0], normal_distribution[:, 1])
plt.show()

La salida por consola son los siguientes:

randn=
[-1.0856306   0.99734545  0.2829785 ]

array_rang1=
[-1.0856306   0.99734545  0.2829785 ]

array_rang2=
[[-1.08563060e+00  9.97345447e-01]
   ...
 [-3.41261716e-01 -2.17946262e-01]]

La gráfica generada en el ejemplo anterior es la que se muestra en el siguiente gráfico:

2.- Transformación de arrays de una dimensión a otra.

La creación de array n-dimensionales los podemos crear a partir de un array de una dimensión. Para realizar el cambio de dimensión, se realiza con la función reshape; con esta función, se puede especificar las dimensiones de forma explícita o implícito. Para realizar el proceso inverso, empleamos la función flatten; y, por último, para concatenar arrays, se emplea la función concatenate. En el siguiente snippet se muestra ejemplos de uso:

array = np.array([1, 2, 3, 4, 5, 6])
print(f'array=\n{array}\n')
array_23 = array.reshape(2, 3)  
print(f'array_23=\n{array_23}\n')
print(f'may_share_memory=\n{np.may_share_memory(array_23, array)}\n')

array_2_1 = array.reshape(2, -1)  
print(f'array_2_1=\n{array_2_1}\n')

array_1_2 = array.reshape(-1, 2)  
print(f'array_1_2=\n{array_1_2}\n')

array2 = np.array([[1, 2, 3],
                      [4, 5, 6]])
print(f'array2=\n{array2}\n')
array2_1 = array2.reshape(-1)  
print(f'array2_1=\n{array2_1}\n')
print(f'array2.ravel()=\n{array2.ravel()}\n')  # ravel es flatten

print(f'np.may_share_memory(array2.flatten(), array2)={np.may_share_memory(array2.flatten(), array2)}')  # False
print(f'np.may_share_memory(array2.ravel(), array2)  ={np.may_share_memory(array2.ravel(), array2)}')  # True

ary = np.array([1, 2, 3])
ary_concatenate = np.concatenate((ary, ary))
print(f'ary_concatenate=\n{ary_concatenate}\n')

La salida por consola es la siguiente:

array=
 [1 2 3 4 5 6]

array_23=
  [[1 2 3]
   [4 5 6]]

may_share_memory=
    True

array_2_1=
  [[1 2 3]
  [4 5 6]]

array_1_2=
  [[1 2]
   [3 4]
   [5 6]]

array2=
  [[1 2 3]
   [4 5 6]]

array2_1=
 [1 2 3 4 5 6]

array2.ravel()=
 [1 2 3 4 5 6]

np.may_share_memory(array2.flatten(), array2)=False
np.may_share_memory(array2.ravel(), array2)  =True
ary_concatenate=
   [1 2 3 1 2 3]

3.- Algebra linear.

La multiplicación de matrices así como la operación con la matriz transpuesta es una operación típica, en el presente apartado, presentamos dos funciones para realizar el producto de dos matrices: función matmul, multiplica dos matrices pasadas por parámetro; y, la función dot, realiza la misma funcionalidad pero más eficiente. Para calcular la función transpuesta, empleamos la función T de un array. En el siguiente ejemplo, se muestra un snippet de código con ejemplos de productos de matrices.

matrix = np.array([[1, 2, 3],
                    [4, 5, 6]])
column_vector = np.array([[1, 2, 3]]).reshape(-1, 1)
print(f'matrix=\n{matrix}\n')
print(f'column_vector=\n{column_vector}\n')

result = np.matmul(matrix, column_vector)
print(f'matrix X column_vector=\n{result}\n')

# Más eficiente con dot.
print(f'np.dot(row_vector, row_vector)=\n{np.dot(row_vector, row_vector)}\n')
print(f'np.dot(matrix, row_vector)=\n{np.dot(matrix, row_vector)}\n')
print(f'np.dot(matrix, column_vector)=\n{np.dot(matrix, column_vector)}\n')

print(f'matrix.transpose()=\n{matrix.transpose()}\n')
print(f'matrix.T=\n{matrix.T}\n')
print(f'np.dot(matrix, matrix.T)=\n{np.dot(matrix, matrix.T)}\n')
print(f'np.matmul(matrix, matrix.T)=\n{np.matmul(matrix, matrix.T)}\n')

La salida por consola es la siguiente:

matrix=
   [[1 2 3]
    [4 5 6]]

column_vector=
   [[1]
    [2]
    [3]]

matrix X column_vector=
  [[14]
   [32]]

np.dot(row_vector, row_vector)=
  14

np.dot(matrix, row_vector)=
    [14 32]

np.dot(matrix, column_vector)=
    [[14]
     [32]]

matrix.transpose()=
    [[1 4]
     [2 5]
     [3 6]]

matrix.T=
    [[1 4]
     [2 5]
     [3 6]]

np.dot(matrix, matrix.T)=
    [[14 32]
     [32 77]]

np.matmul(matrix, matrix.T)=
    [[14 32]
     [32 77]]

4.- Operaciones sobre conjuntos.

Las operaciones de intersección, diferencia, unión o conjunto único sin repeticiones, se realizan respectivamente con las siguientes funciones: intersect1d, setdiff1d, union1d y unique. En el siguiente snippet de código se muestra ejemplos de uso:

array = np.array([1, 1, 2, 3, 1, 5])
array_set = np.unique(array)
print(f'array_set=\n{array_set}\n')

array1 = np.array([1, 2, 3])
array2 = np.array([3, 4, 5, 6])
print(f'array1=\n{array1}\n')
print(f'array2=\n{array2}\n')

array_intersec = np.intersect1d(array1, array2, assume_unique=True)
print(f'array_intersec=\n{array_intersec}\n')

array_diff = np.setdiff1d(array1, array2, assume_unique=True)  # aaray1 - array2
print(f'array_diff=\n{array_diff}\n')

array_union = np.union1d(array1, array2)La librería Numpy es aquella librería pensada y preparada para realizar operaciones matemáticas orientadas a distintos ámbitos de la ciencia la cual, en mi caso, me permita profundizar en casos prácticos de Machine Learning.
print(f'array_union=\n{array_union}\n')

La salida por consola es la siguiente:

array_set=
[1 2 3 5]

array1=
[1 2 3]

array2=
[3 4 5 6]

array_intersec=
[3]

array_diff=
[1 2]

array_union=
[1 2 3 4 5 6]

5.- Serialización de arrays.

Para almacenar los valores de un array en un fichero, empleamos la función save; para almacenar un array con los índices, se emplea la función savez; y, para realizar la carga de un fichero en memoria, se emplea la función load. Los ficheros con los que se operan tienen extensión .npz.

En el siguiente snippet de código se muestra unos ejemplos de uso de estas funciones:

array = np.array([1, 2, 3])
np.save('ary-data.npy', array)

data_file = np.load('ary-data.npy')
print(f'data_file=\n{data_file}\n')

array2 = np.array([1, 2, 3, 4, 5, 6])
np.savez('ary2-data.npz', array, array2)  

ary2_data = np.load('ary2-data.npz')
print(f'ary2_data=\n{ary2_data}\n')

array2_key = ary2_data.keys()
print(f'array2_key=\n{array2_key}\n')
print(f'ary2_data["arr_0"]=\n{ary2_data["arr_0"]}\n')  
print(f'ary2_data["arr_1"]=\n{ary2_data["arr_1"]}\n')

kwarg = {'ary1': array, 'ary2': array2}
np.savez('ary3-data.npz', **kwarg)

ary3_data = np.load('ary3-data.npz')
print(f'ary3_data=\n{ary3_data}\n')
print(f'ary3_data["ary1"]=\n{ary3_data["ary1"]}\n')  
print(f'ary3_data["ary2"]=\n{ary3_data["ary2"]}\n')

La salida por consola es la siguiente:

data_file=
[1 2 3]

ary2_data=
<numpy.lib.npyio.NpzFile object at 0x7f580fa5a128>

array2_key=
KeysView(<numpy.lib.npyio.NpzFile object at 0x7f580fa5a128>)

ary2_data["arr_0"]=
 [1 2 3]

ary2_data["arr_1"]=
  [1 2 3 4 5 6]

ary3_data=
  <numpy.lib.npyio.NpzFile object at 0x7f57e8b7def0>

ary3_data["ary1"]=
  [1 2 3]

ary3_data["ary2"]=
  [1 2 3 4 5 6]

La librería Numpy es aquella librería pensada y preparada para realizar operaciones matemáticas orientadas a distintos ámbitos de la ciencia la cual, en mi caso, me permita profundizar en casos prácticos de Machine Learning.

Numpy II

En la entrada anterior, Numpy I, realicé una presentación de la librería Numpy y realicé la descripción de unos ejemplos básicos. En la presenta entrada, Numpy II, continuaré presentando ejemplos de operaciones con la Numpy.

Los ejemplos de utilización de la librería Numpy a presentar en esta entrada son los siguientes:

  1. Operaciones de arrays con sus dimensiones.
  2. Indexación avanzada.
  3. Operaciones de comparación.

1.- Operaciones de arrays con sus dimensiones.

Los arrays son estructuras n-dimensionales con los cuáles podemos realizar sumas a pesar de tener diferente número de dimensión. A continuación, muestro una serie de ejemplos.

Sean dos arrays con la misma dimensión y número de elementos por dimensión, la operación suma se realiza con el operador +. El snippet del código es el siguiente:

array1 = np.array([1, 2, 3])
array2 = np.array([4, 5, 6])
print(f'array1 + array2={array1 + array2}')

La salida por consola es la siguiente:

array1 + array2=[5 7 9]

Sea un array de dos dimenciones con tres elementos por dimensión y un array de una dimensión e igual número de elementos que el primero. El snippet del código es el siguiente:

array3 = np.array([[7, 8, 9], [3, 2, 1]])
print(f'array3 + array1={array3 + array1}')

La salida por consola es la siguiente:

array3 + array1=[[ 8 10 12]
 [ 4  4  4]]

Sea un array de dos dimensiones con tres elementos y un array de dos dimensiones de un elemento por dimensión. El resultado es una array de dos dimensiones de tres elementos en el que se ha incrementado el valor del segundo array. El snippet del código es el siguiente:

array_21 = np.array([[1], [2]])
print(f'array3 + array_21={array3 + array_21}')  

La salida por consola es la siguiente:

array3 + array_21=[[ 8  9 10]
 [ 5  4  3]]

2.- Indexación avanzada.

Para realizar operaciones con parte de los elmentos de un array, se deben de crear referencias a la estructura con la que se desea operar. Si se desea incrementar en 100 la segunda posición de las dimensiones que forman parte de un array, se crea una referencia a las posiciones de todas las dimensiones y se incrementa en 100 con el operador +=, en nuestro ejemplo, center_array; una vez operado, el array inicial contiene el resultado. Hay que destacar que las operaciones no son inmutables y se trabaja con referencias. El snippet del código es el siguiente:

array = np.array([[1, 2, 3], [4, 5, 6]])
center_array = array[:, 1]
center_array += 100
print(f'center_array=\n{center_array}')
print(f'array=\n{array}')

La salida por consola es la siguiente:

center_array=
[102 105]
array=
[[  1 102   3]
 [  4 105   6]]

Si se desea realizar una copia de una dimensión se utiliza la función copy. En el siguiente ejemplo, se realiza una copia de una dimensión de una array inicial. El snippet de ejemplo es el siguiente:

array2 = np.array([[1, 2, 3], [4, 5, 6]])
second_row = array2[1].copy()  
second_row += 100
print(f'second_row=\n{second_row}')
print(f'array2=\n{array2}')

La salida por consola es la siguiente:

second_row=
[104 105 106]
array2=
[[1 2 3]
 [4 5 6]]

Para determinar si una referencia es una copia o forma parte de una estructura se emplea la función may_share_memory. A continuación, se muestran dos ejemplos de ejemplos previos. El snippet de código es el siguiente:

  first_row = array2[:1]
  np.may_share_memory(first_row, array2)
  print(f'np.may_share_memory(first_row, array2)=\n{np.may_share_memory(first_row, array2)}')
  print(f'np.may_share_memory(second_row, array2)=\n{np.may_share_memory(second_row, array2)}')

La salida por consola es la siguiente:

  np.may_share_memory(first_row, array2)=
  True
  np.may_share_memory(second_row, array2)=
  False

Para obtener arrays que sean subconjuntos de un array, podemos realizar seleccion de posiciones de una determinada dimensión. En el siguiente ejemplo, se imprimen todos los elementos de la primera dimensión y el primer y último elemento de la segundo; y, por último, el caso contrario, último y primero. El snippet del código es el siguiente:

array3 = np.array([[1, 2, 3], [4, 5, 6]])
print(f'array3=\n{array3}')  
print(f'array3[:, [0,2]]=\n{array3[:, [0,2]]}')  
print(f'array3[:, [2,0]]=\n{array3[:, [2,0]]}')  

La salida por consola es la siguiente:

array3=
[[1 2 3]
 [4 5 6]]
array3[:, [0,2]]=
[[1 3]
 [4 6]]
array3[:, [2,0]]=
[[3 1]
 [6 4]]

Por último, se pueden realizar operaciones de comparación de los elementos y obtener array lógicos con el resultado. En el siguiente ejemplo, se imprimen los elementos mayores a 3; impresión de los elementos que son mayores a 3; y, por último, se compone un predicado lógico. El snippet del código es el siiguiente:

array3 = np.array([[1, 2, 3], [4, 5, 6]])
print(f'array3 > 3=\n{array3 > 3}')  
print(f'array3[array3 > 3]=\n{array3[array3 > 3]}')  
print(f'array3[(array3 > 2) & (array3 <5)]=\n{array3[(array3 > 2) & (array3 <5)]}')

La salida por consola es la siguiente:

array3 > 3=
[[False False False]
 [ True  True  True]]
array3[array3 > 3]=
[4 5 6]
array3[(array3 > 2) & (array3 <5)]=
[3 4]

3.- Operaciones de comparación.

En el apartado anterior, se presentó la operación de comparación y, en el presente apartado, profundizaremos en las operaciones de comparación. En este primer ejemplo, se muestra una selección de elementos de un array mayores a 2. El snippet del código es el siguiente:

array = np.array([1, 2, 3, 4])
array_mayor_2 = array > 2
print(f'array_mayor_2=\n{array_mayor_2}\n')

La salida por consola es la siguiente:

array_mayor_2=
[False False  True  True]

Con los resultados lógicos podemos realizar operaciones para poder operar con ellos. Para ello, podemos emplear las siguientes funciones:

  • Función sum.- función que cuantifica el número de elementos que cumplen la función.
  • Función nonzero.- función que retorna el índice de la posición en la dimensión.

En el siguiente snippet se muestran ejemplos a partir del array inicial:

print(f'array[array_mayor_2]=\n{array[array_mayor_2]}\n')
print(f'array_mayor_2.sum()=\n{array_mayor_2.sum()}\n')  
print(f'array_mayor_2.nonzero()=\n{array_mayor_2.nonzero()}\n')  
print(f'(array > 2).nonzero()=\n{(array > 2).nonzero()}\n')  

La salida por consola es la siguiente:

array[array_mayor_2]=
[3 4]

array_mayor_2.sum()=
 2

array_mayor_2.nonzero()=
(array([2, 3]),)

(array > 2).nonzero()=
(array([2, 3]),)

Para poder realizar un tratamiento más específico, podemos utilizar la función where en la cuál podemos declarar qué valor asignar al resultado si cumple una condición, o bien, cuando no la cumple. En el siguiente snippet se muestra un ejemplo de uso de la función where:

array_where = np.where(array > 2)
print(f'np.where(array > 2)=\n{array_where}\n')
array_where_2 = np.where(array > 2, 1, 0)  
print(f'np.where(array > 2, 1, 0)=\n{array_where_2}\n')

La salida por consola es la siguiente:

 np.where(array > 2)=
  (array([2, 3]),)

  np.where(array > 2, 1, 0)=
  [0 0 1 1]

Otra forma de trabajar con predicados sin la función where es declarando un predicado con una condición. Una vez creado el predicado, lo aplicamos en el array como una indexación asignando el valor para el caso de éxito, o bien, utilizando el carácter ~ para el caso de no cumplirse. En el siguiente snippet se muestra un ejemplo de uso de ejemplo:

array2 = np.array([1, 2, 3, 4])
array2_mayor_2 = array2 > 2
array2[array2_mayor_2] = 1  
array2[~array2_mayor_2] = 0  
print(f'array2={array2}')

La salida por consola es la siguiente:

array2=[0 0 1 1]

En la siguiente entrada con título Numpy III presentaré los últimos ejemplos y finalizaré la serie de entradas relacionadas con la librería Numpy.

Numpy I

Numpy es una librería Python open-source para computación científica que permite tener el poder de computación de lenguajes como C o Fortran en lenguaje Python. En la presente entrada, Numpy I, realizaré una breve presentación y realizaré unos ejemplos básicos.

Las características generales de Numpy son las siguientes:

  1. Permite trabajar con matrices N dimensionales.
  2. Proporciona herramientas de computación numérica ofreciendo un conjunto de funciones matemáticas complejas.
  3. Es interoperable, con lo cual, permite trabajar con amplias plataformas, funciona con bibliotecas distribuidas y de GPU.
  4. El núcleo de la librería es código C bien optimizado.
  5. Fácil de usar.
  6. Es una librería de código abierto.

Los ejemplos practicos que mostraré en los siguientes apartados están desarrollados con Python 3.6. Las dependencias de las librerías utilizadas son las siguientes: numpy y matplotlib; y, para la utilización de las funciones en cada módulo, es necesario importar la librería de la siguiente forma:

import numpy as np

Los ejemplos de utilización de la librería Numpy a presentar en esta entrada son los siguientes:

  1. Creación de arrays n-dimensionales.
  2. Generación e inicialización de arrys n-dimensionales.
  3. Indexación de elementos en arrays n-dimensionales.
  4. Funciones básicas en arrays n-dimensionales.

1.- Creación de arrays N dimensaionales.

La creación de un array la realizaremos empleando la función array y, para determinar el tipo de los elementos del array, emplearemos la función dtype. En el siguiente ejemplo se define una array de dos dimensiones a partir de una lista.

lst = [[1, 2, 3],
       [4, 5, 6]]
arrayld = np.array(lst)
print(f'Array integer:\n{arrayld}')
print(f'Type={arrayld.dtype}')

La salida por consola es la siguiente:

Array integer:
[[1 2 3]
[4 5 6]]
Type=int64

Para crear un array de elementos de tipos reales a partir del array anterior, utilizamos la función astype con el tipo float32 definidos en Numpy, el snippet ejemplo es el siguiente:

array_float_32 = arrayld.astype(np.float32)
print(f'Array float32:\n{array_float_32}')
print(f'Type={array_float_32.dtype}\n')

La salida por consola es la siguiente:

Array float32:
[[1. 2. 3.]
[4. 5. 6.]]
Type=float32

Para crear un array de dos dimensiones de elementos enteros a partir de una lista, se utiliza la función array y se especifica el tipos int64. Una vez creado el array, podemos conocer sus características con las siguientes funciones:

  • itemsize.- para determinar el tamaño en bit que ocupan en el array.
  • size.- para determinar el número de elementos del array.
  • ndim.- para determinar el número de dimensiones.
  • shap.- para determinar el número de elementos por dimensión.

El snippet ejemplo es el siguiente:

array_2_dimesion = np.array([[1, 2, 3], [4, 5, 6]], dtype='int64')
print(f'array_2_dimesion=\n{array_2_dimesion}')
print(f'array_2_dimesion.itemsize={array_2_dimesion.itemsize}') 
print(f'array_2_dimesion.size={array_2_dimesion.size}') 
print(f'array_2_dimesion.ndim={array_2_dimesion.ndim}') 
print(f'array_2_dimesion.shape={array_2_dimesion.shape}\n') 

La salida por consola es la siguiente:

array_2_dimesion=
[[1 2 3]
 [4 5 6]]
array_2_dimesion.itemsize=8
array_2_dimesion.size=6
array_2_dimesion.ndim=2
array_2_dimesion.shape=(2, 3)

En el caso de un array de una dimensión, el resultado de la función shape sería el siguiente:

array_ahape =  np.array([1, 2, 3]).shape
print(f'array_ahape={array_ahape}')  

La salida por consola es la siguiente:

array_ahape=(3,)

2.- Generación e inicialización de arrays n-dimensionales.

La creación de un array n-dimensional de forma dinámica con un generador se realiza con la función fromiter. El generador debe de ser pasado como parámetro ya sea una función explícita, o bien, mediante una sentencia que defina un generador. El snippet con un ejemplo es el siguiente:

 def generator():
     for i in range(10):
         if not (i % 2):
             yield i
 gen = generator()
 array_generator = np.fromiter(gen, dtype=int)
 print(f'array_generator={array_generator}')
 generator_expression = (i for i in range(10) if i % 2)
 array_generator_expression = np.fromiter(generator_expression, dtype=int)
 print(f'array_generator_expression={array_generator_expression}')

La salida por consola es la siguiente:

array_generator=[0 2 4 6 8]
array_generator_expression=[1 3 5 7 9]
array_3_3_1=
  [[1. 1. 1.]
   [1. 1. 1.]
   [1. 1. 1.]]

Otra forma de crear arrays con inicializaciones determinadas se pueden realizar con las siguientes funciones:

  • ones.- Creación de un array n-dimensional de números reales con valor 1.
  • zeros.- Creación de un array n-dimensional de números reales con valor 0.
  • eye.- Creación de un array n-dimensional cuya diagonal tiene valor 1.
  • diag.- Creación de un array n-dimensional cuya diagonal tiene un valor pasado por parámetro.
  • arrange.- Creación de un array n-dimensional cuya diagonal tiene el rango de valores pasados por parámetro.
  • linspace.- Creación de un array n-dimensional cuyos de x elementos pasados por paámetros y valores comprendidos entre un valor mínimo y máximo.

El snippet de código con ejemplos es el siguiente:

 # Inicializadores de arrays.
 array_3_3_1 = np.ones((3, 3))
 print(f'array_3_3_1=\n{array_3_3_1}\n')
 array_3_3_0 = np.zeros((3, 3))
 print(f'array_3_3_0=\n{array_3_3_0}\n')
 array_eye_diagonal = np.eye(3)
 print(f'array_eye_diagonal=\n{array_eye_diagonal}\n')
 array_diagonal = np.diag((3, 3, 3))
 print(f'array_diagonal=\n{array_diagonal}\n')
 array_arange_float = np.arange(4., 10.)  
 print(f'array_arange_float=\n{array_arange_float}\n')
 array_arange_int = np.arange(5)  
 print(f'array_arange_int=\n{array_arange_int}\n')
 array_arange_interval = np.arange(1., 11., 2)  
 print(f'array_arange_interval=\n{array_arange_interval}\n')
 array_insterval_space_1 = np.linspace(0., 1., num=5)  
 print(f'array_insterval_space_1=\n{array_insterval_space_1}\n')
 array_insterval_space_2 = np.linspace(0., 1., num=6)  
 print(f'array_insterval_space_2=\n{array_insterval_space_2}\n')

La salida por consola es la siguiente:

  array_3_3_0=
  [[0. 0. 0.]
   [0. 0. 0.]
   [0. 0. 0.]]
  array_eye_diagonal=
  [[1. 0. 0.]
   [0. 1. 0.]
   [0. 0. 1.]]
  array_diagonal=
  [[3 0 0]
   [0 3 0]
   [0 0 3]]
  array_arange_float=
  [4. 5. 6. 7. 8. 9.]
  array_arange_int=
  [0 1 2 3 4]
  array_arange_interval=
  [1. 3. 5. 7. 9.]
  array_insterval_space_1=
  [0.   0.25 0.5  0.75 1.  ]
  array_insterval_space_2=
  [0.  0.2 0.4 0.6 0.8 1. ]

3.- Indexación de elementos en arrays n-dimensionales.

El acceso a los elementos de los array se realiza indicando la posición entre corchetes. Unos ejemplos de acceso a elementos de un array de una y dos dimensiones son los que se muestran en el siguiente snippet de código:

array = np.array([1, 2, 3])
print(f'array=\n{array}\n')
print(f'array[0]=\n{array[0]}')
print(f'array[1]=\n{array[1]}\n')
print(f'array[:2]=\n{array[:2]}\n')
print(f'array[1:]=\n{array[1:]}\n')
array_22 = np.array([[1, 2, 3], [4, 5, 6]])
print(f'array=\n{array_22}\n')
print(f'array_22[0,0]=\n{array_22[0, 0]}')
print(f'array_22[-1,-1]=\n{array_22[-1, -1]}\n')
print(f'array_22[0]=\n{array_22[0]}\n') 
print(f'array_22[:, 0]=\n{array_22[:, 0]}\n')
print(f'array_22[:, :2]=\n{array_22[:, :2]}\n')

La salida por consola es la siguiente:

array=
[1 2 3]
array[0]=
1
array[1]=
2
array[:2]=
[1 2]
array[1:]=
[2 3]
array=
[[1 2 3]
 [4 5 6]]
array_22[0,0]=
1
array_22[-1,-1]=
6
array_22[0]=
[1 2 3]
array_22[:, 0]=
[1 4]
array_22[:, :2]=
[[1 2]
 [4 5]]

4.- Funciones básicas en arrays n-dimensionales.

La manipulación de los elementos de los array n-dimensionales se puede realizar accediendo de forma directo, o bien, utilizando operadores matemáticos. En el siguiente snippet, se muestra unos ejemplos de manipulación de elementos:

 # Forma1: Suma 1 a los elementos de la lista
 list = [[1, 2, 3], [4, 5, 6]]
 list_mas_1 = [[cell + 1 for cell in row] for row in list]  # Incrementamos en 1
 print(f'list_mas_1=\n{list_mas_1}\n')
 # Forma2: Suma 1
 array_nd_list_mas_1 = np.add(list, 1)
 print(f'array_nd_list_mas_1=\n{array_nd_list_mas_1}\n')
 # Forma3: Suma 1
 ndarray_3 = np.array(list)
 array_nd_list_mas_12 = ndarray_3 + 1
 print(f'array_nd_list_mas_12=\n{array_nd_list_mas_12}\n')
 ndarray_cuadrada = np.array(list)
 array_nd_list_cuadrado = ndarray_cuadrada**2
 print(f'array_nd_list_cuadrado=\n{array_nd_list_cuadrado}\n')

La salida por consola es la siguiente:

list_mas_1=
[[2, 3, 4], [5, 6, 7]]
array_nd_list_mas_1=
[[2 3 4]
 [5 6 7]]
array_nd_list_mas_12=
[[2 3 4]
 [5 6 7]]
array_nd_list_cuadrado=
[[ 1  4  9]
 [16 25 36]]

Otras funciones básicas pueden ser las siguientes:

  • reduce.- función de suma de columnas de un array n-dimensional.
  • sum.- función de suma de elemntos de una terminada dimensión.
  • mean.- función de cálculo de la media.
  • std.- función de cálculo de la desviación típica.
  • var.- función de la varianza.
  • max.- función de cálculo del valor máximo.
  • min.- función de cálculo del vfalor mínimo

El snippet con ejemplos de utilización de uso de dichas funciones es el siguiente:

  ndarray = np.array(list)
  print(f'np.add.reduce(ndarray)={np.add.reduce(ndarray)}')  
  print(f'np.sum(ndarray, axis=0)={np.sum(ndarray, axis=0)}')  
  print(f'np.sum(ndarray, axis=1)={np.sum(ndarray, axis=1)}')  
  print(f'ndarray.sum()={ndarray.sum()}')  
  print(f'ndarray.mean()={ndarray.mean()}')
  print(f'ndarray.std()={ndarray.std()}')
  print(f'ndarray.var()={ndarray.var()}')
  print(f'ndarray.max()={ndarray.max()}')
  print(f'ndarray.min()={ndarray.min()}')
  print(f'ndarray.argmax()={ndarray.argmax()}')
  print(f'ndarray.argmin()={ndarray.argmin()}')

La salida por consola es la siguiente:

np.add.reduce(ndarray)=[5 7 9]
np.sum(ndarray, axis=0)=[5 7 9]
np.sum(ndarray, axis=1)=[ 6 15]
ndarray.sum()=21
ndarray.mean()=3.5
ndarray.std()=1.707825127659933
ndarray.var()=2.9166666666666665
ndarray.max()=6
ndarray.min()=1
ndarray.argmax()=5
ndarray.argmin()=0

En la siguiente entrada, Numpy II, continuaré describiendo operaciones con arrays en Numpy.

HTTP4S: Introducción

La primera solución para implementar servicios o microservicios en Scala es utilizar AKKA HTTP; pero, con las prestaciones de librerías como Cats, ha permitido la creación de librerías como HTTP4S. En la presente entrada, HTTP4s: Introducción, realizaré una breve descripción para la creación de servicios con la librería HTTP4S.

Definición de dependencias

La definición de dependencias y la gestión del ciclo de desarrollo del software se realizará con sbt. La versión de Scala a utilizar es la versión 2.13.4. Las librería utilizadas son las siguientes:

  • http4s_%.- librerías específicas de http4s.
  • munit_%.- librerías específicas para la realización de test.
  • circe_%.- librerías específicas para el tratamiento de componentes JSON.

El snippet del código con la definición de un módulo que utilice la librería HTTP4s es el siguiente:

val munit = "0.7.20"
val munit_cats_effect_version = "0.12.0"
val http4s = "0.21.14"
val circe = "0.13.0"
[…]
lazy val munit = "org.scalameta" %% "munit" % Versions.munit % Test
lazy val munit_cats_effect_2 = "org.typelevel" %% "munit-cats-effect-2" % Versions.munit_cats_effect_version % Test
lazy val http4s_blaze_server = "org.http4s" %% "http4s-blaze-server" % Versions.http4s
lazy val http4s_blaze_client = "org.http4s" %% "http4s-blaze-client" % Versions.http4s
lazy val http4s_dsl = "org.http4s" %% "http4s-dsl" % Versions.http4s
lazy val http4s_circe = "org.http4s" %% "http4s-circe" % Versions.http4s
lazy val circe_generic = "io.circe" %% "circe-generic" % Versions.circe
lazy val circe_literal = "io.circe" %% "circe-literal" % Versions.circe
[…]
lazy val http4s = (project in file("http4s"))
 .settings(
    name := "example-http4s",
    assemblySettings,
    scalacOptions ++= basicScalacOptions,
    testFrameworks += new TestFramework("munit.Framework"),
    libraryDependencies ++=
    http4sDependencies ++ Seq(
      scalaTest,
      munit,
      munit_cats_effect_2
    )
  )
  lazy val http4sDependencies = Seq(
     http4s_blaze_server,
     http4s_blaze_client,
     http4s_circe,
     http4s_dsl,
     circe_generic,
     circe_literal
   )

Definición de Servicios

No me centrará en definir qué es un micro-servicio o servicio ya que ha sido definido y descrito en otras entradas. Desde el punto de vista de la librería HTTP4s, un servicio es aquella definición de un método HTTP el cual, dado un path determinado, recibe una petición HTTP, realiza una funcionalidad determinada y, como resultado, retorna una respuesta HTTP.

HTTP4s está basada en la librería Cats y, al definir operaciones con efecto de lado, emplearemos como tipo parametrizado el componente IO.

El servicio lo definieremos con la función of del componente HttpRoutes, definiremos el tratamiento a realizar en función del método HTTP junto al path del recurso; para cada método, se realizará la creación de una respuesta con el componente Ok.

El servicio es definido y utilizado en un objeto de tipo aplicación de entrada salida IOApp el cual, con la definición de la función run, arranca la aplicación y levanta el servicio. Esta clase es propia de la librería Cats. La función run realiza la creación de un server con el objeto de tipo BlazeServerBuilder al cual se le configura con el servicio, el puerto y el host deseado.

En los siguiente apartados, se definen ejemplos de definición de servicios con HTTP4s.

Ejemplo de servicio básico

Ejemplo básico de tipo «Hello World» donde se define un servicio HTTP con método GET. El código de ejemplo es el siguiente:

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import scala.concurrent.ExecutionContext.Implicits.global
import org.http4s.server.blaze._
import org.http4s.implicits._
object ServiceEjem1 extends IOApp {

  val helloWorldService = HttpRoutes
     .of[IO] { 
        case GET -> Root / "hello" / name => Ok(s"Hello, $name.")
     }.orNotFound

  def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilderIO
       .bindHttp(8080, "localhost")
       .withHttpApp(helloWorldService)
       .serve
       .compile
       .drain
       .as(ExitCode.Success)
}

Para probar el servicio anterior desde la línea de comando, se utiliza el siguiente comando curl:

curl http://localhost:8080/hello/Pete

Test

Para realizar el test del ejemplo anterior, usaremos el framework MUnit. La definición del test consiste en la declaración de una clase que herede de munit.FunSuite, en nuestro caso, la clase ServiceEjem1Test; ésta, define los test de prueba con la función test y, la verificación de resultado, se realiza con las funciones assert.

Dado que el servicio creado se implementa como una función Kleisli, no es necesario arrancar un server para realizar pruebas, con lo cual, el proceso de pruebas es un proceso de test de una función, simplificando dichas tareas.

La clase de test del servicio anterior es la siguiente:

import cats.effect._
import org.http4s._
import org.http4s.implicits._
class ServiceEjem1Test extends munit.FunSuite {

  test("Test ServiceEjem1 OK") {
     val service = ServiceEjem1.helloWorldService
     val requestTest = Request[IO](Method.GET, uri"/hello/test")
     val result = service.run(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.Ok)
  }

  test("Test ServiceEjem1 KO") {
     val service = ServiceEjem1.helloWorldService
     val requestTest = Request[IO](Method.GET, uri"/hello")
     val result = service.run(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.NotFound)
  }

}

La prueba consiste en la creación de una referencia al servicio, definido con el objeto service; definición de la petición HTTP, definido en el objeto requestTest; y, para su ejecución, emplearemos la función run() y unsafeRunSync(); el resultado, se recoge en el objeto result el cual es utilizado en las funciones assert.

Composición de servicios

Un servicio está implementado como una función Kleisli y, la composición de funciones Kleisli, se realiza combinado dichas funciones. Para ello, utilizamos la función <+> la cual está definida en la sintaxis de la librería cuyo paquete es cats.syntax.all._.

En el código ejemplo, se define un servicio básico tipo «Hola Mundo» en el objeto helloWorldService y se define un servicio tweeService. El servicio tweetService define dos métodos para dos endpoints diferentes. La composición se define en el objeto services con la función <+>.

Una vez definido la composición del servicio, se crea el enrutado de los servicios en el objeto httpApp; este objeto, será utilizada para levantar la aplicación en la función run.

El código de ejemplo es el siguiente:

object ServiceEjem2 extends IOApp {
   val helloWorldService = HttpRoutes.of[IO] { 
     case GET -> Root / "hello" / name => Ok(s"Hello, $name.")
   }
   case class Tweet(id: Int, message: String)
   […]

   def getTweet(tweetId: Int): IO[Tweet] = IO(Tweet(id = 1, message = "Tweet1"))

   def getPopularTweets(): IO[Seq[Tweet]] = IO(
      List(Tweet(id = 2, message = "Tweet2"), Tweet(id = 3, message = "Tweet3"))
   )

   val tweetService = HttpRoutes.of[IO] {
     case GET -> Root / "tweets" / "popular" =>
        getPopularTweets().flatMap(Ok()) 
     case GET -> Root / "tweets" / IntVar(tweetId) =>  
        getTweet(tweetId).flatMap(Ok())
   }
   val services = tweetService <+> helloWorldService

   val httpApp = Router("/" -> helloWorldService, "/api" -> services).orNotFound

   def run(args: List[String]): IO[ExitCode] =
      BlazeServerBuilder[IO](global)
        .bindHttp(7676, "localhost")
        .withHttpApp(httpApp)
        .serve
        .compile
        .drain
        .as(ExitCode.Success)
}

Test

La definición de los test se realiza con el mismo criterio del primer ejemplo. Para simplificar, me centraré los test del servicio compuesto y en la aplicación.

La estrategia de prueba sigue la misma secuencia, definición del servicio, definición de la petición de pruebas, ejecución y valoración de resultados.

El snippet de prueba es el siguiente:

[…]
test("Test ServiceEjem2.service (compose service) OK") {
   val service = ServiceEjem2.services
   val requestTestHello = Request[IO](Method.GET, uri"/hello/test")
   val resultHello = service.orNotFound(requestTestHello).unsafeRunSync()
   assertEquals(resultHello.status, Status.Ok)
   val requestTestTweet = Request[IO](Method.GET, uri"/tweets/popular")
   val resultTweet = service.orNotFound(requestTestTweet).unsafeRunSync()
   assertEquals(resultTweet.status, Status.Ok)
}
test("Test httpApp OK") {
   val httpApp = ServiceEjem2.httpApp
   val requestTestHello = Request[IO](Method.GET, uri"/hello/test")
   val resultHello = httpApp.run(requestTestHello).unsafeRunSync()
   assertEquals(resultHello.status, Status.Ok)
   val requestTestTweet = Request[IO](Method.GET, uri"/api/tweets/popular")
   val resultTweet = httpApp.run(requestTestTweet).unsafeRunSync()
   assertEquals(resultTweet.status, Status.Ok)
}
[…]

Definición de Middleware

En HTTP4S un middleware es un envoltorio de un servicio para poder manipular la petición enviada, o bien, la respuesta. La estrategia de definición de un middleware puede ser empleando una función, o bien, utilizando un objeto. En los siguientes apartados se muestran unos ejemplos prácticos.

Definición de un middleware mediante una función.

La forma básica para definir un middleware es empleando una función la cual recibe como parámetro un servicio y, como resultado, retorna una función kleisli representado en un HttpRoutes. El middleware ejecutará el servicio el cual, en función del resultado, modificará las cabeceras del objeto de respuesta. El resto de elementos sigue la misma estructura y función que los ejemplos de servicios.

El snippet del código es el siguiente:

object MiddlewareEjem1 extends IOApp {
   def myMiddle(service: HttpRoutes[IO], header: Header): HttpRoutes[IO] = Kleisli { (req: Request[IO]) =>
      service(req).map {
         case Status.Successful(resp) =>
             resp.putHeaders(header)
         case resp =>
             resp
      }
   }

   val service = HttpRoutes.of[IO] { 
     case GET -> Root / "hello" / name => Ok(s"Hello wrapper, $name.")
   }

   val wrappedService = myMiddle(service, Header("SomeKey", "SomeValue"))

   val apiService = HttpRoutes.of[IO] { 
     case GET -> Root / "rest1" => Ok("OK response API")
   }

   val httpRoute = Router("/" -> wrappedService, "/api" -> apiService).orNotFound

   def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilder[IO](global)
      .bindHttp(7676, "localhost")
      .withHttpApp(httpRoute)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)
}

Test

Para definir las pruebas unitarias del middleware, igual que los servivios, se emplea el framework Munit; en nuestro caso, en el resultado se obtendrá las cabeceras asignadas por el middleware.

El snippet de las pruebas unitarias es el siguiente:

class MiddlewareEjem1Test extends munit.FunSuite {
   […]
   test("Test wrappedService MiddlewareEjem1 OK") {
     val service = MiddlewareEjem1.wrappedService
     val requestTest = Request[IO](Method.GET, uri"/hello/test")
     val result = service.orNotFound(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.Ok)
     assertEquals(result.headers.get(CaseInsensitiveString("SomeKey")).get.value, "SomeValue")
   }
   […]
}

Definición de un middleware con un object.

Otra forma de definir el middleware es utilizando un objeto. El objeto implementará el método apply el cual tendrá la misma funcionalidad que la función del ejemplo anterior; en nuestro caso, se utiliza una función addHeader para modularizar más el código. El servicio que emplee este objeto, wrappedService, simplemente realiza la creación del objeto; posteriormente, se emplea en la creación del Router.

El snipper del código ejemplo es el siguiente:

object MiddlewareEjem2 extends IOApp {
   object MyMiddle {
     def addHeader(resp: Response[IO], header: Header): Response[IO] = resp match {
        case Status.Successful(resp) => resp.putHeaders(header)
        case resp => resp
     }

     def apply(service: HttpRoutes[IO], header: Header) =
        service.map(addHeader(_, header))
   }

   val service = HttpRoutes.of[IO] { 
      case GET -> Root / "middleware" / name =>
        Ok(s"Hello wrapper, $name.")
   }

   val wrappedService = MyMiddle(service, Header("SomeKey", "SomeValue"))

   val apiService = HttpRoutes.of[IO] { 
      case GET -> Root / "rest1" =>
        Ok("OK response API")
   }

   val httpRoute = Router("/" -> wrappedService, "/api" -> apiService).orNotFound
      def run(args: List[String]): IO[ExitCode] =
         BlazeServerBuilder[IO](global)
           .bindHttp(7676, "localhost")
           .withHttpApp(httpRoute)
           .serve
           .compile
           .drain
           .as(ExitCode.Success)
}

Test

La definicón del test del ejemplo anterior sigue la misma estructura que los ejemplos anteriores. El Snippet con el código de test es el siguiente:

[…]
test("Test wrappedService MiddlewareEjem1 OK") {
   val service = MiddlewareEjem2.wrappedService
   val requestTest = Request[IO](Method.GET, uri"/middleware/test")
   val result = service.orNotFound(requestTest).unsafeRunSync()
   assertEquals(result.status, Status.Ok)
   assertEquals(result.headers.get(CaseInsensitiveString("SomeKey")).get.value, "SomeValue")
}
[…]

Composición de un middleware y un servicio.

La composición de middleware sigue el mismo criterio que los servicios porque son funciones kleisli. El ejemplo de una composición de middleware es el siguiente:

object MiddlewareEjem3 extends IOApp {

  object MyMiddle {
    def addHeader(resp: Response[IO], header: Header): Response[IO] = resp match {
      case Status.Successful(resp) => resp.putHeaders(header)
      case resp => resp
    }

    def apply(service: HttpRoutes[IO], header: Header) =
      service.map(addHeader(_, header))
  }

  val service = HttpRoutes.of[IO] { case GET -> Root / "middleware" / name =>
      Ok(s"Hello wrapper, $name.")
  }

  val apiService = HttpRoutes.of[IO] { case GET -> Root / "rest1" =>
      Ok("OK response API")
  }

  val wrappedService = apiService <+> MyMiddle(service, Header("SomeKey", "SomeValue"))

  val httpRoute = Router("/api" -> wrappedService).orNotFound

  def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilder[IO](global)
      .bindHttp(7676, "localhost")
      .withHttpApp(httpRoute)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)
}

La definición de los test de este ejemplo es análogo a los anteriores.

Para el lector interesado en el código ejemplo puede acceder a través del siguiente enlace.

En la siguiente entrada, HTTP4S: manejo de JSON, me centraré en cómo se manejan componentes JSON con HTTP4S.

MUnit IV: Filtros

Finalizamos la serie de MUnit con esta última entrada, MUnit IV: Filtros, en donde describiremos cómo filtrar aquellos test que queramos ejecutar y cuál no. Para el lector interesado en la serie de entradas puede acceder a través de los siguiente enlaces:

  1. MUnit I: declaración de test.
  2. MUnit II: aserciones.
  3. MUnit III: accesorios.

Los filtrados a los test los podemos definir de la siguiente forma:

  • Ejecutar solo un test de una clase.- Para ejecutar solo un test definido en una clase se debe de utilizar la función only en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem1 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".only) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ignorar un test.- Si se desea ignorar la ejecución de un test en una clase se debe de utilizar la función ignore en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem2 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".ignore) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ejecución de test en función de condiciones dinámicas.- Si se desea la ejecución de test en función de valores externos, como por ejemplo: la ejecución de un test si es en una máquina Linux o Mac, se emplea la función assume la cual, si no cumple una función, lanza una excepción. Un ejemplo de uso de esta función es la siguiente clase:
class FilteringEjem3 extends munit.FunSuite {
  import scala.util.Properties
  test("paths linux") {
    try {
      print(f"Properties.isLinux=${Properties.isLinux}")
      assume(Properties.isLinux, "this test runs only on Linux")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
  test("paths mac") {
    try {
      println(f"Properties.isMac=${Properties.isMac}")
      assume(Properties.isMac, "this test runs only on Mac")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
}

Los test del ejemplo anterior fallarán o no en función de si la ejecución se realiza en una máquina Linux o Mac.

  • Ignorar una clase de test por anotación.- Un camino rápido para ignorar la ejecución de una clase de test es la utilización de la anotación munit.IgnoreSuite. Un ejemplo de uso es el siguiente:
@munit.IgnoreSuite
class FilteringIgnoreClassEjem4 extends munit.FunSuite {
  test("Ignore test 1") {
    assert(1 == 1)
  }
  test("Ignore test 2") {
    assert(1 == 1)
  }
}

La utilización de MUnit es muy intuitiva para todos aquellos que hayan trabajado con JUnit y, para quien no, no considero un aprendizaje duro y costoso por la sencillez de la definición y uso. La posibilidad de definir pruebas de tareas asíncronas es un aspecto importante para considerar su uso en ciertos casos. Por el contrario, es posible que el uso de los accesorios (fixture) sea algo más laborioso al tener que definir de forma explícita el objeto en el test.

MUnit III: accesorios

En la presente entrada, MUnit III: accesorios, definiré qué es un accesorio (fixtures) en un test con MUnit y mostraré ejemplo de uso. Para el lector interesado, el resto de entradas de la serie son las siguientes:

+ MUnit I: declaración de test.
+ MUnit II: aserciones.

Los accesorios en las pruebas son los entornos de ejecución en donde se ejecutan los test; en estos entornos, permiten la adquisición de recursos para ejecutar la prueba y, una vez finalizada, liberar todos aquellos recursos utilizados. Podemos tener dos tipos de accesorios: funcionales, son aquellos en donde se adquiere un recurso determinado ; y, los reusables, son aquellos en donde se puede definir una funcionalidad antes y después del test.

Las clases de test en donde se definen accesorios son clases que heredan de la clase munit.FunSuite y, en función de si es un accesorio funcional o reusable, se define la referencia a  munitFixture.

Los ejemplos de accesorios son aquellos en los que se definen test en donde es necesario utilizar un recurso, ya sea un fichero o bien una base de datos con los cuáles operar.

Ejemplo de accesorio funcional

Supongamos que necesitamos crear un fichero temporal para un test, necesitamos crear un fichero antes del test, ejecutar el test y, para finalizar, liberar dicho fichero. Un ejemplo de test es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val files = FunFixture[Path](
    setup = { test =>
      Files.createTempFile("tmp", test.name)
    },
    teardown = { file =>
      Files.deleteIfExists(file)
      ()
    }
  )
  files.test("Functional Test Local Fixtures") { file =>
     assert(Files.isRegularFile(file), s"Files.isRegularFile($file)")
  }
  [...]
}

En el ejemplo anterior, se define un objeto FunFixture cuyo tipo parametrizado es un objeto Path del paquete java.nio.file; la función setup, es aquella función que se ejecuta antes de la ejecución del test; y, la función teardown, es aquella función que se ejecuta después del test; en nuestro caso, se realiza la creación de un fichero temporal y su liberación respectivamente. Una vez definido el accesorio, se invoca la función test del objeto FunFixture en el cual se define las aserciones oportunas.

Si deseamos realizar una composición de accesorios, podemos emplear la función map2 o map3 del objeto FunFixture. Un ejemplo de composición de dos accesorios como el definido anteriormente es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  [...]
  val files2 = FunFixture.map2(files, files)
  files2.test("Multiple Functional Test Local Fixtures") {
    case (file1, file2) => {
      assertNotEquals(file1, file2)
      assert(Files.isRegularFile(file1), s"Files.isRegularFile($file1)")
      assert(Files.isRegularFile(file2), s"Files.isRegularFile($file2)")
    }
  }
}

Ejemplo de accesorio reusable

En JUnit puedes definir funciones con funcionalidad antes del test y después, representadas en las funciones before y after. Para conseguir estas funciones, necesitamos definir accesorios reusables las cuáles son más poderosas que los accesorios funcionales. En el siguiente ejemplo se muestra una accesorio reusable que trata con ficheros:

import java.nio.file.{Files, Path}
class ReusableTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val file = new Fixture[Path]("files") {
    var file: Path = null
    override def apply(): Path = file
    override def beforeEach(context: BeforeEach): Unit = {
      file = Files.createTempFile("files", context.test.name)
    }
    override def afterEach(context: AfterEach): Unit = {
      Files.deleteIfExists(file)
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(file)
  test("exists") {
    assert(Files.exists(file()))
  }
}

En el ejemplo anterior, se definie un accesorio de la clase Fixture con un tipo parametrizado de tipos Path; esta clase, define las funciones beforeEach y afterEach con la referencia al recurso de tipo file con el que se trabaja. Además, se define la lista de Fixtures que pueden ser utilizados en cada test.

Si por el contrario, deseamos trabajar con una base de datos, el ejemplo de definición de la clase de test con un Fixture con el tratamiento de una base de datos es el siguiente:

import java.sql.{Connection, DriverManager}
class ReusableTestLocalFixturesDBEjem1 extends munit.FunSuite {
  val db = new Fixture[Connection]("database") {
    private var connection: Connection = null
    override def apply(): Connection = connection
    override def beforeEach(context: BeforeEach): Unit = {
      connection = DriverManager.getConnection("jdbc:h2:mem:test", "sa", "password")
    }
    override def afterEach(context: AfterEach): Unit = {
      connection.close()
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(db)
  test("test1") {
    db()
    assert(1 == 1)
  }
  test("test2") {
    db()
    assert(1 == 1)
  }
}

En el ejemplo anterior, para cada test definido, se inicializa una base de datos de tipo m2 en memoria.

En la siguiente entrada, MUnit IV: filtrado, describiré como realizar filtros de test en ejecución.