De Magico A Versatil Ruby

Cómo escribí nuestro módulo de versionamiento aprovechándome de la magia de Ruby.

Engineering
Daniel Leal
Software Engineer
Tabla de contenidos
 Tabla de contenidos
Title of resume

Si tuviera que describir Ruby con una sola palabra, sería versatilidad. Es tan versátil que hasta parece como si se pudiera inventar sintaxis nueva (todos tenemos ese amigo/a que se queja de que “Ruby es demasiado mágico”).

En mi anterior blogpost hablé sobre nuestro módulo de versionamiento, de cómo se vería para nuestros usuarios y de cómo lo usarían nuestros devs. Hay una cosa que intencionalmente no incluí y es de lo que vengo a hablar: mágicos detalles de implementación.

Buena parte de escribir el módulo de versionamiento fue escribir un DSL (Domain Specific Language) diseñado para simplificar la expresión de cambios. Un DSL es un lenguaje o API, creada para llevar a cabo tareas concretas en un dominio específico, de manera concisa y expresiva.

Para escribir el DSL con el que devs creen nuevas versiones de la API me aproveché mucho de la versatilidad de Ruby y no decepcionó. Veamos algunas de las magias de Ruby. 🧙

Cómo se ve el DSL

Para que un dev pueda crear una nueva versión de la API, solo tiene que escribir un pequeño módulo encapsulando el cambio en la forma de entrada y salida de una request, desde cómo está la API antes de la nueva versión hasta cómo se ve después de la versión. Acá hay un ejemplo:

El DSL es muy expresivo, escribir los cambios toma solo un par de líneas y la interfaz para los devs es simple y segura.

request y response

Los métodos request y response están inmediatamente definidos para la clase y no hay que instanciarla para usarlos (de hecho se llaman en la definición de RandomChange). Para que eso fuera posible, esos métodos deben definirse en la eigenclass de la clase Versioning::VersionChange.

Acá, la línea class << self abre la eigenclass de la clase que se está definiendo para poder definir métodos en ella.

Con eso, el método request puede ser usado inmediatamente en la definición de las clases que heredan de Versioning::VersionChange.

Además, notemos que el método request recibe un parámetro controller, un parámetro action y un parámetro &block. Para el DSL nos aprovechamos de que en ruby se pueden omitir los paréntesis al llamar las funciones. Diseccionemos el siguiente código:

Aquí, MyController corresponde al parámetro controller del método, :create corresponde al parámetro action y el bloque que viene después del primer do es simplemente el parámetro &block del método (esa es la sintaxis de Ruby para recibir un bloque en una función). No es magia, solamente ✨ Ruby ✨.

Scoped Context

La parte que más me costó de escribir el DSL fue disponibilizar la función body y params para que pudieran usarse en los métodos request y response. ¿Cómo podía distinguir entre una función body llamada desde request y una llamada desde response?

Además, para mí era esencial impedir que un dev pudiera usar la función body en cualquier parte de la clase Change que está escribiendo. Sé que podría simplemente haber documentado que la función body no se puede usar fuera de request y response, pero entonces no habría sido mágico.

Implementación v1

Mi naive approach fue escribir una versión funcional sin los nice to have que quería al principio y simplemente definir el método request_body en la eigenclass de la clase Versioning::VersionChange.

Con esto, se podía usar el método request_body dentro de request:

Todo bien hasta aquí. La funcionalidad básica estaba implementada ✅, y una versión funcional se podría haber creado. Pero habían dos cosas dando vueltas a mi cabeza:

  1. Me molestaba que la función se llamara request_body y no simplemente body. Desde el punto de vista de un dev usando el DSL, es ridículo tener que especificar que se está alterando el body de la request llamando una función request_body dentro del bloque pasado a la función request. Debería simplemente llamarse body, pero con esta primera versión de la implementación no habría habido forma de distinguir de dónde era el body a modificar (si de la request o de la response).
  2. request_body también se podía usar fuera del contexto de request:

El código de arriba no arrojaba errores explicativos para un dev. De hecho, se podría haber hecho algo así:

Y tampoco habían saltado errores. Un desastre. Lo que quería que pasara era que en cualquiera de los casos de arriba se levantara un error como el siguiente:

La magia puede ser buena

La magia versatilidad de Ruby

Me di cuenta rápido de que lo que quería hacer era algo similar a una inyección de contexto. Quería inyectarle al bloque de la función request un contexto que tuviera la función body, para no tener que definirla en la eigenclass de la clase Versioning::VersionChange.

Investigando, me topé con que Ruby tiene una forma de hacer algo similar. Los objetos en Ruby tienen un método llamado instance_eval, que recibe un bloque y lo ejecuta en el contexto del objeto en que fue llamado instance_eval. Veamos un ejemplo:

En el ejemplo de arriba, el bloque de operate usa el método add_two, pero ese método no existe en la clase OperatorClass. Pero como el bloque se evalúa usando instance_eval en una instancia de la clase NumberClass (que sí tiene el método add_two), entonces todo funciona bien.

💡 Aquí se me ocurrió algo: el bloque de request sería ejecutado por una clase con un contexto diseñado específicamente para las requests, y el bloque response sería ejecutado por una clase con un contexto diseñado específicamente para las responses. A trabajar.

Definí que tendría dos clases:

  1. Versioning::RequestTransformer
  2. Versioning::ResponseTransformer

Estas clases tendrían cada una el método body, por lo que cuando el bloque del método request fuera ejecutado por una instancia de la clase Versioning::RequestTransformer, sabría exactamente a qué body pertenecería el cambio. Entonces, mi clase Versioning::VersionChange quedó así:

Ahora está claro que todo el código dentro del bloque del método request se ejecuta usando el contexto de una instancia de Versioning::RequestTransformer. Finalmente solo me quedaba definir la clase en cuestión:

Notar que el método body no debe transformar la request inmediatamente, esto se debe hacer a voluntad al momento de transformar las versiones, por eso se define el método transform, y body solamente almacena el bloque.

Lo que hace la clase es recibir el bloque del método body, almacenarlo y exponer un método transform para poder usar el bloque que se le pasa al version change durante su creación.

Con esto, todos los bloques son ejecutados fuera del contexto de la clase Change misma, y son ejecutados por uno de los transformers 🤖.

Así quedó la implementación del DSL para el módulo de versionamiento de la API de Fintoc, con errores claros y una interfaz limpia. Algo que me quedó claro después de haber escrito el DSL: no hay que hacer magia, solamente entender las reglas de Ruby.

Si quieres ser parte de un equipo que versiona así, estamos buscando software engineers

Puedes ver las vacantes y postular acá.

Artículos más recientes