Если вы работаете с 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
  end
end
class Post < ApplicationRecord
  belongs_to :author
  has_many :comments
  validates :title, presence: true
end

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

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

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

  • 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 :categories

end

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

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

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

AppConfig.setup do
  host '127.0.0.1'
  port 6380
  logging level: :info

  pool size: 20
end

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

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

Есть некий класс 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
  end
end

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

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

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

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

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

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

# Этот метод уже есть в модуле Singleton

def instance
  @instance ||= new
end

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

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

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

require_relative 'dsl'
require_relative 'config'

config = AppConfig.instance
config.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.

# добавляем метод в AppConfig

def 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 для всех методов, даже если они не объявлены явно.

# добавляем метод в AppConfig

def respond_to_missing?(name, include_private = false)
 true
end

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

Заключение

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

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

Комментарии (0)