Skip to content

Latest commit

 

History

History

scala-registros

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

SRegistros: Ejemplo de DSL Funcional en Scala

tl-dr; Este repositorio ilustra el diseño, implementación y uso de un lenguaje de dominio específico (DSL) en Scala 3 para copiar datos desde/hacia bases de datos, archivos planos, servidores y otros formatos. Para ello se emplean patrones funcionales simples alrededor de una simplificación del modelo map/reduce.

Map/Reduce

La idea esencial de la operación map/reduce es muy simple: un generador produce una secuencia de ítems de datos que son subsiguientemente transformados (map) y luego agregados (reduce) en un resultado final. Una formulación simplificada de este modelo sería:

/**
 * Implementación minimalista de `mapReduce`.
 * @param generar Función sin argumentos que retorna un iterador del tipo de entrada `E`
 * @param transformar Función que transforma un ítem de entrada `E` en tipo intermedio `T`
 * @param reducir Función que agrega ítems de tipo intermedio `T` en un único ítem de salida `S`
 * @tparam E Tipo de datos de entrada
 * @tparam T Tipo de datos intermedio
 * @tparam S Tipo de datos de salida
 * @return Instancia de tipo de datos de salida `S` resultante del proceso
 */
def mapReduce[E, T, S](generar: => Iterator[E], 
                       transformar: E => T, 
                       reducir: Iterator[T] => S) = reducir(generar.map(transformar))

👉 Esta simplificación se aparta de la idea "clásica" de map/reduce (como aquella popularizada por Google) en que omitimos su aspecto de paralelización. Esto es apropiada para nuestro más mundano propósito de, simplemente, "copiar registros".

Un ejemplo simple de uso de esta función de map/reduce sería:

case class Compra(item: String, cantidad: Int, precioUnitario: Double)

val compras = List(
  Compra("martillo", 1, 2.5),
  Compra("clavo", 12, 0.3),
  Compra("tornillo", 10, 0.35)
)

val totalCompras = mapReduce(
  generar =
    compras.iterator,
  transformar = 
    compra => compra.cantidad * compra.precioUnitario,
  reducir = 
    _.sum
)

// Imprime: "El total de compras es 9.6"
println(s"El total de compras es $totalCompras")

Nuestro DSL de copia de registros está construido alrededor de esta simple, pero potente, forma de map/reduce.

El punto más importante a tener en cuenta para la discusión de nuestra herramienta es que enfatizamos el uso de funciones por encima del uso de clases e interfaces.

Es decir: sacamos partido las capacidades funcionales de Scala apelando a sus capacidades orientadas a objetos solo donde estas son apropiadas.

Copia de Registros

Nuestro propósito es definir un vocabulario que permita al desarrollador expresar declarativamente operaciones de extracción y diseminación de datos en una variedad de formatos.

Ejemplos:

  • Extraer datos de una base de datos relacional y colocar los resultados CSV en un servidor SFTP remoto
  • Leer datos a partir de un archivo de longitud fija y almacenarlos en una base de datos relacional
  • Leer datos de un archivo delimitado por tabs y generar un libro Excel en otro archivo local

Requerimos leer datos de una variedad de fuentes y formatos a la vez que también requerimos escribir datos en la misma variedad de destinos y formatos.

Esto hace necesario definir un formato intermedio uniformemente generado por los lectores y consumido por los escritores.

👉 De esta forma, dados M formatos de entrada y N formatos de salida necesitaremos solo M+N combinaciones en vez de M*N!

Llamaremos a este formato intermedio registro y lo representaremos como un mapa de nombres de campo a valores de campo. Simbólicamente:

type Registro = Map[String, _]

La existencia de este formato intermedio nos lleva a refinar nuestra versión original de map/reduce para:

  • Renombrar la operación de mapReduce a copiar
  • Renombrar también las funciones pasadas como parámetros a fin de enfatizar su uso en operación de copia
  • Añadir un argumento de extracción que convierte del tipo de datos de entrada E al registro Map[String,_]

Así, nuestro framework de copia se reformula como:

def copiar[E, S](leer: => Iterator[E],
                 extraer: E => Map[String, _],
                 transformar: Map[String, _] => Map[String, _],
                 recolectar: Iterator[Map[String, _]] => S): S =
  recolectar(leer.map(extraer.andThen(transformar)))

Para ilustrar el tipo de DSL declarativo que queremos formular consideremos el requerimiento de convertir el siguiente archivo delimitado personas.csv:

janet,doe,1000.25
john,doe,750.5

en el siguiente archivo de longitud fija personas.dat:

janet   doe     100025
john    doe     075050

Esta transformación se formularía como:

copiar(
      leyendoLineas(File("personas.csv")),
  
      extrayendoCon(
        delimitadorEntrada(","),
        campoEntradaDelimitado("nombre", posicion = 0),
        campoEntradaDelimitado("apellido", 1),
        campoEntradaDelimitado("saldo", 2, extraer = _.toDouble),
      ),
  
      recolectandoCon(
        registroFijo(longitud = 24),
        recolectorFijo(File("personas.dat")),
        campoSalidaFijo("nombre", posicion = 0, longitud = 8),
        campoSalidaFijo("apellido", 8, 8),
        campoSalidaFijo("saldo", 16, 6, colocar = formatoNumerico("000000", 100))
      )
)

Funciones de Dominio Específico

En el ejemplo anterior, los argumentos pasados como gerundios (leyendLineas, extrayendoCon, recolectandoCon) invocan funciones de orden superior que construyen y retornan otras funciones (aquellas que la función copiar espera como argumentos).

👉 El patrón de escribir funciones que retornan otras funciones es muy común en programación funcional pero no lo es (todavía) en la programación orientada a objetos, si bien es el del todo posible.

Estas funciones de orden superior son las que definen el lenguaje de dominio específico como tal.

La responsabilidad primaria de estas funciones es aquella de traducir el qué al cómo. Esto le permite al desarrollador declarar qué quiere lograr en vez de deletrear cómo debe hacerse.

Consideremos la función leyendoLineas utilizada en el ejemplo anterior:

copiar(
    leer = leyendoLineas(java.io.File("personas.csv")),
    . . .
)

Recordemos que el argumento leer de la función copiar es una función que retorna un iterador de ítems de entrada: => Iterator[E]

Así, pues, la función leyendoLineas retorna otra función que, al ser finalmente invocada, retorna un iterador de ítems de entrada.

Cuántos niveles de indirección! 😉

Pero es en esta indirección donde reside el poder de la composición funcional: posiblita manipular las funciones como datos y combinarlas selectivamente para lograr efectos que, implementados de forma imperativa, requerirían repetición mecánica de código y "juiciosa aplicación de patrones de diseño".

Afortunadamente, la sintaxis call by name de Scala permite definir estas funciones de manera que retornen directamente los valores esperados.

Veamos:

def leyendoLineas(archivo: File): Iterator[String] =
  leyendoLineas(FileReader(archivo))

def leyendoLineas(lector: Reader): Iterator[String] = new Iterator[String] :
    private val lectorLineas = BufferedReader(lector)
    private var linea = lectorLineas.readLine()
    
    override def hasNext: Boolean = linea != null
    
    override def next(): String =
        val lineaAnterior = linea
        linea = lectorLineas.readLine()
        lineaAnterior

Como se aprecia, la función leyendoLineas es un adaptador que transforma un archivo en un iterador de las líneas contenidas en ese archivo.

En este mismo espíritu, la función extrayendoCon sintetiza una función que transforma las líneas retornadas por leyendoLineas en registros de tipo Map[String, _] separando campos dado un delimitador:

extrayendoCon(
    delimitadorEntrada(","),
    campoEntradaDelimitado("nombre", posicion = 0),
    campoEntradaDelimitado("apellido", 1),
    campoEntradaDelimitado("saldo", 2, extraer = _.toDouble),
)

👉 Esta es una de las más importantes diferencias entre los estilos imperativo y funcional: donde el programador imperativo instruye al computador, en tiempo de compilación, para que este ejecute una serie estática de pasos, el programador funcional usa una formulación declarativa para derivar, en tiempo de ejecución, los pasos que el computador finalmente debe ejecutar.

Más Funciones de Dominio Específico

Para añadir un nuevo formato de datos (base de datos relacional, servidor ftp o http, libros Excel, etc.) basta con definir sus tipos de datos de entrada y salda