Création d'un Ruby DSL : un guide pour la métaprogrammation avancée

Publié: 2022-03-11

Les langages spécifiques à un domaine (DSL) sont un outil incroyablement puissant pour faciliter la programmation ou la configuration de systèmes complexes. Ils sont également partout - en tant qu'ingénieur logiciel, vous utilisez très probablement plusieurs DSL différents au quotidien.

Dans cet article, vous apprendrez ce que sont les langages spécifiques à un domaine, quand ils doivent être utilisés et enfin comment vous pouvez créer votre propre DSL en Ruby en utilisant des techniques de métaprogrammation avancées.

Cet article s'appuie sur l'introduction de Nikola Todorovic à la métaprogrammation Ruby, également publiée sur le blog Toptal. Donc, si vous êtes nouveau dans la métaprogrammation, assurez-vous de lire cela en premier.

Qu'est-ce qu'un langage spécifique à un domaine ?

La définition générale des DSL est qu'il s'agit de langages spécialisés dans un domaine d'application ou un cas d'utilisation particulier. Cela signifie que vous ne pouvez les utiliser que pour des choses spécifiques - ils ne conviennent pas au développement de logiciels à usage général. Si cela semble large, c'est parce que c'est le cas : les DSL se présentent sous différentes formes et tailles. Voici quelques catégories importantes :

  • Les langages de balisage tels que HTML et CSS sont conçus pour décrire des éléments spécifiques tels que la structure, le contenu et les styles des pages Web. Il n'est pas possible d'écrire des algorithmes arbitraires avec eux, ils correspondent donc à la description d'un DSL.
  • Les langages de macro et de requête (par exemple, SQL) reposent sur un système particulier ou un autre langage de programmation et sont généralement limités dans ce qu'ils peuvent faire. Par conséquent, ils sont évidemment qualifiés de langages spécifiques à un domaine.
  • De nombreux DSL n'ont pas leur propre syntaxe. Au lieu de cela, ils utilisent la syntaxe d'un langage de programmation établi d'une manière intelligente qui donne l'impression d'utiliser un mini-langage séparé.

Cette dernière catégorie s'appelle un DSL interne , et c'est l'une d'entre elles que nous allons créer à titre d'exemple très prochainement. Mais avant d'aborder cela, examinons quelques exemples bien connus de DSL internes. La syntaxe de définition de route dans Rails en fait partie :

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

C'est du code Ruby, mais il ressemble plus à un langage de définition de route personnalisé, grâce aux diverses techniques de métaprogrammation qui rendent possible une interface aussi propre et facile à utiliser. Notez que la structure du DSL est implémentée à l'aide de blocs Ruby et que des appels de méthode tels que get et resources sont utilisés pour définir les mots-clés de ce mini-langage.

La métaprogrammation est encore plus utilisée dans la bibliothèque de 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

Ce morceau de code contient également des exemples d' interfaces fluides , qui permettent aux déclarations d'être lues à haute voix sous forme de phrases simples en anglais, ce qui facilite grandement la compréhension de ce que fait le code :

 # 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 autre exemple d'interface fluide est l'interface de requête d'ActiveRecord et d'Arel, qui utilise en interne une arborescence de syntaxe abstraite pour créer des requêtes SQL complexes :

 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`

Bien que la syntaxe propre et expressive de Ruby ainsi que ses capacités de métaprogrammation le rendent particulièrement adapté à la création de langages spécifiques à un domaine, les DSL existent également dans d'autres langages. Voici un exemple de test JavaScript utilisant le 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!"); }); }); });

Cette syntaxe n'est peut-être pas aussi propre que celle des exemples Ruby, mais elle montre qu'avec une dénomination intelligente et une utilisation créative de la syntaxe, des DSL internes peuvent être créés en utilisant presque n'importe quel langage.

L'avantage des DSL internes est qu'ils ne nécessitent pas d'analyseur séparé, ce qui peut être notoirement difficile à mettre en œuvre correctement. Et parce qu'ils utilisent la syntaxe du langage dans lequel ils sont implémentés, ils s'intègrent également de manière transparente avec le reste de la base de code.

Ce que nous devons abandonner en retour, c'est la liberté syntaxique - les DSL internes doivent être syntaxiquement valides dans leur langage d'implémentation. La quantité de compromis que vous devez faire à cet égard dépend en grande partie du langage sélectionné, avec des langages verbeux et statiquement typés tels que Java et VB.NET étant à une extrémité du spectre, et des langages dynamiques avec des capacités de métaprogrammation étendues telles que Ruby de l'autre finir.

Construire notre propre — Un Ruby DSL pour la configuration de classe

L'exemple de DSL que nous allons construire en Ruby est un moteur de configuration réutilisable pour spécifier les attributs de configuration d'une classe Ruby en utilisant une syntaxe très simple. L'ajout de capacités de configuration à une classe est une exigence très courante dans le monde Ruby, en particulier lorsqu'il s'agit de configurer des gemmes externes et des clients API. La solution habituelle est une interface comme celle-ci :

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

Commençons par implémenter cette interface, puis, en l'utilisant comme point de départ, nous pouvons l'améliorer étape par étape en ajoutant plus de fonctionnalités, en nettoyant la syntaxe et en rendant notre travail réutilisable.

De quoi avons-nous besoin pour faire fonctionner cette interface ? La classe MyApp doit avoir une méthode de classe configure qui prend un bloc puis exécute ce bloc en lui cédant, en passant un objet de configuration qui a des méthodes d'accès pour lire et écrire les valeurs de configuration :

 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

Une fois le bloc de configuration exécuté, nous pouvons facilement accéder et modifier les valeurs :

 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"

Jusqu'à présent, cette implémentation ne ressemble pas suffisamment à un langage personnalisé pour être considérée comme un DSL. Mais prenons les choses une étape à la fois. Ensuite, nous découplerons la fonctionnalité de configuration de la classe MyApp et la rendrons suffisamment générique pour être utilisable dans de nombreux cas d'utilisation différents.

Le rendre réutilisable

À l'heure actuelle, si nous voulions ajouter des capacités de configuration similaires à une classe différente, nous devions copier à la fois la classe Configuration et ses méthodes de configuration associées dans cette autre classe, ainsi que modifier la liste attr_accessor pour modifier les attributs de configuration acceptés. Pour éviter d'avoir à le faire, déplaçons les fonctionnalités de configuration dans un module séparé appelé Configurable . Avec cela, notre classe MyApp ressemblera à ceci :

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

Tout ce qui concerne la configuration a été déplacé vers le module 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

Peu de choses ont changé ici, à l'exception de la nouvelle méthode self.included . Nous avons besoin de cette méthode car l'inclusion d'un module ne mélange que ses méthodes d'instance, donc nos méthodes de classe config et configure ne seront pas ajoutées à la classe hôte par défaut. Cependant, si nous définissons une méthode spéciale appelée included sur un module, Ruby l'appellera chaque fois que ce module sera inclus dans une classe. Là, nous pouvons étendre manuellement la classe hôte avec les méthodes de 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

Nous n'avons pas encore terminé - notre prochaine étape consiste à rendre possible la spécification des attributs pris en charge dans la classe hôte qui inclut le module Configurable . Une solution comme celle-ci aurait l'air bien:

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

Peut-être quelque peu surprenant, le code ci-dessus est syntaxiquement correct - include n'est pas un mot-clé mais simplement une méthode régulière qui attend un objet Module comme paramètre. Tant que nous lui transmettons une expression qui renvoie un Module , il l'inclura avec plaisir. Ainsi, au lieu d'inclure Configurable directement, nous avons besoin d'une méthode with le nom qui génère un nouveau module personnalisé avec les attributs spécifiés :

 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

Il y a beaucoup à déballer ici. L'ensemble du module Configurable se compose désormais d'une seule méthode with , avec tout ce qui se passe dans cette méthode. Tout d'abord, nous créons une nouvelle classe anonyme avec Class.new pour contenir nos méthodes d'accès aux attributs. Parce que Class.new prend la définition de classe comme un bloc et que les blocs ont accès à des variables extérieures, nous pouvons passer la variable attrs à attr_accessor sans problème.

 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

Le fait que les blocs de Ruby aient accès à des variables externes est également la raison pour laquelle ils sont parfois appelés fermetures , car ils incluent ou "ferment" l'environnement extérieur dans lequel ils ont été définis. Notez que j'ai utilisé l'expression "défini dans". et non "exécuté en". C'est exact - quels que soient le moment et l'endroit où nos blocs define_method seront éventuellement exécutés, ils pourront toujours accéder aux variables config_class et class_methods , même après que la méthode with a fini de s'exécuter et est retournée. L'exemple suivant illustre ce comportement :

 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

Maintenant que nous connaissons ce comportement soigné des blocs, nous pouvons continuer et définir un module anonyme dans class_methods pour les méthodes de classe qui seront ajoutées à la classe hôte lorsque notre module généré sera inclus. Ici, nous devons utiliser define_method pour définir la méthode de config , car nous avons besoin d'accéder à la variable externe config_class depuis la méthode. Définir la méthode à l'aide du mot-clé def ne nous donnerait pas cet accès car les définitions de méthode régulières avec def ne sont pas des fermetures - cependant, define_method prend un bloc, donc cela fonctionnera :

 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`

Enfin, nous appelons Module.new pour créer le module que nous allons retourner. Ici, nous devons définir notre méthode self.included , mais malheureusement nous ne pouvons pas le faire avec le mot-clé def , car la méthode a besoin d'accéder à la variable externe class_methods . Par conséquent, nous devons à nouveau utiliser define_method avec un bloc, mais cette fois sur la classe singleton du module, car nous définissons une méthode sur l'instance de module elle-même. Oh, et puisque define_method est une méthode privée de la classe singleton, nous devons utiliser send pour l'invoquer au lieu de l'appeler directement :

 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

Ouf, c'était déjà de la métaprogrammation assez hardcore. Mais la complexité supplémentaire en valait-elle la peine ? Regardez à quel point il est facile à utiliser et décidez par vous-même :

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

Mais nous pouvons faire encore mieux. Dans l'étape suivante, nous allons nettoyer un peu la syntaxe du bloc configure pour rendre notre module encore plus pratique à utiliser.

Nettoyer la syntaxe

Il y a une dernière chose qui me dérange encore avec notre implémentation actuelle : nous devons répéter la config sur chaque ligne du bloc de configuration. Un bon DSL saurait que tout ce qui se trouve dans le bloc de configure doit être exécuté dans le contexte de notre objet de configuration et nous permettre d'obtenir la même chose avec juste ceci :

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

Mettons-le en œuvre, d'accord ? À première vue, nous aurons besoin de deux choses. Tout d'abord, nous avons besoin d'un moyen d'exécuter le bloc transmis à configure dans le contexte de l'objet de configuration afin que les appels de méthode dans le bloc aillent vers cet objet. Deuxièmement, nous devons modifier les méthodes d'accès afin qu'elles écrivent la valeur si un argument leur est fourni et la lisent lorsqu'elles sont appelées sans argument. Une implémentation possible ressemble à ceci :

 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

Le changement le plus simple ici consiste à exécuter le bloc configure dans le contexte de l'objet de configuration. L'appel de la méthode instance_eval de Ruby sur un objet vous permet d'exécuter un bloc de code arbitraire comme s'il s'exécutait dans cet objet, ce qui signifie que lorsque le bloc de configuration appelle la méthode app_id sur la première ligne, cet appel ira à notre instance de classe de configuration.

La modification des méthodes d'accès aux attributs dans config_class est un peu plus compliquée. Pour le comprendre, nous devons d'abord comprendre ce que faisait exactement attr_accessor dans les coulisses. Prenez l'appel attr_accessor suivant, par exemple :

 class SomeClass attr_accessor :foo, :bar end

Cela équivaut à définir une méthode de lecture et d'écriture pour chaque attribut spécifié :

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

Ainsi, lorsque nous avons écrit attr_accessor *attrs dans le code d'origine, Ruby a défini pour nous les méthodes de lecture et d'écriture d'attributs pour chaque attribut dans attrs - c'est-à-dire que nous avons obtenu les méthodes d'accès standard suivantes : app_id , app_id= , title , title= et ainsi de suite au. Dans notre nouvelle version, nous souhaitons conserver les méthodes d'écriture standard afin que les affectations comme celle-ci fonctionnent toujours correctement :

 MyApp.config.app_ => "not_my_app"

Nous pouvons continuer à générer automatiquement les méthodes d'écriture en appelant attr_writer *attrs . Cependant, nous ne pouvons plus utiliser les méthodes de lecteur standard, car elles doivent également être capables d'écrire l'attribut pour prendre en charge cette nouvelle syntaxe :

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

Pour générer nous-mêmes les méthodes du lecteur, nous parcourons le tableau attrs et définissons une méthode pour chaque attribut qui renvoie la valeur actuelle de la variable d'instance correspondante si aucune nouvelle valeur n'est fournie et écrit la nouvelle valeur si elle est spécifiée :

 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

Ici, nous utilisons la méthode instance_variable_get de Ruby pour lire une variable d'instance avec un nom arbitraire, et instance_variable_set pour lui attribuer une nouvelle valeur. Malheureusement, le nom de la variable doit être précédé d'un signe "@" dans les deux cas, d'où l'interpolation de chaîne.

Vous vous demandez peut-être pourquoi nous devons utiliser un objet vide comme valeur par défaut pour "non fourni" et pourquoi nous ne pouvons pas simplement utiliser nil à cette fin. La raison est simple : nil est une valeur valide que quelqu'un pourrait vouloir définir pour un attribut de configuration. Si nous testions nil , nous ne serions pas en mesure de distinguer ces deux scénarios :

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

Cet objet vide stocké dans not_provided ne sera jamais égal qu'à lui-même, de cette façon nous pouvons être certains que personne ne le passera dans notre méthode et provoquera une lecture involontaire au lieu d'une écriture.

Ajout de la prise en charge des références

Il y a une fonctionnalité supplémentaire que nous pourrions ajouter pour rendre notre module encore plus polyvalent : la possibilité de référencer un attribut de configuration à partir d'un autre :

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

Ici, nous avons ajouté une référence de cookie_name à l'attribut app_id . Notez que l'expression contenant la référence est transmise sous forme de bloc, ce qui est nécessaire pour prendre en charge l'évaluation différée de la valeur de l'attribut. L'idée est de n'évaluer le bloc que plus tard, lorsque l'attribut est lu et non lorsqu'il est défini, sinon des choses amusantes se produiraient si nous définissions les attributs dans le "mauvais" ordre :

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

Si l'expression est enveloppée dans un bloc, cela l'empêchera d'être évaluée immédiatement. Au lieu de cela, nous pouvons enregistrer le bloc pour qu'il soit exécuté ultérieurement lorsque la valeur de l'attribut sera récupérée :

 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!

Nous n'avons pas besoin d'apporter de modifications importantes au module Configurable pour ajouter la prise en charge de l'évaluation différée à l'aide de blocs. En fait, nous n'avons qu'à changer la définition de la méthode d'attribut :

 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

Lors de la définition d'un attribut, le block || value L'expression de block || value enregistre le bloc s'il y en a un passé, sinon elle enregistre la valeur. Ensuite, lorsque l'attribut est lu ultérieurement, nous vérifions s'il s'agit d'un bloc et l'évaluons à l'aide de instance_eval si c'est le cas, ou si ce n'est pas un bloc, nous le renvoyons comme nous l'avons fait auparavant.

Les références de support viennent avec leurs propres mises en garde et cas extrêmes, bien sûr. Par exemple, vous pouvez probablement comprendre ce qui se passe si vous lisez l'un des attributs de cette configuration :

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

Le module fini

En fin de compte, nous nous sommes procuré un module assez soigné pour rendre configurable une classe arbitraire, puis spécifier ces valeurs de configuration à l'aide d'un DSL propre et simple qui nous permet également de référencer un attribut de configuration à partir d'un autre :

 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

Voici la version finale du module qui implémente notre DSL—un total de 36 lignes de code :

 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

En regardant toute cette magie Ruby dans un morceau de code presque illisible et donc très difficile à maintenir, vous pourriez vous demander si tout cet effort en valait la peine juste pour rendre notre langage spécifique à un domaine un peu plus agréable. La réponse courte est que cela dépend, ce qui nous amène au dernier sujet de cet article.

DSL Ruby - Quand les utiliser et quand ne pas les utiliser

Vous avez probablement remarqué en lisant les étapes de mise en œuvre de notre DSL que, comme nous avons rendu la syntaxe externe du langage plus propre et plus facile à utiliser, nous avons dû utiliser un nombre toujours croissant d'astuces de métaprogrammation sous le capot pour y arriver. Cela a abouti à une implémentation qui sera incroyablement difficile à comprendre et à modifier à l'avenir. Comme tant d'autres choses dans le développement de logiciels, c'est aussi un compromis qui doit être soigneusement examiné.

Pour qu'un langage spécifique à un domaine vaille son coût de mise en œuvre et de maintenance, il doit apporter une somme encore plus importante d'avantages à la table. Ceci est généralement réalisé en rendant le langage réutilisable dans autant de scénarios différents que possible, amortissant ainsi le coût total entre de nombreux cas d'utilisation différents. Les frameworks et les bibliothèques sont plus susceptibles de contenir leurs propres DSL exactement parce qu'ils sont utilisés par de nombreux développeurs, chacun pouvant profiter des avantages de productivité de ces langages intégrés.

Donc, en règle générale, ne construisez des DSL que si vous, d'autres développeurs ou les utilisateurs finaux de votre application en tirerez beaucoup parti. Si vous créez un DSL, assurez-vous d'inclure une suite de tests complète avec lui, ainsi que de documenter correctement sa syntaxe car il peut être très difficile de comprendre à partir de la seule implémentation. Le futur, vous et vos collègues développeurs, vous en remercierez.


Lectures complémentaires sur le blog Toptal Engineering :

  • Comment aborder la rédaction d'un interprète à partir de zéro