Criando uma DSL Ruby: Um Guia para Metaprogramação Avançada

Publicados: 2022-03-11

As linguagens específicas de domínio (DSL) são uma ferramenta incrivelmente poderosa para facilitar a programação ou a configuração de sistemas complexos. Eles também estão em toda parte—como engenheiro de software, você provavelmente está usando várias DSLs diferentes diariamente.

Neste artigo, você aprenderá o que são linguagens específicas de domínio, quando elas devem ser usadas e, finalmente, como você pode fazer sua própria DSL em Ruby usando técnicas avançadas de metaprogramação.

Este artigo se baseia na introdução de Nikola Todorovic à metaprogramação Ruby, também publicada no Toptal Blog. Portanto, se você é novo em metaprogramação, certifique-se de ler isso primeiro.

O que é um idioma específico de domínio?

A definição geral de DSLs é que elas são linguagens especializadas para um domínio de aplicação ou caso de uso específico. Isso significa que você só pode usá-los para coisas específicas - eles não são adequados para desenvolvimento de software de uso geral. Se isso soa amplo, é porque é – DSLs vêm em muitas formas e tamanhos diferentes. Aqui estão algumas categorias importantes:

  • As linguagens de marcação, como HTML e CSS, são projetadas para descrever coisas específicas, como estrutura, conteúdo e estilos de páginas da web. Não é possível escrever algoritmos arbitrários com eles, então eles se encaixam na descrição de uma DSL.
  • As linguagens de macro e de consulta (por exemplo, SQL) ficam em cima de um sistema específico ou outra linguagem de programação e geralmente são limitadas no que podem fazer. Portanto, eles obviamente se qualificam como idiomas específicos de domínio.
  • Muitas DSLs não têm sua própria sintaxe - em vez disso, elas usam a sintaxe de uma linguagem de programação estabelecida de uma maneira inteligente que parece usar uma minilinguagem separada.

Esta última categoria é chamada de DSL interna , e é uma delas que vamos criar como exemplo muito em breve. Mas antes de entrarmos nisso, vamos dar uma olhada em alguns exemplos bem conhecidos de DSLs internas. A sintaxe de definição de rota no Rails é uma delas:

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

Este é o código Ruby, mas parece mais uma linguagem de definição de rota personalizada, graças às várias técnicas de metaprogramação que tornam possível uma interface tão limpa e fácil de usar. Observe que a estrutura da DSL é implementada usando blocos Ruby, e chamadas de métodos como get e resources são usadas para definir as palavras-chave dessa minilinguagem.

A metaprogramação é usada ainda mais fortemente na biblioteca de testes 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 pedaço de código também contém exemplos de interfaces fluentes , que permitem que as declarações sejam lidas em voz alta como frases simples em inglês, tornando muito mais fácil entender o que o código está fazendo:

 # 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

Outro exemplo de interface fluente é a interface de consulta do ActiveRecord e do Arel, que usa uma árvore de sintaxe abstrata internamente para construir consultas SQL complexas:

 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`

Embora a sintaxe limpa e expressiva do Ruby, juntamente com seus recursos de metaprogramação, o torne exclusivamente adequado para a construção de linguagens específicas de domínio, as DSLs também existem em outras linguagens. Aqui está um exemplo de um teste de JavaScript usando o framework 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!"); }); }); });

Essa sintaxe talvez não seja tão limpa quanto a dos exemplos do Ruby, mas mostra que com nomenclatura inteligente e uso criativo da sintaxe, DSLs internas podem ser criadas usando praticamente qualquer linguagem.

O benefício das DSLs internas é que elas não exigem um analisador separado, o que pode ser notoriamente difícil de implementar corretamente. E como eles usam a sintaxe da linguagem em que são implementados, eles também se integram perfeitamente com o restante da base de código.

O que temos que abrir mão em troca é a liberdade sintática — as DSLs internas precisam ser sintaticamente válidas em sua linguagem de implementação. O quanto você tem que comprometer a esse respeito depende em grande parte da linguagem selecionada, com linguagens verbosas e estaticamente tipadas, como Java e VB.NET, em uma extremidade do espectro, e linguagens dinâmicas com recursos extensos de metaprogramação, como Ruby, na outra fim.

Construindo o nosso próprio - uma DSL Ruby para configuração de classe

O exemplo de DSL que vamos construir em Ruby é um mecanismo de configuração reutilizável para especificar os atributos de configuração de uma classe Ruby usando uma sintaxe muito simples. Adicionar recursos de configuração a uma classe é um requisito muito comum no mundo Ruby, especialmente quando se trata de configurar gems externas e clientes de API. A solução usual é uma interface como esta:

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

Vamos implementar essa interface primeiro — e então, usando-a como ponto de partida, podemos melhorá-la passo a passo adicionando mais recursos, limpando a sintaxe e tornando nosso trabalho reutilizável.

O que precisamos para fazer essa interface funcionar? A classe MyApp deve ter um método de classe configure que pegue um bloco e depois execute esse bloco cedendo a ele, passando um objeto de configuração que possui métodos acessadores para ler e escrever os valores de configuração:

 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

Uma vez executado o bloco de configuração, podemos acessar e modificar facilmente os 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"

Até agora, essa implementação não parece uma linguagem personalizada o suficiente para ser considerada uma DSL. Mas vamos levar as coisas um passo de cada vez. Em seguida, vamos desacoplar a funcionalidade de configuração da classe MyApp e torná-la genérica o suficiente para ser usada em muitos casos de uso diferentes.

Tornando-o reutilizável

No momento, se quiséssemos adicionar recursos de configuração semelhantes a uma classe diferente, teríamos que copiar a classe Configuration e seus métodos de configuração relacionados para essa outra classe, bem como editar a lista attr_accessor para alterar os atributos de configuração aceitos. Para evitar ter que fazer isso, vamos mover os recursos de configuração para um módulo separado chamado Configurable . Com isso, nossa classe MyApp ficará assim:

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

Tudo relacionado à configuração foi movido para o 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

Não mudou muito aqui, exceto pelo novo método self.included . Precisamos desse método porque incluir um módulo apenas mistura seus métodos de instância, portanto, nossos métodos de classe config e configure não serão adicionados à classe host por padrão. No entanto, se definirmos um método especial chamado included em um módulo, o Ruby o chamará sempre que esse módulo for incluído em uma classe. Lá podemos estender manualmente a classe host com os métodos em 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

Ainda não terminamos - nosso próximo passo é tornar possível especificar os atributos suportados na classe de host que inclui o módulo Configurable . Uma solução como essa ficaria legal:

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

Talvez surpreendentemente, o código acima esteja sintaticamente correto — include não é uma palavra-chave, mas simplesmente um método regular que espera um objeto Module como seu parâmetro. Contanto que passemos uma expressão que retorne um Module , ele a incluirá com prazer. Então, em vez de incluir o Configurable diretamente, precisamos de um método with o nome nele que gere um novo módulo personalizado com os 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

Há muito o que descompactar aqui. Todo o módulo Configurable agora consiste em apenas um único método with , com tudo acontecendo dentro desse método. Primeiro, criamos uma nova classe anônima com Class.new para armazenar nossos métodos de acesso de atributo. Como Class.new toma a definição de classe como um bloco e os blocos têm acesso a variáveis ​​externas, podemos passar a variável attrs para attr_accessor sem 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

O fato de os blocos em Ruby terem acesso a variáveis ​​externas também é a razão pela qual eles às vezes são chamados de closures , pois incluem ou “fecham” o ambiente externo em que foram definidos. Observe que usei a frase “definido em” e não “executado em”. Isso mesmo – independente de quando e onde nossos blocos define_method serão executados, eles sempre poderão acessar as variáveis config_class e class_methods , mesmo depois que o método with terminar de rodar e retornar. O exemplo a seguir demonstra esse comportamento:

 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

Agora que sabemos sobre esse comportamento de blocos, podemos seguir em frente e definir um módulo anônimo em class_methods para os métodos de classe que serão adicionados à classe host quando nosso módulo gerado for incluído. Aqui temos que usar define_method para definir o método de config , porque precisamos acessar a variável config_class externa de dentro do método. Definir o método usando a palavra-chave def não nos daria esse acesso porque definições de métodos regulares com def não são closures – no entanto, define_method leva um bloco, então isso 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, chamamos Module.new para criar o módulo que vamos retornar. Aqui precisamos definir nosso método self.included , mas infelizmente não podemos fazer isso com a palavra-chave def , pois o método precisa acessar a variável externa class_methods . Portanto, temos que usar define_method com um bloco novamente, mas desta vez na classe singleton do módulo, pois estamos definindo um método na própria instância do módulo. Ah, e como define_method é um método privado da classe singleton, temos que usar send para invocá-lo em vez de chamá-lo diretamente:

 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

Ufa, isso já foi uma metaprogramação bastante hardcore. Mas a complexidade adicional valeu a pena? Veja como é fácil de usar e decida por si mesmo:

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

Mas podemos fazer ainda melhor. Na próxima etapa, vamos limpar um pouco a sintaxe do bloco configure para tornar nosso módulo ainda mais conveniente de usar.

Limpando a Sintaxe

Há uma última coisa que ainda está me incomodando com nossa implementação atual - temos que repetir a config em cada linha do bloco de configuração. Uma DSL adequada saberia que tudo dentro do bloco configure deve ser executado no contexto do nosso objeto de configuração e nos permitiria obter a mesma coisa apenas com isso:

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

Vamos implementá-lo, sim? Pelo que parece, precisaremos de duas coisas. Primeiro, precisamos de uma maneira de executar o bloco passado para configure no contexto do objeto de configuração para que as chamadas de método dentro do bloco sejam direcionadas para esse objeto. Em segundo lugar, temos que alterar os métodos de acesso para que eles escrevam o valor se um argumento for fornecido a eles e o leiam de volta quando chamados sem um argumento. Uma possível implementação se parece com isso:

 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

A mudança mais simples aqui é executar o bloco configure no contexto do objeto de configuração. Chamar o método instance_eval de Ruby em um objeto permite que você execute um bloco de código arbitrário como se estivesse sendo executado dentro desse objeto, o que significa que quando o bloco de configuração chama o método app_id na primeira linha, essa chamada irá para nossa instância de classe de configuração.

A mudança nos métodos de acesso de atributo em config_class é um pouco mais complicada. Para entendê-lo, precisamos primeiro entender o que exatamente o attr_accessor estava fazendo nos bastidores. Tome a seguinte chamada attr_accessor por exemplo:

 class SomeClass attr_accessor :foo, :bar end

Isso é equivalente a definir um método de leitor e gravador para cada atributo especificado:

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

Então, quando escrevemos attr_accessor *attrs no código original, Ruby definiu os métodos de leitor e gravador de atributo para nós para cada atributo em attrs — ou seja, temos os seguintes métodos de acesso padrão: app_id , app_id= , title , title= e assim em. Em nossa nova versão, queremos manter os métodos de gravação padrão para que atribuições como essa ainda funcionem corretamente:

 MyApp.config.app_ => "not_my_app"

Podemos continuar gerando automaticamente os métodos writer chamando attr_writer *attrs . No entanto, não podemos mais usar os métodos de leitura padrão, pois eles também precisam ser capazes de escrever o atributo para suportar essa nova sintaxe:

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

Para gerar os métodos do leitor nós mesmos, fazemos um loop no array attrs e definimos um método para cada atributo que retorna o valor atual da variável de instância correspondente se nenhum novo valor for fornecido e grava o novo valor se for especificado:

 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

Aqui usamos o método instance_variable_get do Ruby para ler uma variável de instância com um nome arbitrário e instance_variable_set para atribuir um novo valor a ela. Infelizmente, o nome da variável deve ser prefixado com um sinal “@” em ambos os casos – daí a interpolação de string.

Você pode estar se perguntando por que temos que usar um objeto em branco como o valor padrão para “não fornecido” e por que não podemos simplesmente usar nil para esse propósito. A razão é simples — nil é um valor válido que alguém pode querer definir para um atributo de configuração. Se testássemos para nil , não poderíamos distinguir esses dois cenários:

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

Esse objeto em branco armazenado em not_provided só será igual a si mesmo, dessa forma, podemos ter certeza de que ninguém o passará para o nosso método e causará uma leitura não intencional em vez de uma gravação.

Adicionando suporte para referências

Há mais um recurso que poderíamos adicionar para tornar nosso módulo ainda mais versátil - a capacidade de referenciar um atributo de configuração de outro:

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

Aqui adicionamos uma referência de cookie_name ao atributo app_id . Observe que a expressão que contém a referência é passada como um bloco - isso é necessário para dar suporte à avaliação atrasada do valor do atributo. A ideia é só avaliar o bloco mais tarde quando o atributo for lido e não quando for definido – senão coisas engraçadas aconteceriam se definimos os atributos na ordem “errada”:

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

Se a expressão estiver envolvida em um bloco, isso impedirá que ela seja avaliada imediatamente. Em vez disso, podemos salvar o bloco para ser executado posteriormente quando o valor do atributo for recuperado:

 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!

Não precisamos fazer grandes alterações no módulo Configurable para adicionar suporte para avaliação atrasada usando blocos. Na verdade, basta alterar a definição do método do 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

Ao definir um atributo, o block || value expressão de block || value salva o bloco se foi passado, caso contrário, salva o valor. Então, quando o atributo é lido posteriormente, verificamos se é um bloco e avaliamos usando instance_eval se for, ou se não for um bloco, retornamos como fizemos antes.

As referências de suporte vêm com suas próprias ressalvas e casos extremos, é claro. Por exemplo, você provavelmente pode descobrir o que acontece se ler qualquer um dos atributos nesta configuração:

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

O Módulo Concluído

No final, nós temos um módulo bem legal para tornar uma classe arbitrária configurável e, em seguida, especificar esses valores de configuração usando uma DSL limpa e simples que também nos permite referenciar um atributo de configuração de outro:

 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

Aqui está a versão final do módulo que implementa nosso DSL—um total de 36 linhas 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

Olhando para toda essa magia Ruby em um pedaço de código que é quase ilegível e, portanto, muito difícil de manter, você pode se perguntar se todo esse esforço valeu a pena apenas para tornar nossa linguagem específica de domínio um pouco melhor. A resposta curta é que depende – o que nos leva ao tópico final deste artigo.

Ruby DSLs — Quando usar e quando não usá-las

Você provavelmente notou ao ler as etapas de implementação de nossa DSL que, como tornamos a sintaxe externa da linguagem mais limpa e fácil de usar, tivemos que usar um número cada vez maior de truques de metaprogramação sob o capô para que isso acontecesse. Isso resultou em uma implementação que será incrivelmente difícil de entender e modificar no futuro. Como tantas outras coisas no desenvolvimento de software, essa também é uma troca que deve ser cuidadosamente examinada.

Para que uma linguagem específica de domínio valha seu custo de implementação e manutenção, ela deve trazer uma soma ainda maior de benefícios para a mesa. Isso geralmente é alcançado tornando a linguagem reutilizável em tantos cenários diferentes quanto possível, amortizando assim o custo total entre muitos casos de uso diferentes. É mais provável que frameworks e bibliotecas contenham suas próprias DSLs exatamente porque são usadas por muitos desenvolvedores, cada um dos quais pode aproveitar os benefícios de produtividade dessas linguagens incorporadas.

Portanto, como princípio geral, apenas construa DSLs se você, outros desenvolvedores ou os usuários finais de seu aplicativo forem usar muito deles. Se você criar uma DSL, certifique-se de incluir um conjunto de testes abrangente com ela, bem como documentar adequadamente sua sintaxe, pois pode ser muito difícil descobrir apenas com a implementação. No futuro, você e seus colegas desenvolvedores agradecerão por isso.


Leitura adicional no Blog da Toptal Engineering:

  • Como abordar a escrita de um intérprete do zero