Создание Ruby DSL: руководство по расширенному метапрограммированию
Опубликовано: 2022-03-11Предметно-ориентированные языки (DSL) — невероятно мощный инструмент, облегчающий программирование и настройку сложных систем. Кроме того, они повсюду — как инженер-программист, вы, скорее всего, ежедневно используете несколько разных DSL.
В этой статье вы узнаете, что такое специфичные для предметной области языки, когда их следует использовать и, наконец, как вы можете создать свой собственный DSL на Ruby, используя передовые методы метапрограммирования.
Эта статья основана на введении Николы Тодоровича в метапрограммирование Ruby, также опубликованном в блоге Toptal. Поэтому, если вы новичок в метапрограммировании, убедитесь, что вы прочитали это в первую очередь.
Что такое доменный язык?
Общее определение DSL состоит в том, что это языки, специализированные для конкретной предметной области или варианта использования. Это означает, что вы можете использовать их только для определенных целей — они не подходят для разработки программного обеспечения общего назначения. Если это звучит слишком широко, то это потому, что так оно и есть — DSL бывают разных форм и размеров. Вот несколько важных категорий:
- Языки разметки, такие как HTML и CSS, предназначены для описания конкретных вещей, таких как структура, содержимое и стили веб-страниц. С ними невозможно написать произвольные алгоритмы, поэтому они подходят под описание DSL.
- Языки макросов и запросов (например, SQL) находятся поверх конкретной системы или другого языка программирования и обычно ограничены в том, что они могут делать. Поэтому они, очевидно, квалифицируются как доменные языки.
- Многие DSL не имеют собственного синтаксиса — вместо этого они используют синтаксис установленного языка программирования умным способом, который напоминает использование отдельного мини-языка.
Эта последняя категория называется внутренним DSL , и именно ее мы собираемся создать в качестве примера очень скоро. Но прежде чем мы углубимся в это, давайте взглянем на несколько хорошо известных примеров внутренних DSL. Синтаксис определения маршрута в Rails — один из них:
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end Это код Ruby, но он больше похож на собственный язык определения маршрутов благодаря различным методам метапрограммирования, которые делают возможным такой чистый, простой в использовании интерфейс. Обратите внимание, что структура DSL реализована с использованием блоков Ruby, а вызовы методов, таких как get и resources , используются для определения ключевых слов этого мини-языка.
Метапрограммирование еще более активно используется в тестовой библиотеке 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Этот фрагмент кода также содержит примеры для плавных интерфейсов , которые позволяют читать объявления вслух как простые английские предложения, что значительно упрощает понимание того, что делает код:
# 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Другим примером плавного интерфейса является интерфейс запросов ActiveRecord и Arel, который использует внутреннее абстрактное синтаксическое дерево для построения сложных SQL-запросов:
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`Хотя чистый и выразительный синтаксис Ruby вместе с его возможностями метапрограммирования делает его уникальным для создания предметно-ориентированных языков, DSL существуют и в других языках. Вот пример теста JavaScript с использованием фреймворка 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!"); }); }); });Этот синтаксис, возможно, не такой чистый, как в примерах Ruby, но он показывает, что при умном именовании и творческом использовании синтаксиса внутренние DSL могут быть созданы практически на любом языке.
Преимущество внутренних DSL заключается в том, что они не требуют отдельного синтаксического анализатора, который, как известно, очень сложно реализовать должным образом. А поскольку они используют синтаксис языка, на котором они реализованы, они легко интегрируются с остальной кодовой базой.
Взамен мы должны отказаться от синтаксической свободы — внутренние DSL должны быть синтаксически действительными в своем языке реализации. То, насколько вам придется идти на компромисс в этом отношении, во многом зависит от выбранного языка, при этом многословные языки со статической типизацией, такие как Java и VB.NET, находятся на одном конце спектра, а динамические языки с широкими возможностями метапрограммирования, такие как Ruby, — на другом. конец.
Создание собственного — Ruby DSL для настройки класса
Пример DSL, который мы собираемся создать в Ruby, представляет собой повторно используемый механизм конфигурации для указания атрибутов конфигурации класса Ruby с использованием очень простого синтаксиса. Добавление возможностей конфигурации в класс — очень распространенное требование в мире Ruby, особенно когда речь идет о настройке внешних гемов и клиентов API. Обычным решением является такой интерфейс:
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" endДавайте сначала реализуем этот интерфейс, а затем, используя его в качестве отправной точки, мы сможем улучшать его шаг за шагом, добавляя дополнительные функции, очищая синтаксис и делая нашу работу пригодной для повторного использования.
Что нам нужно, чтобы этот интерфейс заработал? Класс MyApp должен иметь метод класса configure , который принимает блок, а затем выполняет этот блок, уступая ему, передавая объект конфигурации, который имеет методы доступа для чтения и записи значений конфигурации:
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После запуска блока конфигурации мы можем легко получить доступ к значениям и изменить их:
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" Пока что эта реализация не выглядит как пользовательский язык, достаточный для того, чтобы считаться DSL. Но давайте пошагово. Далее мы отделим функции конфигурации от класса MyApp и сделаем их достаточно универсальными, чтобы их можно было использовать во многих различных случаях.
Делаем его многоразовым
Прямо сейчас, если бы мы хотели добавить аналогичные возможности конфигурации в другой класс, нам пришлось бы скопировать как класс Configuration , так и связанные с ним методы настройки в этот другой класс, а также отредактировать список attr_accessor , чтобы изменить принятые атрибуты конфигурации. Чтобы этого не делать, давайте переместим функции конфигурации в отдельный модуль под названием Configurable . При этом наш класс MyApp будет выглядеть так:
class MyApp #BOLD include Configurable #BOLDEND # ... end Все, что связано с настройкой, перенесено в модуль 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 Здесь мало что изменилось, за исключением нового метода self.included . Нам нужен этот метод, потому что включение модуля только смешивает методы его экземпляра, поэтому наши методы класса config и configure не будут добавлены в класс хоста по умолчанию. Однако если мы определим специальный метод, называемый included в модуль, Ruby будет вызывать его всякий раз, когда этот модуль включается в класс. Там мы можем вручную расширить хост-класс с помощью методов 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 Мы еще не закончили — наш следующий шаг — сделать возможным указывать поддерживаемые атрибуты в хост-классе, включающем модуль Configurable . Такое решение выглядело бы красиво:
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end Возможно, несколько удивительно, но приведенный выше код синтаксически корректен — include — это не ключевое слово, а просто обычный метод, который ожидает объект Module в качестве своего параметра. Пока мы передаем ему выражение, которое возвращает Module , он с радостью включит его. Таким образом, вместо того, чтобы напрямую включать Configurable , нам нужен метод с именем with , который генерирует новый модуль, настроенный с указанными атрибутами:
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 Здесь есть что распаковать. Весь модуль Configurable теперь состоит только из одного метода with , и все, что происходит внутри этого метода. Во-первых, мы создаем новый анонимный класс с Class.new для хранения наших методов доступа к атрибутам. Поскольку Class.new принимает определение класса как блок, а блоки имеют доступ к внешним переменным, мы можем без проблем передать переменную attrs в attr_accessor .
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 Тот факт, что блоки в Ruby имеют доступ к внешним переменным, также является причиной того, что их иногда называют замыканиями , поскольку они включают или «закрывают» внешнюю среду, в которой они были определены. Обратите внимание, что я использовал фразу «определены в». а не "выполнено в". Это правильно — независимо от того, когда и где в конечном итоге будут выполняться наши блоки define_method , они всегда будут иметь доступ к переменным config_class и class_methods , даже после того, как метод with завершит выполнение и вернется. Следующий пример демонстрирует это поведение:
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 Теперь, когда мы знаем об этом аккуратном поведении блоков, мы можем двигаться вперед и определить анонимный модуль в class_methods для методов класса, которые будут добавлены к основному классу, когда наш сгенерированный модуль будет включен. Здесь мы должны использовать define_method для определения метода config , потому что нам нужен доступ к внешней переменной config_class из метода. Определение метода с помощью ключевого слова def не даст нам такого доступа, потому что обычные определения метода с помощью def не являются замыканиями, однако define_method принимает блок, так что это будет работать:

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` Наконец, мы вызываем Module.new для создания модуля, который мы собираемся вернуть. Здесь нам нужно определить наш метод self.included , но, к сожалению, мы не можем сделать это с помощью ключевого слова def , так как методу требуется доступ к внешней переменной class_methods . Следовательно, мы должны снова использовать define_method с блоком, но на этот раз в одноэлементном классе модуля, так как мы определяем метод в самом экземпляре модуля. Да, и так как define_method является закрытым методом класса singleton, мы должны использовать send для его вызова, а не вызывать его напрямую:
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Уф, это уже было довольно хардкорным метапрограммированием. Но стоила ли дополнительная сложность того? Посмотрите, как легко им пользоваться, и решите сами:
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat" Но мы можем сделать еще лучше. На следующем шаге мы немного очистим синтаксис блока configure , чтобы сделать наш модуль еще более удобным в использовании.
Очистка синтаксиса
Есть еще одна вещь, которая все еще беспокоит меня в нашей текущей реализации — нам приходится повторять config в каждой строке блока конфигурации. Надлежащий DSL будет знать, что все в блоке configure должно выполняться в контексте нашего объекта конфигурации, и позволит нам добиться того же самого с помощью всего этого:
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end Давайте реализуем, а? Судя по всему, нам понадобятся две вещи. Во-первых, нам нужен способ выполнить блок, переданный для configure в контексте объекта конфигурации, чтобы вызовы методов внутри блока направлялись к этому объекту. Во-вторых, мы должны изменить методы доступа так, чтобы они записывали значение, если им предоставляется аргумент, и считывали его при вызове без аргумента. Возможная реализация выглядит так:
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 Более простым изменением здесь является запуск блока configure в контексте объекта конфигурации. Вызов метода Ruby instance_eval для объекта позволяет вам выполнить произвольный блок кода, как если бы он выполнялся внутри этого объекта, а это означает, что когда блок конфигурации вызывает метод app_id в первой строке, этот вызов будет передан нашему экземпляру класса конфигурации.
Изменение методов доступа к атрибутам в config_class немного сложнее. Чтобы понять это, нам нужно сначала понять, что именно attr_accessor делает за кулисами. Возьмем, к примеру, следующий вызов attr_accessor :
class SomeClass attr_accessor :foo, :bar endЭто эквивалентно определению методов чтения и записи для каждого указанного атрибута:
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end Поэтому, когда мы написали attr_accessor *attrs в исходном коде, Ruby определил для нас методы чтения и записи атрибутов для каждого атрибута в attrs , то есть мы получили следующие стандартные методы доступа: app_id , app_id= , title , title= и так далее. на. В нашей новой версии мы хотим сохранить стандартные методы записи, чтобы такие назначения по-прежнему работали правильно:
MyApp.config.app_ => "not_my_app" Мы можем продолжать автоматически генерировать методы записи, вызывая attr_writer *attrs . Однако мы больше не можем использовать стандартные методы чтения, так как они также должны быть способны записывать атрибут для поддержки этого нового синтаксиса:
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end Чтобы сгенерировать методы чтения самостоятельно, мы перебираем массив attrs и определяем метод для каждого атрибута, который возвращает текущее значение соответствующей переменной экземпляра, если новое значение не предоставлено, и записывает новое значение, если оно указано:
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 Здесь мы используем метод Ruby instance_variable_get для чтения переменной экземпляра с произвольным именем и instance_variable_set для присвоения ей нового значения. К сожалению, имя переменной должно начинаться со знака «@» в обоих случаях — отсюда интерполяция строк.
Вам может быть интересно, почему мы должны использовать пустой объект в качестве значения по умолчанию для «не предоставлено» и почему мы не можем просто использовать nil для этой цели. Причина проста: nil — допустимое значение, которое кто-то может захотеть установить для атрибута конфигурации. Если бы мы тестировали на nil , мы бы не смогли отличить эти два сценария друг от друга:
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end Этот пустой объект, хранящийся в not_provided , всегда будет равен только самому себе, поэтому мы можем быть уверены, что никто не передаст его в наш метод и вызовет непреднамеренное чтение вместо записи.
Добавление поддержки ссылок
Есть еще одна функция, которую мы могли бы добавить, чтобы сделать наш модуль еще более универсальным — возможность ссылаться на атрибут конфигурации из другого:
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session" Здесь мы добавили ссылку из cookie_name в атрибут app_id . Обратите внимание, что выражение, содержащее ссылку, передается как блок — это необходимо для поддержки отложенной оценки значения атрибута. Идея состоит в том, чтобы оценивать блок только позже, когда атрибут прочитан, а не когда он определен, иначе могли бы произойти забавные вещи, если бы мы определили атрибуты в «неправильном» порядке:
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funnyЕсли выражение заключено в блок, это предотвратит его немедленную оценку. Вместо этого мы можем сохранить блок для выполнения позже, когда будет получено значение атрибута:
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! Нам не нужно вносить большие изменения в модуль Configurable , чтобы добавить поддержку отложенной оценки с использованием блоков. Фактически, нам нужно только изменить определение метода атрибута:
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 При установке атрибута block || value выражение block || value сохраняет блок, если он был передан, или в противном случае сохраняет значение. Затем, когда атрибут будет позже прочитан, мы проверяем, является ли он блоком, и оцениваем его с помощью instance_eval , если это так, или, если это не блок, мы возвращаем его, как делали раньше.
Вспомогательные ссылки, конечно же, имеют свои предостережения и крайние случаи. Например, вы, вероятно, можете понять, что произойдет, если вы прочитаете любой из атрибутов в этой конфигурации:
SomeClass.configure do foo { bar } bar { foo } endГотовый модуль
В конце концов, мы получили довольно изящный модуль для настройки произвольного класса, а затем указания этих значений конфигурации с помощью простого и понятного DSL, который также позволяет нам ссылаться на один атрибут конфигурации из другого:
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Вот окончательная версия модуля, реализующего наш DSL — всего 36 строк кода:
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Глядя на всю эту магию Ruby в фрагменте кода, который почти не читается и поэтому очень сложен в обслуживании, вы можете задаться вопросом, стоили ли все эти усилия того, чтобы сделать наш предметно-ориентированный язык немного лучше. Короткий ответ заключается в том, что это зависит, что подводит нас к последней теме этой статьи.
Ruby DSL — когда их использовать, а когда не использовать
Вы, вероятно, заметили, читая этапы реализации нашего DSL, что по мере того, как мы делали внешний синтаксис языка чище и проще в использовании, нам приходилось использовать все большее количество трюков метапрограммирования под капотом, чтобы это произошло. Это привело к реализации, которую будет невероятно сложно понять и изменить в будущем. Как и многие другие вещи в разработке программного обеспечения, это также компромисс, который необходимо тщательно изучить.
Чтобы предметно-ориентированный язык оправдал затраты на его внедрение и поддержку, он должен принести еще большую сумму преимуществ. Обычно это достигается путем повторного использования языка в как можно большем количестве различных сценариев, тем самым амортизируя общую стоимость между множеством различных вариантов использования. Фреймворки и библиотеки, скорее всего, будут содержать свои собственные DSL именно потому, что они используются многими разработчиками, каждый из которых может пользоваться преимуществами производительности этих встроенных языков.
Таким образом, как правило, создавайте DSL только в том случае, если вы, другие разработчики или конечные пользователи вашего приложения будут получать от них много пользы. Если вы создаете DSL, не забудьте включить в него исчерпывающий набор тестов, а также должным образом задокументировать его синтаксис, поскольку его может быть очень сложно понять только на основе реализации. В будущем вы и ваши коллеги-разработчики поблагодарите вас за это.
Дальнейшее чтение в блоге Toptal Engineering:
- Как подойти к написанию интерпретатора с нуля
