Creazione di un Ruby DSL: una guida alla metaprogrammazione avanzata

Pubblicato: 2022-03-11

I linguaggi specifici del dominio (DSL) sono uno strumento incredibilmente potente per semplificare la programmazione o la configurazione di sistemi complessi. Sono anche ovunque: come ingegnere del software è molto probabile che utilizzi diversi DSL diversi su base giornaliera.

In questo articolo imparerai quali sono i linguaggi specifici del dominio, quando dovrebbero essere usati e, infine, come puoi creare il tuo DSL personale in Ruby usando tecniche di metaprogrammazione avanzate.

Questo articolo si basa sull'introduzione di Nikola Todorovic alla metaprogrammazione di Ruby, pubblicata anche sul blog Toptal. Quindi, se sei nuovo nella metaprogrammazione, assicurati di leggerlo prima.

Che cos'è una lingua specifica del dominio?

La definizione generale di DSL è che sono linguaggi specializzati in un particolare dominio applicativo o caso d'uso. Ciò significa che puoi usarli solo per cose specifiche: non sono adatti per lo sviluppo di software generico. Se sembra ampio, è perché lo è: i DSL sono disponibili in molte forme e dimensioni diverse. Ecco alcune categorie importanti:

  • I linguaggi di markup come HTML e CSS sono progettati per descrivere cose specifiche come la struttura, il contenuto e gli stili delle pagine web. Non è possibile scrivere algoritmi arbitrari con loro, quindi si adattano alla descrizione di un DSL.
  • Macro e linguaggi di query (ad es. SQL) si trovano sopra un particolare sistema o un altro linguaggio di programmazione e di solito sono limitati in ciò che possono fare. Pertanto si qualificano ovviamente come lingue specifiche del dominio.
  • Molti DSL non hanno una propria sintassi, invece usano la sintassi di un linguaggio di programmazione consolidato in un modo intelligente che sembra usare un mini-linguaggio separato.

Quest'ultima categoria è chiamata DSL interna , ed è una di queste che creeremo a titolo di esempio molto presto. Ma prima di entrare in questo, diamo un'occhiata ad alcuni esempi ben noti di DSL interni. La sintassi di definizione del percorso in Rails è una di queste:

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

Questo è il codice Ruby, ma sembra più un linguaggio di definizione del percorso personalizzato, grazie alle varie tecniche di metaprogrammazione che rendono possibile un'interfaccia così pulita e facile da usare. Si noti che la struttura del DSL è implementata utilizzando i blocchi Ruby e le chiamate di metodo come get e resources vengono utilizzate per definire le parole chiave di questo mini-linguaggio.

La metaprogrammazione viene utilizzata ancora più pesantemente nella libreria di test 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

Questo pezzo di codice contiene anche esempi per interfacce fluenti , che consentono di leggere ad alta voce dichiarazioni come semplici frasi inglesi, rendendo molto più semplice capire cosa sta facendo il codice:

 # 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

Un altro esempio di interfaccia fluente è l'interfaccia di query di ActiveRecord e Arel, che utilizza internamente un albero sintattico astratto per creare query SQL complesse:

 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`

Sebbene la sintassi pulita ed espressiva di Ruby insieme alle sue capacità di metaprogrammazione lo renda particolarmente adatto per la creazione di linguaggi specifici di dominio, i DSL esistono anche in altri linguaggi. Ecco un esempio di test JavaScript che utilizza il 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!"); }); }); });

Questa sintassi forse non è così pulita come quella degli esempi di Ruby, ma mostra che con una denominazione intelligente e un uso creativo della sintassi, è possibile creare DSL interni utilizzando quasi tutti i linguaggi.

Il vantaggio dei DSL interni è che non richiedono un parser separato, che può essere notoriamente difficile da implementare correttamente. E poiché utilizzano la sintassi del linguaggio in cui sono implementati, si integrano perfettamente anche con il resto della codebase.

Ciò a cui dobbiamo rinunciare in cambio è la libertà sintattica: i DSL interni devono essere sintatticamente validi nel loro linguaggio di implementazione. Quanto devi scendere a compromessi a questo proposito dipende in gran parte dal linguaggio selezionato, con linguaggi dettagliati e tipizzati staticamente come Java e VB.NET che si trovano su un'estremità dello spettro e linguaggi dinamici con ampie capacità di metaprogrammazione come Ruby dall'altra fine.

Costruire il nostro: un DSL Ruby per la configurazione di classe

L'esempio DSL che costruiremo in Ruby è un motore di configurazione riutilizzabile per specificare gli attributi di configurazione di una classe Ruby usando una sintassi molto semplice. L'aggiunta di funzionalità di configurazione a una classe è un requisito molto comune nel mondo Ruby, specialmente quando si tratta di configurare gemme esterne e client API. La solita soluzione è un'interfaccia come questa:

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

Implementiamo prima questa interfaccia e poi, usandola come punto di partenza, possiamo migliorarla passo dopo passo aggiungendo più funzionalità, ripulendo la sintassi e rendendo il nostro lavoro riutilizzabile.

Di cosa abbiamo bisogno per far funzionare questa interfaccia? La classe MyApp dovrebbe avere un metodo di classe configure che prende un blocco e quindi esegue quel blocco cedendo ad esso, passando un oggetto di configurazione che ha metodi di accesso per leggere e scrivere i valori di configurazione:

 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 volta eseguito il blocco di configurazione, possiamo facilmente accedere e modificare i valori:

 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"

Finora, questa implementazione non sembra un linguaggio personalizzato abbastanza per essere considerato un DSL. Ma facciamo le cose un passo alla volta. Successivamente, disaccoppieremo la funzionalità di configurazione dalla classe MyApp e la renderemo sufficientemente generica da essere utilizzabile in molti casi d'uso diversi.

Rendendolo riutilizzabile

In questo momento, se volessimo aggiungere funzionalità di configurazione simili a una classe diversa, dovremmo copiare sia la classe Configuration che i relativi metodi di configurazione in quell'altra classe, nonché modificare l'elenco attr_accessor per modificare gli attributi di configurazione accettati. Per evitare di doverlo fare, spostiamo le funzionalità di configurazione in un modulo separato chiamato Configurable . Con ciò, la nostra classe MyApp sarà simile a questa:

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

Tutto ciò che riguarda la configurazione è stato spostato nel modulo 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

Non molto è cambiato qui, ad eccezione del nuovo metodo self.included . Abbiamo bisogno di questo metodo perché l'inclusione di un modulo mescola solo i suoi metodi di istanza, quindi i nostri metodi di config e configure della classe non verranno aggiunti alla classe host per impostazione predefinita. Tuttavia, se definiamo un metodo speciale chiamato included in un modulo, Ruby lo chiamerà ogni volta che quel modulo è incluso in una classe. Lì possiamo estendere manualmente la classe host con i metodi in 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

Non abbiamo ancora finito: il nostro prossimo passo è rendere possibile specificare gli attributi supportati nella classe host che include il modulo Configurable . Una soluzione come questa sembrerebbe carina:

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

Forse in qualche modo sorprendentemente, il codice sopra è sintatticamente corretto include non è una parola chiave ma semplicemente un metodo regolare che si aspetta un oggetto Module come parametro. Finché gli passiamo un'espressione che restituisce un Module , lo includerà felicemente. Quindi, invece di includere Configurable direttamente, abbiamo bisogno di un metodo with il nome che genera un nuovo modulo personalizzato con gli attributi specificati:

 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

C'è molto da disfare qui. L'intero modulo Configurable ora consiste in un solo metodo with tutto ciò che accade all'interno di quel metodo. Innanzitutto, creiamo una nuova classe anonima con Class.new per contenere i nostri metodi di accesso agli attributi. Poiché Class.new prende la definizione di classe come un blocco e i blocchi hanno accesso a variabili esterne, siamo in grado di passare la variabile attrs ad attr_accessor senza problemi.

 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

Il fatto che i blocchi in Ruby abbiano accesso a variabili esterne è anche il motivo per cui a volte vengono chiamati chiusure , poiché includono o "chiudono" l'ambiente esterno in cui sono stati definiti. Nota che ho usato la frase "definito in" e non “eseguito”. Esatto: indipendentemente da quando e dove i nostri blocchi define_method verranno eventualmente eseguiti, saranno sempre in grado di accedere alle variabili config_class e class_methods , anche dopo che il metodo with ha terminato l'esecuzione e restituito. L'esempio seguente mostra questo 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

Ora che sappiamo di questo comportamento pulito dei blocchi, possiamo andare avanti e definire un modulo anonimo in class_methods per i metodi di classe che verranno aggiunti alla classe host quando il nostro modulo generato sarà incluso. Qui dobbiamo usare define_method per definire il metodo di config , perché abbiamo bisogno dell'accesso alla variabile config_class esterna dall'interno del metodo. Definire il metodo usando la parola chiave def non ci darebbe quell'accesso perché le definizioni di metodi regolari con def non sono chiusure, tuttavia, define_method prende un blocco, quindi funzionerà:

 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`

Infine, chiamiamo Module.new per creare il modulo che stiamo per restituire. Qui dobbiamo definire il nostro metodo self.included , ma sfortunatamente non possiamo farlo con la parola chiave def , poiché il metodo necessita dell'accesso alla variabile class_methods esterna. Pertanto, dobbiamo usare di define_method con un blocco, ma questa volta sulla classe singleton del modulo, poiché stiamo definendo un metodo sull'istanza del modulo stesso. Oh, e poiché define_method è un metodo privato della classe singleton, dobbiamo usare send per invocarlo invece di chiamarlo direttamente:

 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

Uff, quella era già una metaprogrammazione piuttosto hardcore. Ma la complessità aggiunta ne è valsa la pena? Dai un'occhiata a quanto è facile da usare e decidi tu stesso:

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

Ma possiamo fare ancora meglio. Nel passaggio successivo ripuliremo un po' la sintassi del blocco configure per rendere il nostro modulo ancora più comodo da usare.

Ripulire la sintassi

C'è un'ultima cosa che mi infastidisce ancora con la nostra attuale implementazione: dobbiamo ripetere la config su ogni singola riga del blocco di configurazione. Un DSL corretto saprebbe che tutto all'interno del blocco configure dovrebbe essere eseguito nel contesto del nostro oggetto di configurazione e ci consentirebbe di ottenere la stessa cosa solo con questo:

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

Mettiamolo in pratica, vero? A quanto pare, avremo bisogno di due cose. Innanzitutto, abbiamo bisogno di un modo per eseguire il blocco passato per configure nel contesto dell'oggetto di configurazione in modo che le chiamate al metodo all'interno del blocco vadano a quell'oggetto. In secondo luogo, dobbiamo modificare i metodi di accesso in modo che scrivano il valore se viene loro fornito un argomento e lo rileggano quando vengono chiamati senza un argomento. Una possibile implementazione si presenta così:

 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

La modifica più semplice qui è l'esecuzione del blocco configure nel contesto dell'oggetto di configurazione. Chiamare il metodo instance_eval di Ruby su un oggetto ti consente di eseguire un blocco di codice arbitrario come se fosse in esecuzione all'interno di quell'oggetto, il che significa che quando il blocco di configurazione chiama il metodo app_id sulla prima riga, quella chiamata andrà alla nostra istanza della classe di configurazione.

La modifica dei metodi di accesso agli attributi in config_class è un po' più complicata. Per capirlo, dobbiamo prima capire cosa stava facendo esattamente attr_accessor dietro le quinte. Prendi ad esempio la seguente chiamata attr_accessor :

 class SomeClass attr_accessor :foo, :bar end

Ciò equivale a definire un metodo di lettura e scrittura per ogni attributo specificato:

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

Quindi, quando abbiamo scritto attr_accessor *attrs nel codice originale, Ruby ha definito per noi i metodi di lettura e scrittura di attributi per ogni attributo in attrs , ovvero abbiamo ottenuto i seguenti metodi di accesso standard: app_id , app_id= , title , title= e così via su. Nella nostra nuova versione, vogliamo mantenere i metodi di scrittura standard in modo che compiti come questo funzionino ancora correttamente:

 MyApp.config.app_ => "not_my_app"

Possiamo continuare a generare automaticamente i metodi writer chiamando attr_writer *attrs . Tuttavia, non possiamo più utilizzare i metodi di lettura standard, poiché devono anche essere in grado di scrivere l'attributo per supportare questa nuova sintassi:

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

Per generare noi stessi i metodi di lettura, eseguiamo un ciclo sull'array attrs e definiamo un metodo per ogni attributo che restituisce il valore corrente della variabile di istanza corrispondente se non viene fornito un nuovo valore e scrive il nuovo valore se è specificato:

 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

Qui usiamo il metodo instance_variable_get di Ruby per leggere una variabile di istanza con un nome arbitrario e instance_variable_set per assegnarle un nuovo valore. Sfortunatamente il nome della variabile deve essere preceduto da un segno "@" in entrambi i casi, da qui l'interpolazione delle stringhe.

Ti starai chiedendo perché dobbiamo usare un oggetto vuoto come valore predefinito per "non fornito" e perché non possiamo semplicemente usare nil per quello scopo. Il motivo è semplice: nil è un valore valido che qualcuno potrebbe voler impostare per un attributo di configurazione. Se avessimo testato per nil , non saremmo in grado di distinguere questi due scenari:

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

Quell'oggetto vuoto memorizzato in not_provided sarà sempre e solo uguale a se stesso, quindi in questo modo possiamo essere certi che nessuno lo passerà nel nostro metodo e causerà una lettura non intenzionale invece di una scrittura.

Aggiunta del supporto per i riferimenti

C'è un'altra caratteristica che potremmo aggiungere per rendere il nostro modulo ancora più versatile: la possibilità di fare riferimento a un attributo di configurazione da un altro:

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

Qui abbiamo aggiunto un riferimento da cookie_name all'attributo app_id . Si noti che l'espressione contenente il riferimento viene passata come un blocco: ciò è necessario per supportare la valutazione ritardata del valore dell'attributo. L'idea è di valutare il blocco solo in un secondo momento quando l'attributo viene letto e non quando viene definito, altrimenti accadrebbero cose divertenti se definissimo gli attributi nell'ordine "sbagliato":

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

Se l'espressione è racchiusa in un blocco, ciò impedirà che venga valutata immediatamente. Invece, possiamo salvare il blocco da eseguire in seguito quando viene recuperato il valore dell'attributo:

 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!

Non è necessario apportare grandi modifiche al modulo Configurable per aggiungere il supporto per la valutazione ritardata utilizzando i blocchi. In effetti, dobbiamo solo cambiare la definizione del metodo di attributo:

 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

Quando si imposta un attributo, il block || value block || value expression salva il blocco se ne è stato passato uno, altrimenti salva il valore. Quindi, quando l'attributo viene successivamente letto, controlliamo se si tratta di un blocco e lo valutiamo utilizzando instance_eval se lo è, o se non è un blocco, lo restituiamo come abbiamo fatto prima.

I riferimenti di supporto vengono forniti con i suoi avvertimenti e casi limite, ovviamente. Ad esempio, puoi probabilmente capire cosa succede se leggi uno qualsiasi degli attributi in questa configurazione:

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

Il modulo finito

Alla fine, abbiamo un modulo abbastanza accurato per rendere configurabile una classe arbitraria e quindi specificare quei valori di configurazione usando un DSL pulito e semplice che ci consente anche di fare riferimento a un attributo di configurazione da un altro:

 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

Ecco la versione finale del modulo che implementa la nostra DSL, un totale di 36 righe di codice:

 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

Guardando tutta questa magia di Ruby in un pezzo di codice che è quasi illeggibile e quindi molto difficile da mantenere, potresti chiederti se tutto questo sforzo sia valso la pena solo per rendere un po' più piacevole il nostro linguaggio specifico del dominio. La risposta breve è che dipende, il che ci porta all'argomento finale di questo articolo.

Ruby DSL: quando usarli e quando non usarli

Probabilmente avrai notato durante la lettura dei passaggi di implementazione del nostro DSL che, poiché abbiamo reso la sintassi del lato esterno del linguaggio più pulita e più facile da usare, abbiamo dovuto utilizzare un numero sempre crescente di trucchi di metaprogrammazione sotto il cofano per farlo accadere. Ciò ha portato a un'implementazione che sarà incredibilmente difficile da comprendere e modificare in futuro. Come tante altre cose nello sviluppo del software, anche questo è un compromesso che deve essere esaminato attentamente.

Affinché un linguaggio specifico di dominio valga i suoi costi di implementazione e manutenzione, deve portare una somma ancora maggiore di vantaggi sul tavolo. Questo di solito si ottiene rendendo il linguaggio riutilizzabile in quanti più scenari possibili, ammortizzando così il costo totale tra molti casi d'uso diversi. È più probabile che i framework e le librerie contengano i propri DSL proprio perché vengono utilizzati da molti sviluppatori, ognuno dei quali può godere dei vantaggi in termini di produttività di quei linguaggi incorporati.

Quindi, come principio generale, costruisci DSL solo se tu, altri sviluppatori o gli utenti finali della tua applicazione ne utilizzerai molto. Se crei un DSL, assicurati di includere una suite di test completa con esso, oltre a documentarne correttamente la sintassi poiché può essere molto difficile da capire dalla sola implementazione. Il futuro tu e i tuoi colleghi sviluppatori vi ringrazierete per questo.


Ulteriori letture sul blog di Toptal Engineering:

  • Come avvicinarsi alla scrittura di un interprete da zero