Creación de un Ruby DSL: una guía para la metaprogramación avanzada

Publicado: 2022-03-11

Los lenguajes específicos de dominio (DSL) son una herramienta increíblemente poderosa para facilitar la programación o configuración de sistemas complejos. También están en todas partes: como ingeniero de software, lo más probable es que utilice varios DSL diferentes a diario.

En este artículo, aprenderá qué son los lenguajes específicos de dominio, cuándo deben usarse y, finalmente, cómo puede crear su propio DSL en Ruby utilizando técnicas avanzadas de metaprogramación.

Este artículo se basa en la introducción de Nikola Todorovic a la metaprogramación de Ruby, también publicada en el blog de Toptal. Entonces, si eres nuevo en la metaprogramación, asegúrate de leer eso primero.

¿Qué es un lenguaje específico de dominio?

La definición general de DSL es que son lenguajes especializados para un dominio de aplicación o caso de uso en particular. Esto significa que solo puede usarlos para cosas específicas; no son adecuados para el desarrollo de software de propósito general. Si eso suena amplio, es porque lo es: los DSL vienen en muchas formas y tamaños diferentes. Aquí hay algunas categorías importantes:

  • Los lenguajes de marcado como HTML y CSS están diseñados para describir cosas específicas como la estructura, el contenido y los estilos de las páginas web. No es posible escribir algoritmos arbitrarios con ellos, por lo que se ajustan a la descripción de un DSL.
  • Los lenguajes de macros y consultas (por ejemplo, SQL) se ubican sobre un sistema en particular u otro lenguaje de programación y generalmente están limitados en lo que pueden hacer. Por lo tanto, obviamente califican como lenguajes específicos de dominio.
  • Muchos DSL no tienen su propia sintaxis; en cambio, usan la sintaxis de un lenguaje de programación establecido de una manera inteligente que se siente como usar un mini-lenguaje separado.

Esta última categoría se llama DSL interno , y es uno de estos que vamos a crear como ejemplo muy pronto. Pero antes de entrar en eso, echemos un vistazo a algunos ejemplos conocidos de DSL internos. La sintaxis de definición de ruta en Rails es una de ellas:

 Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end

Este es código Ruby, pero se siente más como un lenguaje de definición de ruta personalizado, gracias a las diversas técnicas de metaprogramación que hacen posible una interfaz tan limpia y fácil de usar. Tenga en cuenta que la estructura del DSL se implementa mediante bloques de Ruby y las llamadas a métodos, como get y resources , se utilizan para definir las palabras clave de este minilenguaje.

La metaprogramación se usa aún más en la biblioteca de pruebas RSpec:

 describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end

Este fragmento de código también contiene ejemplos de interfaces fluidas , que permiten que las declaraciones se lean en voz alta como oraciones sencillas en inglés, lo que facilita mucho la comprensión de lo que hace el código:

 # Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success

Otro ejemplo de una interfaz fluida es la interfaz de consulta de ActiveRecord y Arel, que utiliza internamente un árbol de sintaxis abstracta para crear consultas SQL complejas:

 Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`

Aunque la sintaxis limpia y expresiva de Ruby junto con sus capacidades de metaprogramación lo hacen especialmente adecuado para crear lenguajes específicos de dominio, los DSL también existen en otros lenguajes. Aquí hay un ejemplo de una prueba de JavaScript usando el marco Jasmine:

 describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });

Esta sintaxis quizás no sea tan clara como la de los ejemplos de Ruby, pero muestra que con nombres inteligentes y un uso creativo de la sintaxis, se pueden crear DSL internos usando casi cualquier idioma.

El beneficio de los DSL internos es que no requieren un analizador separado, lo que puede ser notoriamente difícil de implementar correctamente. Y como utilizan la sintaxis del lenguaje en el que están implementados, también se integran a la perfección con el resto del código base.

A lo que tenemos que renunciar a cambio es a la libertad sintáctica: los DSL internos tienen que ser sintácticamente válidos en su lenguaje de implementación. Cuánto debe comprometerse a este respecto depende en gran medida del lenguaje seleccionado, con lenguajes detallados y estáticos como Java y VB.NET en un extremo del espectro, y lenguajes dinámicos con amplias capacidades de metaprogramación como Ruby en el otro. final.

Construyendo el nuestro: un Ruby DSL para la configuración de clase

El DSL de ejemplo que vamos a construir en Ruby es un motor de configuración reutilizable para especificar los atributos de configuración de una clase de Ruby usando una sintaxis muy simple. Agregar capacidades de configuración a una clase es un requisito muy común en el mundo de Ruby, especialmente cuando se trata de configurar gemas externas y clientes API. La solución habitual es una interfaz como esta:

 MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end

Primero implementemos esta interfaz y luego, usándola como punto de partida, podemos mejorarla paso a paso agregando más funciones, limpiando la sintaxis y haciendo que nuestro trabajo sea reutilizable.

¿Qué necesitamos para que esta interfaz funcione? La clase MyApp debe tener un método de clase de configure que tome un bloque y luego ejecute ese bloque cediendo ante él, pasando un objeto de configuración que tenga métodos de acceso para leer y escribir los valores de configuración:

 class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end

Una vez ejecutado el bloque de configuración, podemos acceder y modificar fácilmente los valores:

 MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"

Hasta ahora, esta implementación no parece un lenguaje lo suficientemente personalizado como para ser considerado un DSL. Pero vamos a tomar las cosas un paso a la vez. A continuación, desvincularemos la funcionalidad de configuración de la clase MyApp y la haremos lo suficientemente genérica para que se pueda utilizar en muchos casos de uso diferentes.

hacerlo reutilizable

En este momento, si quisiéramos agregar capacidades de configuración similares a una clase diferente, tendríamos que copiar la clase Configuration y sus métodos de configuración relacionados en esa otra clase, así como editar la lista attr_accessor para cambiar los atributos de configuración aceptados. Para evitar tener que hacer esto, movamos las características de configuración a un módulo separado llamado Configurable . Con eso, nuestra clase MyApp se verá así:

 class MyApp #BOLD include Configurable #BOLDEND # ... end

Todo lo relacionado con la configuración se ha movido al módulo Configurable :

 #BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND

No ha cambiado mucho aquí, excepto por el nuevo método self.included . Necesitamos este método porque incluir un módulo solo se mezcla en sus métodos de instancia, por lo que nuestros métodos de clase config y configure no se agregarán a la clase de host de forma predeterminada. Sin embargo, si definimos un método especial llamado included en un módulo, Ruby lo llamará cada vez que ese módulo esté incluido en una clase. Allí podemos extender manualmente la clase de host con los métodos en ClassMethods :

 def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end

Aún no hemos terminado: nuestro siguiente paso es hacer posible especificar los atributos admitidos en la clase de host que incluye el módulo Configurable . Una solución como esta se vería bien:

 class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end

Quizás algo sorprendente, el código anterior es sintácticamente correcto include no es una palabra clave sino simplemente un método normal que espera un objeto Module como su parámetro. Siempre que le pasemos una expresión que devuelva un Module , felizmente lo incluirá. Entonces, en lugar de incluir Configurable directamente, necesitamos un método with el nombre que genere un nuevo módulo personalizado con los atributos especificados:

 module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end

Hay mucho que desempacar aquí. Todo el módulo Configurable ahora consiste en un solo método with , con todo lo que sucede dentro de ese método. Primero, creamos una nueva clase anónima con Class.new para contener nuestros métodos de acceso a atributos. Debido a que Class.new toma la definición de clase como un bloque y los bloques tienen acceso a variables externas, podemos pasar la variable attrs a attr_accessor sin problemas.

 def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end

El hecho de que los bloques en Ruby tengan acceso a variables externas también es la razón por la que a veces se los llama cierres , ya que incluyen o "cierran" el entorno externo en el que se definieron. Tenga en cuenta que usé la frase "definido en" y no “ejecutado en”. Eso es correcto: independientemente de cuándo y dónde se ejecutarán finalmente nuestros bloques define_method , siempre podrán acceder a las variables config_class y class_methods , incluso después de que el método with haya terminado de ejecutarse y haya regresado. El siguiente ejemplo demuestra este comportamiento:

 def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end  block = create_block # call `create_block` to retrieve the block  block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us

Ahora que conocemos este comportamiento ordenado de los bloques, podemos continuar y definir un módulo anónimo en class_methods para los métodos de clase que se agregarán a la clase host cuando se incluya nuestro módulo generado. Aquí tenemos que usar define_method para definir el método de config , porque necesitamos acceso a la variable externa config_class desde dentro del método. Definir el método usando la palabra clave def no nos daría ese acceso porque las definiciones de métodos regulares con def no son cierres; sin embargo, define_method toma un bloque, por lo que funcionará:

 config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`

Finalmente, llamamos a Module.new para crear el módulo que vamos a devolver. Aquí necesitamos definir nuestro método self.included , pero desafortunadamente no podemos hacerlo con la palabra clave def , ya que el método necesita acceso a la variable class_methods externa. Por lo tanto, tenemos que usar define_method con un bloque nuevamente, pero esta vez en la clase singleton del módulo, ya que estamos definiendo un método en la propia instancia del módulo. Ah, y como define_method es un método privado de la clase singleton, tenemos que usar send para invocarlo en lugar de llamarlo directamente:

 class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end

Uf, eso ya fue una metaprogramación bastante dura. Pero, ¿valió la pena la complejidad añadida? Echa un vistazo a lo fácil que es utilizarlo y decide por ti mismo:

 class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"

Pero podemos hacerlo aún mejor. En el siguiente paso, limpiaremos un poco la sintaxis del bloque de configure para que nuestro módulo sea aún más cómodo de usar.

Limpiando la sintaxis

Hay una última cosa que todavía me molesta con nuestra implementación actual: tenemos que repetir la config en cada línea del bloque de configuración. Un DSL adecuado sabría que todo dentro del bloque de configure debe ejecutarse en el contexto de nuestro objeto de configuración y nos permitiría lograr lo mismo con solo esto:

 MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end

Vamos a implementarlo, ¿de acuerdo? Por lo que parece, necesitaremos dos cosas. Primero, necesitamos una forma de ejecutar el bloque pasado para configure en el contexto del objeto de configuración para que las llamadas al método dentro del bloque vayan a ese objeto. En segundo lugar, tenemos que cambiar los métodos de acceso para que escriban el valor si se les proporciona un argumento y lo lean cuando se les llame sin un argumento. Una posible implementación se ve así:

 module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end

El cambio más simple aquí es ejecutar el bloque de configure en el contexto del objeto de configuración. Llamar al método instance_eval de Ruby en un objeto le permite ejecutar un bloque de código arbitrario como si se estuviera ejecutando dentro de ese objeto, lo que significa que cuando el bloque de configuración llama al método app_id en la primera línea, esa llamada irá a nuestra instancia de clase de configuración.

El cambio a los métodos de acceso a atributos en config_class es un poco más complicado. Para entenderlo, primero debemos entender qué estaba haciendo exactamente attr_accessor detrás de escena. Tome la siguiente llamada attr_accessor por ejemplo:

 class SomeClass attr_accessor :foo, :bar end

Esto es equivalente a definir un método de lectura y escritura para cada atributo especificado:

 class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end

Entonces, cuando escribimos attr_accessor *attrs en el código original, Ruby definió los métodos de lectura y escritura de atributos para nosotros para cada atributo en attrs , es decir, obtuvimos los siguientes métodos de acceso estándar: app_id , app_id= , title , title= y así en. En nuestra nueva versión, queremos mantener los métodos de escritura estándar para que tareas como esta sigan funcionando correctamente:

 MyApp.config.app_ => "not_my_app"

Podemos seguir generando automáticamente los métodos de escritor llamando a attr_writer *attrs . Sin embargo, ya no podemos usar los métodos de lectura estándar, ya que también deben ser capaces de escribir el atributo para admitir esta nueva sintaxis:

 MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end

Para generar los métodos del lector nosotros mismos, recorremos la matriz attrs y definimos un método para cada atributo que devuelve el valor actual de la variable de instancia coincidente si no se proporciona un valor nuevo y escribe el valor nuevo si se especifica:

 not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end

Aquí usamos el método instance_variable_get de Ruby para leer una variable de instancia con un nombre arbitrario, y instance_variable_set para asignarle un nuevo valor. Desafortunadamente, el nombre de la variable debe tener el prefijo "@" en ambos casos, de ahí la interpolación de cadenas.

Quizás se pregunte por qué tenemos que usar un objeto en blanco como valor predeterminado para "no proporcionado" y por qué no podemos simplemente usar nil para ese propósito. La razón es simple: nil es un valor válido que alguien podría querer establecer para un atributo de configuración. Si probamos nil , no podríamos diferenciar estos dos escenarios:

 MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end

Ese objeto en blanco almacenado en not_provided solo será igual a sí mismo, por lo que de esta manera podemos estar seguros de que nadie lo pasará a nuestro método y provocará una lectura no deseada en lugar de una escritura.

Agregar soporte para referencias

Hay una característica más que podríamos agregar para hacer que nuestro módulo sea aún más versátil: la capacidad de hacer referencia a un atributo de configuración de otro:

 MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"

Aquí agregamos una referencia de cookie_name al atributo app_id . Tenga en cuenta que la expresión que contiene la referencia se pasa como un bloque; esto es necesario para admitir la evaluación retrasada del valor del atributo. La idea es evaluar el bloque solo más tarde cuando se lee el atributo y no cuando se define; de ​​lo contrario, sucederían cosas divertidas si definiéramos los atributos en el orden "incorrecto":

 SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny

Si la expresión está envuelta en un bloque, eso evitará que se evalúe de inmediato. En cambio, podemos guardar el bloque para ejecutarlo más tarde cuando se recupere el valor del atributo:

 SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!

No tenemos que hacer grandes cambios en el módulo Configurable para agregar soporte para la evaluación retrasada usando bloques. De hecho, solo tenemos que cambiar la definición del método de atributo:

 define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end

Al establecer un atributo, el block || value expresión de block || value guarda el bloque si se pasó uno, o de lo contrario guarda el valor. Luego, cuando el atributo se lee más tarde, verificamos si es un bloque y lo evaluamos usando instance_eval si lo es, o si no es un bloque, lo devolvemos como lo hicimos antes.

Las referencias de apoyo vienen con sus propias advertencias y casos extremos, por supuesto. Por ejemplo, probablemente pueda averiguar qué sucede si lee cualquiera de los atributos en esta configuración:

 SomeClass.configure do foo { bar } bar { foo } end

El módulo terminado

Al final, obtuvimos un módulo bastante bueno para hacer configurable una clase arbitraria y luego especificar esos valores de configuración usando un DSL limpio y simple que también nos permite hacer referencia a un atributo de configuración de otro:

 class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end

Aquí está la versión final del módulo que implementa nuestro DSL: un total de 36 líneas de código:

 module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end

Al ver toda esta magia de Ruby en un fragmento de código que es casi ilegible y, por lo tanto, muy difícil de mantener, es posible que se pregunte si valió la pena todo este esfuerzo solo para hacer que nuestro lenguaje específico de dominio sea un poco más agradable. La respuesta corta es que depende, lo que nos lleva al tema final de este artículo.

Ruby DSL: cuándo usarlos y cuándo no usarlos

Probablemente haya notado al leer los pasos de implementación de nuestro DSL que, a medida que hicimos que la sintaxis externa del lenguaje fuera más limpia y fácil de usar, tuvimos que usar un número cada vez mayor de trucos de metaprogramación debajo del capó para que esto sucediera. Esto resultó en una implementación que será increíblemente difícil de entender y modificar en el futuro. Como tantas otras cosas en el desarrollo de software, esta también es una compensación que debe examinarse cuidadosamente.

Para que un lenguaje específico de dominio valga la pena su costo de implementación y mantenimiento, debe traer una suma aún mayor de beneficios a la mesa. Esto generalmente se logra haciendo que el lenguaje sea reutilizable en tantos escenarios diferentes como sea posible, amortizando así el costo total entre muchos casos de uso diferentes. Es más probable que los marcos y las bibliotecas contengan sus propios DSL porque son utilizados por muchos desarrolladores, cada uno de los cuales puede disfrutar de los beneficios de productividad de esos lenguajes integrados.

Por lo tanto, como principio general, solo cree DSL si usted, otros desarrolladores o los usuarios finales de su aplicación obtendrán un gran uso de ellos. Si crea un DSL, asegúrese de incluir un conjunto de pruebas completo con él, así como documentar adecuadamente su sintaxis, ya que puede ser muy difícil de entender solo a partir de la implementación. El futuro, tú y tus compañeros desarrolladores te lo agradecerán.


Lecturas adicionales en el blog de ingeniería de Toptal:

  • Cómo abordar la escritura de un intérprete desde cero