Выразительный DSL на Ruby

от автора

Если вы работаете с Ruby on Rails или пишете тесты RSpec — вы уже используете DSL

Взгляните на привычные конструкции:

describe User do  it 'validates presence of name' do    user = User.new(name: nil)    expect(user).not_to be_valid  endend
class Post < ApplicationRecord  belongs_to :author  has_many :comments  validates :title, presence: trueend

Что общего у этих фрагментов?

Код описывает желаемое поведение, а не алгоритм его реализации.

Мы просто пишем:

  • validates :title, presence: true проверяет поля

  • it 'does something' описывает сценарий

Этот мини‑язык настолько прочно вошёл в нашу жизнь, что кажется естественным. Признаюсь, когда я начинала изучать Ruby, в том числе Rails, я не придавала значения, как на самом деле работают эти конструкции.

В этой статье на примере конфигурационного файла мы разберем, какие механизмы Ruby позволяют написать лаконичный DSL, вроде validates :title.

Давайте разбираться.

Что такое DSL?

DSL (Domain‑Specific Language) — это узкоспециализированный мини-язык, созданный для решения конкретных задач:

  • использует естественный, читаемый синтаксис;

  • скрывает низкоуровневые детали реализации;

  • позволяет описывать что нужно сделать, а не как это сделать.

Пример config/routes.rb

Rails.application.routes.draw do    root "pages#home"  get 'about', to: "pages#about"  resources :articles  get 'signup', to: "users#new"  resources :users, except: [:new]  get 'login', to: "sessions#new"  post 'login', to: "sessions#create"  delete 'logout', to: "sessions#destroy"  resources :categoriesend

Даже, если вы не знакомы с Ruby, наглядно видно, что здесь описываются доступные в приложении URL-адреса и действия при переходе по ним.

Как это работает?

Взглянем на пример конфигурационного файла. Что здесь происходит?

AppConfig.setup do  host '127.0.0.1'  port 6380  logging level: :info  pool size: 20end

По сути, мы передаем некий набор параметров для конфигурации приложения. Есть ли хоть какой-то намек, как это дальше работает? Нет.

Перейдем к деталям реализации.

Есть некий класс AppConfig c классовым методом setup, куда можно передать блок кода с методами host, port, logging, pool.

Взглянем на этот класс:

require 'singleton'class AppConfig  include Singleton  def initialize    @settings = {}  end  def host(value)    @settings[:host] = value  end  def port(value)    @settings[:port] = value.to_i  end  def logging(options = {})    @settings[:logging] ||= {}    @settings[:logging].merge!(options)  end  def pool(options = {})    @settings[:pool] ||= {}    @settings[:pool].merge!(options)  end  def show    puts "🛠 Текущие настройки:"    @settings.each do |k, v|      puts "  #{k}: #{v.inspect}"    end  end  def self.setup(&block)    instance.instance_eval(&block)    instance  endend

Мы видим методы (host, port, logging, pool), где реализована простая запись параметров в хеш (для примера).

Что означает include Singleton?

Есть такой паттерн проектирования под названием Singleton, гарантирующий, что у класса будет ровно один экземпляр. Это важно как раз для конфигурации приложения, сервера или подключения внешнего сервиса, что гарантирует использование единственной конфигурации.

Singleton в данном случае — это модуль, который мы подключаем к классу и получаем следующее:

  • создание экземпляров через new блокируется

  • единственный экземпляр доступен через метод instance

# Этот метод уже есть в модуле Singletondef instance  @instance ||= newend

Экземпляр класса создается только в том случае, если ранее он не был создан.

Внутри классового метода setup вызываем instance и на нем instance_eval с переданным блоком. Instance_eval выполняет код в контексте переданного объекта (экземпляр класса AppConfig).

Проверяем работоспособность, вызывая метод show на экземпляре класса.

require_relative 'dsl'require_relative 'config'config = AppConfig.instanceconfig.show
🛠 Текущие настройки:  host: "127.0.0.1"  port: 6380  logging: {:level=>:info}  pool: {:size=>20}

Все наши параметры успешно записаны в хеш.

Добавим гибкости c method_missing

Сейчас AppConfig поддерживает только жестко заданные методы (host, port, logging, pool). Но что, если мы хотим добавить новые настройки без добавления новых методов в класс? Для таких целей в Ruby существует метод method_missing.

Исходя из названия, method_missing предназначен для обработки вызовов несуществующих методов. Давайте переопределим его, тк по умолчанию method_missing просто выдает NoMethodError.

# добавляем метод в AppConfigdef method_missing(name, *args, &block)  if args.size == 1    @config[name] = args.first  else    @config[name] || nil  end    # if block_given? можем ообработать и блок, если он переданend

Теперь мы можем добавить в конфигурационный файл любой новый параметр, который не описан в классе AppConfig.

AppConfig.setup do  ...  database_type "postges"end
🛠 Текущие настройки:  host: "127.0.0.1"  port: 6380  logging: {:level=>:info}  pool: {:size=>20}  database_type: "postges"

Для корректной обработки методов «на лету», которые попадают в method_missing, необходимо переопределить метод respond_to_missing?. В таком случае, метод respond_to? будет возвращать true для всех методов, даже если они не объявлены явно.

# добавляем метод в AppConfigdef respond_to_missing?(name, include_private = false) trueend

Готово! Мы получили легкий и интуитивно-понятный синтаксис для конфигурации нашего приложения.

Заключение

Пример с AppConfig наглядно демонстрирует гибкие инструменты Ruby для написания кода близкого к естественному языку. Всем известный Ruby on Rails, как самый яркий пример использования DSL, позволяет разработчикам абстрагироваться от низкоуровневых деталей реализации и сосредоточиться на бизнес-логике.

В следующий раз, используя validates или belongs_to, вы будете точно знать, что стоит за этими строками.

ссылка на оригинал статьи https://habr.com/ru/articles/1023302/