Transformando Documentos Markdown al Estilo de MediaData
Con esto de buscar compañeras de equipo me he dado cuenta de que no siempre es posible explicar en un rato todos los detalles que para mí son importantes cuando programo. Pensando en esto se me ocurrió publicar un proyecto utilizando el estilo de programación funcional que usamos en el equipo de MediaData de LIFULL Connect. El código del proyecto se parece mucho a nuestro código de producción y el stack es el mismo que usamos en nuestro día a día.
Aprovechando que Lean Mind ha grabado la Markdown Transformation kata me propuse hacer mi solución como si se tratase de una tarea del trabajo. Esta kata consiste en transformar todos los enlaces de un documento Markdown en notas al pie de página.
El código completo de mi solución está en este repositorio de GitHub.
El objetivo que yo me he propuesto al solucionar la kata es que mi código pueda pasar por un código más de mi equipo. Mi propuesta se basa en un pequeño caso de uso que lee un fichero Markdown, lo transforma línea a línea, le añade las notas al final de las líneas, y finalmente, escribe el resultado a un fichero diferente:
final class MarkdownTransformationUseCase(
markdownReader: MarkdownReader,
markdownWriter: MarkdownWriter,
footnotesRepository: FootnotesRepository,
):
def run: IO[Unit] =
(markdownReader.lines.flatMap(transform)
++ format(footnotesRepository.footnotes))
.through(markdownWriter.write)
.compile
.drain
Para escribir el código me he apoyado muchísimo en dos librerías del ecosistema de Typelevel: cats-effect que proporciona abstracciones para tipos de efectos generales (incluyendo una mónada IO) y fs2 para stream processing.
Mi caso de uso utiliza 3 colaboradores: MarkdownReader, MarkdownWriter y FootnotesRepository, que se inyectan como dependencias en el constructor de la clase. Los dos primeros sirven para leer y escribir documentos Markdown:
trait MarkdownReader:
def lines: Stream[IO, Line]
trait MarkdownWriter:
def write(lines: Stream[IO, Line]): Stream[IO, Unit]
El tercer colaborador sirve para almacenar los enlaces que vayamos encontrando a medida que vamos procesando el documento Markdown. Además de almacenar los enlaces, FootnotesRepository le va a ir asignando una referencia a cada enlace, de manera que podamos reutilizar la misma referencia en caso de que el mismo enlace aparezca repetido en el documento.
Una segunda operación nos permite recuperar la lista de notas al pie (el enlace con su referencia):
trait FootnotesRepository:
def save(link: Link): IO[Reference]
def footnotes: IO[List[Footnote]]
Un Inconveniente Inesperado#
Una vez que me he puesto con la kata me he dado cuenta de que la estrategia más sencilla consiste en utilizar expresiones regulares para extraer y reemplazar los enlaces del documento. Aprovechando que el formato Markdown define un patrón para los enlaces, esta debería ser la solución que menos trabajo lleva.
Sin embargo, yo no conozco una forma de aplicar expresiones regulares en Scala que a la vez permita aislar el efecto. El método replaceAllIn es lo más parecido a lo que necesito, pero no es polimórfico en el tipo, por lo cual no permite aislar el efecto (en el caso particular de esta kata, guardar un enlace):
def replaceAllIn(target: CharSequence, replacer: Match => String): String
Siempre podríamos hacer un código imperativo y mutable, pero es 2022 y tenemos contenedores, la nube, serverless, ARM… ejecutar código imperativo y mutable en la JVM debería poder evitarse, a no ser que exista una buena razón para ello. El uso de código declarativo e inmutable permite que la JVM y otras herramientas como librerías y compiladores hagan todo lo posible para adaptarse al entorno de ejecución.
Las dos soluciones que se me ocurren tienen limitaciones, especialmente si nos preocupa el performance de la aplicación. Como no tengo claro que ese sea el caso, para la kata yo me voy a quedar con esta:
final class MarkdownTransformationUseCase:
// -8<-- código omitido para abreviar
@SuppressWarnings(Array("org.wartremover.warts.Throw"))
private[this] def transform(line: Line) = Stream.emit(
linkPattern.replaceAllIn(
line.value,
matcher =>
val text = matcher.group("text")
val url = matcher.group("url")
val link = Link(LinkText(text), LinkUrl(url))
import cats.effect.unsafe.implicits.global
footnotesRepository.save(link).attempt.unsafeRunSync() match
case Right(reference) => s"${link.text} [^${reference.value}]"
case Left(error) => throw error,
),
)
.map(Line(_))
El método attempt materializará cualquier excepción que pueda ocurrir durante la sustitución de la expresión regular, aunque esto nunca debería llegar a pasar. Si por alguna razón ocurriera una excepción dentro del stream, fs2 hará el trabajo extra para recuperar el flujo normal de la aplicación.
La notación @SuppressWarnings avisa al linter de que el fallo con una excepción es intencional y evita que se rompa la compilación. Como he configurado mi proyecto para eliminar algunas características que no quiero en mi código, como las excepciones o el código mutable, tengo que usar esta anotación para hacer un uso explícito de las excepciones.
Hay otra solución que consiste en aprovechar el stream procesar la expresión regular dentro del contexto de la IO.
Testing#
Una de las cosas que no quisiera pasar por alto es la explicación de la estrategia de testing. El caso de uso se prueba por separado en un test de aceptación en el que se crean casos de prueba aleatorios con la ayuda de generadores. El test de aceptación utiliza dobles de prueba para recrear los componentes que en el código de producción se ocuparían de leer y escribir ficheros Markdown. Estos dobles de prueba graban todas las interacciones y generan cambios de estado que luego son comprobados en el test:
final class MarkdownTransformationUseCaseAcceptanceTest
extends CatsEffectSuite
with ScalaCheckEffectSuite:
test("it should transform a markdown document") {
forAllF(tesCaseGen) { testCase =>
FakeMarkdownTransformationResources
.withResources(testCase.initialState)(_.markdownTransformationUseCase.run)
.map { case (result, finalState) =>
assert(result.isRight)
assertEquals(finalState, testCase.expectedState)
}
}
}
El generador crea casos de prueba basados en conceptos del dominio, teniendo cuidado de no repetir el conocimiento que debería estar expresado únicamente en el código de producción. En este caso particular, cada caso de prueba generado consistirá en un conjunto de líneas con formato Markdown:
object MarkdownTransformationUseCaseAcceptanceTest:
final private case class TestCase(
initialState: MarkdownTransformationState,
expectedState: MarkdownTransformationState,
)
// -8<-- código omitido para abreviar
private val tesCaseGen: Gen[TestCase] = for
links <- Gen.nonEmptyContainerOf[Set, Link](linkGen).map(_.toList)
numberOfLines <- Gen.choose(0, 5)
lineFragments <- Gen.listOfN[List[Fragment]](numberOfLines, fragmentsGen(links))
footnotes = footnotesFrom(lineFragments)
initialState = MarkdownTransformationState
.empty
.setReaderLines(linesFrom(lineFragments))
expectedState = initialState
.setFootnotes(footnotes.reverse)
.setWriterLines(transformedLinesFrom(lineFragments, footnotes))
yield TestCase(initialState, expectedState)
Cada línea puede contener ninguno, uno o varios fragmentos que se separan por espacios. Los fragmentos pueden ser: imágenes, enlaces o texto. En su conjunto, las líneas forman un documento Markdown que constituye el estado inicial del caso de prueba. Además del estado inicial, el caso de prueba proporciona el estado esperado.
object MarkdownGenerators:
sealed trait Fragment
object Fragment:
final case class ImageFragment(image: Image) extends Fragment
final case class LinkFragment(link: Link) extends Fragment
final case class TextFragment(text: String) extends Fragment
Por otra parte, las implementaciones de los colaboradores se prueban en tests de integración donde se escriben y leen ficheros reales, con el objetivo de probar la integración con el filesystem:
final class FileMarkdownReaderIntegrationTest
extends MarkdownFileSuite("file-reader"):
test("it should read a file line-by-line") {
forAllF(linesGen) { lines =>
for
fileName <- IO.delay(temporaryFileFixture().toString)
_ <- IO.blocking {
val fileWriter = FileWriter(File(fileName))
lines.foreach(line => fileWriter.write(line.value))
fileWriter.close()
}
result <- FileMarkdownReader(fileName).lines.compile.toList
yield assertEquals(result, lines)
}
}
También he escrito un test unitario que prueba la lectura y validación de los parámetros de entrada de la aplicación.
En todos mis tests he utilizado la misma estrategia para generar casos de prueba aleatorios. En mi experiencia, los tests diseñados de esta manera son más fáciles de mantener. Una ventaja adicional es que el código de los generadores expresa reglas del dominio que ayudan a razonar acerca del test.
Durante la kata no he hecho test-driven development (TDD) estrictamente, ya que he escrito algunos test después del código de producción. Hay algo que me molesta del código de los tests hechos con TDD y es cuando se quedan todos esos pequeños tests exploratorios que prueban pequeñas iteraciones, y que nunca llegan a generalizarse. Se me hace muy difícil trabajar con un código así, sobre todo si tengo que cambiarlo. Por eso, yo prefiero encontrar una abstracción que funcione y dejar que las herramientas hagan el resto.
Finalmente la Aplicación#
La aplicación final utiliza la infraestructura de producción para crear los recursos que va a usar el programa. Una vez que ha leído y validado los parámetros de entrada, la aplicación ejecuta el caso de uso de la misma forma que se probó antes en el test de aceptación:
object MarkdownTransformationApp extends IOApp:
private[this] def program(params: MarkdownTransformationParams) =
MarkdownTransformationResources
.impl(params)
.use(_.markdownTransformationUseCase.run)
override def run(args: List[String]): IO[ExitCode] =
MarkdownTransformationParams.paramsFrom(args) match
case Some(params) =>
for
logger <- Slf4jLogger.create[IO]
_ <- logger.info(s"Running with parameters: ${params.asString}")
_ <- program(params)
_ <- logger.info("Done!")
yield ExitCode.Success
case None =>
Console[IO].errorln(MarkdownTransformationParams.usage).as(ExitCode.Error)
Palabras Finales#
La opinión es una parte importante de cualquier proceso de selección. Con este artículo yo he intentado exponer una estrategia de desarrollo de software que es propia de la forma de trabajar de un equipo con unas condiciones y una realidad muy concreta.
Si te ha gustado lo que has leído, te invito a que revises nuestra oferta y te animes a aplicar.