Crearea unui Ruby DSL: un ghid pentru metaprogramarea avansată
Publicat: 2022-03-11Limbile specifice domeniului (DSL) sunt un instrument incredibil de puternic pentru a facilita programarea sau configurarea sistemelor complexe. Ele sunt, de asemenea, peste tot - ca inginer software, cel mai probabil, utilizați mai multe DSL-uri diferite în fiecare zi.
În acest articol, veți afla ce sunt limbajele specifice domeniului, când ar trebui utilizate și, în sfârșit, cum vă puteți crea propriul DSL în Ruby folosind tehnici avansate de metaprogramare.
Acest articol se bazează pe introducerea lui Nikola Todorovic în metaprogramarea Ruby, publicată și pe Blogul Toptal. Deci, dacă sunteți nou în metaprogramare, asigurați-vă că ați citit asta mai întâi.
Ce este un limbaj specific unui domeniu?
Definiția generală a DSL-urilor este că acestea sunt limbi specializate pentru un anumit domeniu de aplicație sau caz de utilizare. Aceasta înseamnă că le puteți folosi doar pentru lucruri specifice - nu sunt potrivite pentru dezvoltarea de software de uz general. Dacă sună larg, asta se datorează faptului că este - DSL-urile vin în multe forme și dimensiuni diferite. Iată câteva categorii importante:
- Limbajele de marcare precum HTML și CSS sunt concepute pentru a descrie lucruri specifice, cum ar fi structura, conținutul și stilurile paginilor web. Nu este posibil să scrieți algoritmi arbitrari cu ei, așa că se potrivesc cu descrierea unui DSL.
- Limbajele macro și de interogare (de exemplu, SQL) stau deasupra unui anumit sistem sau alt limbaj de programare și sunt de obicei limitate în ceea ce pot face. Prin urmare, ele se califică în mod evident ca limbi specifice domeniului.
- Multe DSL-uri nu au propria lor sintaxă – în schimb, ele folosesc sintaxa unui limbaj de programare consacrat într-un mod inteligent, care se simte ca și cum ar folosi un mini-limbaj separat.
Această ultimă categorie se numește DSL intern și este una dintre acestea pe care o vom crea ca exemplu foarte curând. Dar înainte de a intra în asta, să aruncăm o privire la câteva exemple binecunoscute de DSL-uri interne. Sintaxa definiției rutei în Rails este una dintre ele:
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end Acesta este codul Ruby, dar se simte mai degrabă ca un limbaj personalizat de definire a rutei, datorită diferitelor tehnici de metaprogramare care fac posibilă o interfață atât de curată și ușor de utilizat. Observați că structura DSL-ului este implementată folosind blocuri Ruby, iar apelurile de metode precum get și resources sunt folosite pentru definirea cuvintelor cheie ale acestui mini-limbaj.
Metaprogramarea este folosită și mai mult în biblioteca de testare 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 endAceastă bucată de cod conține, de asemenea, exemple pentru interfețe fluente , care permit citirea cu voce tare a declarațiilor ca propoziții simple în limba engleză, ceea ce face mult mai ușor de înțeles ce face codul:
# 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_successUn alt exemplu de interfață fluentă este interfața de interogare a ActiveRecord și Arel, care utilizează intern un arbore de sintaxă abstractă pentru a construi interogări SQL complexe:
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`Deși sintaxa curată și expresivă a lui Ruby, împreună cu capacitățile sale de metaprogramare, îl fac unic potrivit pentru construirea de limbaje specifice domeniului, DSL-urile există și în alte limbi. Iată un exemplu de test JavaScript folosind cadrul 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!"); }); }); });Această sintaxă poate nu este la fel de curată ca cea a exemplelor Ruby, dar arată că, cu denumirea inteligentă și utilizarea creativă a sintaxei, DSL-urile interne pot fi create folosind aproape orice limbaj.
Avantajul DSL-urilor interne este că nu necesită un parser separat, care poate fi notoriu de dificil de implementat corect. Și pentru că folosesc sintaxa limbajului în care sunt implementate, se integrează perfect cu restul bazei de cod.
Ceea ce trebuie să renunțăm în schimb este libertatea sintactică – DSL-urile interne trebuie să fie valide sintactic în limbajul lor de implementare. Cât de mult trebuie să faceți compromisuri în această privință depinde în mare măsură de limbajul selectat, cu limbaje verbose, tipizate static, cum ar fi Java și VB.NET, la un capăt al spectrului, iar limbaje dinamice cu capabilități extinse de metaprogramare, cum ar fi Ruby, pe de altă parte. Sfârșit.
Construirea propriei noastre — Un DSL Ruby pentru configurarea clasei
Exemplul DSL pe care îl vom construi în Ruby este un motor de configurare reutilizabil pentru specificarea atributelor de configurare ale unei clase Ruby folosind o sintaxă foarte simplă. Adăugarea de capabilități de configurare la o clasă este o cerință foarte comună în lumea Ruby, mai ales când vine vorba de configurarea pietrelor externe și a clienților API. Soluția obișnuită este o interfață ca aceasta:
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" endSă implementăm mai întâi această interfață și apoi, folosind-o ca punct de plecare, o putem îmbunătăți pas cu pas, adăugând mai multe caracteristici, curățând sintaxa și făcând munca noastră reutilizabilă.
De ce avem nevoie pentru ca această interfață să funcționeze? Clasa MyApp ar trebui să aibă o metodă clasei de configure care preia un bloc și apoi execută acel bloc cedându-i acestuia, pasând un obiect de configurare care are metode accesorii pentru citirea și scrierea valorilor de configurare:
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 endOdată ce blocul de configurare a rulat, putem accesa și modifica cu ușurință valorile:
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" Până acum, această implementare nu pare un limbaj personalizat suficient pentru a fi considerat un DSL. Dar haideți să facem lucrurile pe rând. În continuare, vom decupla funcționalitatea de configurare de clasa MyApp și o vom face suficient de generică pentru a fi utilizabilă în multe cazuri de utilizare diferite.
Făcându-l reutilizabil
În acest moment, dacă dorim să adăugăm capabilități de configurare similare unei clase diferite, ar trebui să copiem atât clasa de Configuration , cât și metodele de configurare aferente acesteia în acea altă clasă, precum și să edităm lista attr_accessor pentru a modifica atributele de configurare acceptate. Pentru a evita nevoia de a face acest lucru, să mutăm caracteristicile de configurare într-un modul separat numit Configurable . Cu asta, clasa noastră MyApp va arăta astfel:
class MyApp #BOLD include Configurable #BOLDEND # ... end Tot ceea ce ține de configurare a fost mutat în modulul 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 Nu s-au schimbat multe aici, cu excepția noii metode self.included . Avem nevoie de această metodă deoarece includerea unui modul amestecă doar metodele de instanță, astfel încât metodele noastre de config și configure a clasei nu vor fi adăugate la clasa gazdă în mod implicit. Totuși, dacă definim o metodă specială numită included într-un modul, Ruby o va apela ori de câte ori acel modul este inclus într-o clasă. Acolo putem extinde manual clasa gazdă cu metodele din 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 Nu am terminat încă — următorul nostru pas este să facem posibilă specificarea atributelor acceptate în clasa gazdă care include modulul Configurable . O astfel de soluție ar arăta bine:
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end Poate oarecum surprinzător, codul de mai sus este corect din punct de vedere sintactic - include nu este un cuvânt cheie, ci pur și simplu o metodă obișnuită care așteaptă un obiect Module ca parametru. Atâta timp cât îi trecem o expresie care returnează un Module , o va include cu plăcere. Deci, în loc să includem direct Configurable , avem nevoie de o metodă cu numele with pe ea care generează un nou modul care este personalizat cu atributele specificate:
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 Sunt multe de despachetat aici. Întregul modul Configurable constă acum dintr-o singură metodă with , totul se întâmplă în cadrul acelei metode. În primul rând, creăm o nouă clasă anonimă cu Class.new pentru a păstra metodele noastre accesorii atribute. Deoarece Class.new ia definiția clasei ca un bloc și blocurile au acces la variabile exterioare, putem trece variabila attrs la attr_accessor fără probleme.
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 Faptul că blocurile din Ruby au acces la variabile exterioare este, de asemenea, motivul pentru care acestea sunt uneori numite închideri , deoarece includ sau „apropie” mediul exterior în care au fost definite. Rețineți că am folosit expresia „definit în” și nu „executat în”. Este corect – indiferent de când și unde vor fi executate în cele din urmă blocurile noastre define_method , ele vor putea întotdeauna să acceseze variabilele config_class și class_methods , chiar și după ce metoda with a terminat de rulat și a revenit. Următorul exemplu demonstrează acest comportament:
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 Acum că știm despre acest comportament îngrijit al blocurilor, putem merge mai departe și definim un modul anonim în class_methods pentru metodele de clasă care vor fi adăugate la clasa gazdă atunci când modulul nostru generat este inclus. Aici trebuie să folosim define_method pentru a defini metoda config , deoarece avem nevoie de acces la variabila exterioară config_class din cadrul metodei. Definirea metodei folosind cuvântul cheie def nu ne-ar oferi acest acces, deoarece definițiile obișnuite ale metodei cu def nu sunt închideri – totuși, define_method ia un bloc, așa că va funcționa:

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` În cele din urmă, apelăm Module.new pentru a crea modulul pe care îl vom returna. Aici trebuie să ne definim metoda self.included , dar, din păcate, nu putem face asta cu cuvântul cheie def , deoarece metoda are nevoie de acces la variabila exterioară class_methods . Prin urmare, trebuie să folosim din nou define_method cu un bloc, dar de data aceasta pe clasa singleton a modulului, deoarece definim o metodă pe instanța modulului în sine. Oh, și din moment ce define_method este o metodă privată a clasei singleton, trebuie să folosim send pentru a o invoca în loc să o apelăm direct:
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 endPf, asta a fost deja o metaprogramare destul de hardcore. Dar a meritat complexitatea adăugată? Aruncă o privire la cât de ușor este de utilizat și decideți singur:
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat" Dar putem face și mai bine. În pasul următor vom curăța puțin sintaxa blocului de configure pentru a face modulul nostru și mai ușor de utilizat.
Curățarea sintaxei
Există un ultim lucru care încă mă deranjează cu implementarea noastră actuală - trebuie să repetăm config pe fiecare linie din blocul de configurare. Un DSL adecvat ar ști că totul din blocul de configure ar trebui să fie executat în contextul obiectului nostru de configurare și ne va permite să realizăm același lucru doar cu asta:
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end Să-l punem în aplicare, nu? După cum arată, vom avea nevoie de două lucruri. În primul rând, avem nevoie de o modalitate de a executa blocul transmis pentru a configure în contextul obiectului de configurare, astfel încât apelurile de metodă din bloc să ajungă la acel obiect. În al doilea rând, trebuie să schimbăm metodele accesorii, astfel încât să scrie valoarea dacă le este furnizat un argument și să o citească înapoi atunci când sunt apelate fără un argument. O posibilă implementare arată astfel:
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 Modificarea mai simplă aici este rularea blocului de configure în contextul obiectului de configurare. Apelarea metodei instance_eval a lui Ruby pe un obiect vă permite să executați un bloc arbitrar de cod ca și cum ar fi rulat în acel obiect, ceea ce înseamnă că atunci când blocul de configurare apelează metoda app_id pe prima linie, acel apel va merge la instanța clasei noastre de configurare.
Modificarea metodelor de accesare a atributelor din config_class este puțin mai complicată. Pentru a înțelege, trebuie să înțelegem mai întâi ce făcea exact attr_accessor în culise. Luați, de exemplu, următorul apel attr_accessor :
class SomeClass attr_accessor :foo, :bar endAcest lucru este echivalent cu definirea unei metode de citire și scriere pentru fiecare atribut specificat:
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end Deci, când am scris attr_accessor *attrs în codul original, Ruby a definit pentru noi metodele de citire și scriere de atribute pentru fiecare atribut din attrs - adică, am primit următoarele metode standard de accesare: app_id , app_id= , title , title= și așadar pe. În noua noastră versiune, dorim să păstrăm metodele standard de redactare, astfel încât sarcinile ca aceasta să funcționeze în continuare corect:
MyApp.config.app_ => "not_my_app" Putem continua generarea automată a metodelor writer apelând attr_writer *attrs . Cu toate acestea, nu mai putem folosi metodele standard de citire, deoarece acestea trebuie să fie capabile să scrie atributul pentru a suporta această nouă sintaxă:
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end Pentru a genera noi înșine metodele de citire, trecem peste matricea attrs și definim o metodă pentru fiecare atribut care returnează valoarea curentă a variabilei de instanță care se potrivește dacă nu este furnizată nicio valoare nouă și scrie noua valoare dacă este specificată:
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 Aici folosim metoda instance_variable_get a lui Ruby pentru a citi o variabilă de instanță cu un nume arbitrar și instance_variable_set pentru a-i atribui o nouă valoare. Din păcate, numele variabilei trebuie să fie prefixat cu un semn „@” în ambele cazuri – de unde interpolarea șirurilor.
S-ar putea să vă întrebați de ce trebuie să folosim un obiect gol ca valoare implicită pentru „nefurnizat” și de ce nu putem folosi pur și simplu nil în acest scop. Motivul este simplu – nil este o valoare validă pe care cineva ar putea dori să o seteze pentru un atribut de configurare. Dacă am testa pentru nil , nu am fi capabili să deosebim aceste două scenarii:
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end Acel obiect gol stocat în not_provided va fi întotdeauna egal cu el însuși, astfel încât în acest fel putem fi siguri că nimeni nu îl va trece în metoda noastră și va provoca o citire neintenționată în loc de o scriere.
Adăugarea suportului pentru referințe
Mai există o caracteristică pe care am putea-o adăuga pentru a face modulul nostru și mai versatil - capacitatea de a face referire la un atribut de configurare de la altul:
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session" Aici am adăugat o referință de la cookie_name la atributul app_id . Rețineți că expresia care conține referința este transmisă ca bloc - acest lucru este necesar pentru a sprijini evaluarea întârziată a valorii atributului. Ideea este să evaluăm blocul doar mai târziu când atributul este citit și nu când este definit - altfel s-ar întâmpla lucruri amuzante dacă am defini atributele în ordinea „greșită”:
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funnyDacă expresia este înfășurată într-un bloc, aceasta va împiedica evaluarea imediată. În schimb, putem salva blocul pentru a fi executat mai târziu când valoarea atributului este preluată:
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! Nu trebuie să facem modificări majore la modulul Configurable pentru a adăuga suport pentru evaluarea întârziată folosind blocuri. De fapt, trebuie doar să schimbăm definiția metodei atributelor:
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 Când setați un atribut, block || value expresia block || value salvează blocul dacă a fost transmis unul sau, în caz contrar, salvează valoarea. Apoi, când atributul este citit ulterior, verificăm dacă este un bloc și îl evaluăm folosind instance_eval dacă este, sau dacă nu este un bloc, îl returnăm așa cum am făcut înainte.
Referințele de sprijin vin cu propriile avertismente și cazuri de margine, desigur. De exemplu, probabil vă puteți da seama ce se întâmplă dacă citiți oricare dintre atributele din această configurație:
SomeClass.configure do foo { bar } bar { foo } endModulul Terminat
În cele din urmă, ne-am obținut un modul destul de îngrijit pentru a face o clasă arbitrară configurabilă și apoi a specifica acele valori de configurare folosind un DSL curat și simplu, care, de asemenea, ne permite să facem referință la un atribut de configurare de la altul:
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" } endIată versiunea finală a modulului care implementează DSL-ul nostru - un total de 36 de linii de cod:
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 endPrivind toată această magie Ruby într-o bucată de cod care este aproape ilizibilă și, prin urmare, foarte greu de întreținut, s-ar putea să vă întrebați dacă tot acest efort a meritat doar pentru a face limbajul specific domeniului nostru un pic mai frumos. Răspunsul scurt este că depinde, ceea ce ne duce la subiectul final al acestui articol.
Ruby DSL-Când să le utilizați și când să nu le folosiți
Probabil ați observat în timp ce citiți pașii de implementare a DSL-ului nostru că, pe măsură ce am făcut sintaxa externă a limbajului mai curată și mai ușor de utilizat, a trebuit să folosim un număr tot mai mare de trucuri de metaprogramare sub capotă pentru a face acest lucru. Acest lucru a dus la o implementare care va fi incredibil de greu de înțeles și modificat în viitor. La fel ca multe alte lucruri în dezvoltarea de software, acesta este, de asemenea, un compromis care trebuie examinat cu atenție.
Pentru ca un limbaj specific unui domeniu să merite costul de implementare și întreținere, trebuie să aducă o sumă și mai mare de beneficii. Acest lucru se realizează, de obicei, făcând limbajul reutilizabil în cât mai multe scenarii diferite posibil, amortizand astfel costul total între mai multe cazuri de utilizare diferite. Framework-urile și bibliotecile au mai multe șanse să conțină propriile DSL-uri tocmai pentru că sunt utilizate de o mulțime de dezvoltatori, fiecare dintre aceștia putându-se bucura de beneficiile de productivitate ale acelor limbaje încorporate.
Așadar, ca principiu general, construiți DSL-uri numai dacă dvs., alți dezvoltatori sau utilizatorii finali ai aplicației dvs. le veți folosi foarte mult. Dacă creați un DSL, asigurați-vă că includeți o suită de testare cuprinzătoare cu acesta, precum și documentați-i corect sintaxa, deoarece poate fi foarte greu de înțeles doar din implementare. În viitor, tu și colegii tăi dezvoltatori vă veți mulțumi pentru asta.
Citiți suplimentare pe blogul Toptal Engineering:
- Cum să abordați scrierea unui interpret de la zero
