Pull to refresh

Как быстро и просто написать DSL на Ruby

Reading time 14 min
Views 18K
Original author: Nikhil Mathew
Представленный текст является переводом статьи из официального блога компании ZenPayroll. Несмотря на то, что в некоторых вопросах я не согласен с автором, общий подход и методы, показанные в этой статье, могут быть полезны широкому кругу людей, пишущих на Ruby. Заранее извиняюсь за то, что некоторые бюрократические термины могли быть переведены некорректно. Здесь и далее курсивом выделены мои примечания и комментарии.

В ZenPayroll мы стараемся максимально скрыть сложность решаемой задачи. Начисление заработной платы традиционно было бюрократическим осиным гнездом, и реализация современного и удобного решения в столь недружелюбной атмосфере — это привлекательная техническая задача, которую очень сложно решить без
автоматизации.

ZenPayroll сейчас создает общегосударственный сервис (реализован уже в 24 штатах), что означает, что мы удовлетворяем множеству требований, уникальных для каждого штата. Поначалу мы заметили, что тратим много времени на написание шаблонного кода вместо того, чтобы сконцентрироваться на том, что делает каждый штат уникальным. Вскоре мы поняли, что эту проблему мы можем решить, используя преимущества создания DSL, чтобы ускорить и упростить процесс разработки.

В этой статье мы создадим DSL, максимально близкий к тому, что мы используем сами.

Когда нам нужен DSL?


Написание DSL — это огромное количество работы, и оно далеко не всегда может помочь вам в решении задачи. В нашем случае, однако, достоинства перевесили недостатки:

  1. Весь специфичный код собран в одном месте.
    В нашем Rails-приложении есть несколько моделей, в которых мы должны реализовать специфичный для каждого штата код. Нам нужно генерировать формы, таблицы и манипулировать обязательной информацией, имеющей отношение к сотрудникам, компаниям, графикам подачи документов и ставкам налогов. Мы проводим платежи государственным структурам, подаем сгенерированные формы, вычисляем подоходный налог и многое другое. Реализация DSL позволяет нам собрать весь код, специфичный для шатата, в одном месте.
  2. Шаблонизация штатов.
    Вместо того, чтобы создавать с нуля каждый новый штат, использование DSL позволяет нам автоматизировать создание общих для штатов вещей и, в то же время, позволяет гибко настраивать каждый штат.
  3. Уменьшение количества мест, где можно ошибиться.
    Имея DSL, создающий для нас классы и методы, мы сокращаем шаблонный код и имеем меньше мест, куда вмешиваются разработчики. Качественно оттестировав DSL и защитив его от неправильных входных данных, мы очень сильно снижем вероятность возникновения ошибки.
  4. Возможность быстрого расширения.
    Мы создаем фреймворк, который облегчает реализацию уникальных требований для новых штатов. DSL это набор инструментов, сохраняющий нам время на это и позволяющий разработке двигаться дальше.

Написание DSL


В рамках этой статьи мы сконцентрируемся на создании DSL, который позволит нам хранить идентификационные номера компаний и параметры начисления зарплаты (использующиеся для вычисления налогов). Хотя это всего лишь беглый взгляд на то, что может предоставить нам DSL, это все-еще полноценное введение в тему. Наш итоговый код, написанный с помощью созданного DSL, будет выглядеть примерно так:

StateBuilder.build('CA') do
  company do
    edd { format '\d{3}-\d{4}-\d' }
    sos { format '[A-Z]\d{7}' }
  end

  employee do
    filing_status { options ['Single', 'Married', 'Head of Household'] }
    withholding_allowance { max 99 }
    additional_withholding { max 10000 }
  end
end  

Отлично! Это чистый, понятный и выразительный код, использующий интерфейс, разработанный для решения нашей задачи. Давайте начнем.

Определение параметров


В первую очередь, давайте определимся, что мы хотим получить в итоге. Первый вопрос: какую информацию мы хотим хранить?

Каждый штат требует от компаний регистрироваться у местных властей. При регистрации в большинстве штатов, компаниям выдаются идентификационные номера, которые требуются для выплаты налогов и подачи документов. На уровне компании мы должны иметь возможность хранить различные идентификационные номера для разных штатов.

Удерживаемые налоги рассчитываются исходя из количества пособий, получаемых сотрудником. Это величины, которые определяются в формах W-4 для каждого штата. Для каждого штата есть множество вопросов, задающихся, чтобы определить ставки налогов: ваш статус налогоплательщика, связанные льготы, пособия по инвалидности и многое другое. Для сотрудников нам нужен гибкий метод для определения различных атрибутов для каждого штата, чтобы правильно считать налоговые ставки.

DSL, который мы напишем, будет обрабатывать идентификационные номера компаний и базовую информацию о начислении зарплаты для сотрудников. Дальше мы используем этот инструмент для описания Калифорнии. Так как Калифорния имеет некоторые дополнительные условия, которые необходимо учитывать при рассчете зарплаты, мы сконцентрируемся на них для того, чтобы показать, как разрабатывать DSL.

Я предоставляю ссылку на простое Rails-приложение для того, чтобы вы могли следовать шагам, которые будут сделаны в этой статье.

В приложении используются следующие модели:

  • Company. Описывает сущность «компания». Хранит информацию о названии, типе и дате основания.
  • Employee. Описывает сотрудника, работающего на компанию. Хранит информацию об имени, платежах и дате поступления на работу.
  • CompanyStateField. Каждая компания связана со многими CompanyStateField, каждый из которых хранит определенную информацию, связанную с компанией и специфичную для штата, например, идентификационный номер. В калифорнии от работодателя требуются два номера: номер в департаменте развития занятости (EDD) и номер в секретариате штата (SoS). Больше информации по этому вопросу можно найти здесь.
  • EmployeeStateField. Каждый сотрудник связан со многими EmployeeStateField, каждый из которых хранит информацию сотрудника, специфичную для штата. Это информация, которую можно найти в формах W-4 штата, например, скидки при удержании налогов или статус налогоплательщика. Калифорнийская форма DE4 требует указания налоговых скидок, удерживаемой суммы в долларах, и статуса налогоплательщика (холост, женат, глава семьи).

Мы создаем модели-наследники от моделей CompanyStateField и EmployeeStateField, которые будут использовать те же таблицы, что и базовые классы (single table inheritance). Это позволяет нам определять их наследников, специфичных для штата, и использовать только одну таблицу для хранения данных всех таких моделей. Чтобы это осуществить, обе таблицы содержат сериализованные хеши, которые мы и будем использовать для хранения специфичных данных. Хотя по этим данным и нельзя будет проводить запросы, это позволяет нам не раздувать базу неиспользуемыми столбцами.
Прим. переводчика. При использовании Postgres, эти данные можно хранить в нативно поддерживаемом JSON.

Наше приложение подготовлено для работы со штатами, и теперь наш DSL должен создавать специфичные классы, которые и реализуют требуемую функциональность для Калифорнии.

Что нам поможет?


Метапрограммирование — это та область, где Ruby может показать себя во всей красе. Мы можем создавать методы и классы прямо во время выполнения программы, а также использовать огромное количество методов метопрограммирования, что превращает создание DSL на Ruby в сплошное удовольствие. Сам по себе Rails это
DSL для создания web-приложений и огромное количество его «магии» базируется на возможностях метапрограммирования Ruby. Ниже я приведу небольшой список методов и объектов, которые будут полезны для метапрограммирования.

Блоки


Блоки позволяют нам группировать код и передавать его в виде аргумента для метода. Их можно описывать с помощью конструкции do end или фигурных скобок. Оба варианта тождественны.
Прим. переводчика. Согласно принятому стилю, синтаксис do end используется в многострочных конструкциях, а фигурные скобки — в однострочных. Также существуют некоторые различия (спасибо mudasobwa), несущественные в данном случае, но которые могут доставить вам немало забавных минут отладки.
Восстановленный оригинальный комментарий
Блоки позволяют нам группировать код и передавать его в виде аргумента для метода. Их можно описывать с помощью конструкции do end или фигурных скобок. Оба варианта тождественны.
Прим. переводчика. Согласно принятому стилю, синтаксис do end используется в многострочных конструкциях, а фигурные скобки — в однострочных.


Вы оба неправы :)

На самом деле, разница есть, и она может привести к ошибке в коде, от которой легко поседеть, но которую крайне сложно отловить, если не знать в чем дело. Смотрите:
require 'benchmark'
puts Benchmark.measure { "a"*1_000_000 }
# => 0.000000   0.000000   0.000000 (  0.000427)

puts Benchmark.measure do
  "a"*1_000_000
end
# => LocalJumpError: no block given (yield)
# =>     from IRRELEVANT_PATH_TO_RVM/lib/ruby/2.0.0/benchmark.rb:281:in `measure'
# =>     from (irb):9


Клево, да?

Подумайте, прежде, чем нажать:
Из-за разного приоритета операторов код второго примера на самом деле выполняется в такой последовательности:
(puts Benchmark.measure) do
  # irrelevant code
end



Поправьте примечание в коде, пожалуйста. Люди же читают :)

Практически наверняка вы их использовали, если пользовались методом типа each:
[1,2,3].each { |number| puts number*2 }

Это прекрасная вещь для создания DSL, потому что они позволяют нам создать код в одном контексте, а выполнить его в другом. Это дает нам возможность создать читабельный DSL, вынося определения методов в другие классы. Много примеров этого мы увидим далее.

send


Метод send позволяет нам вызывать методы объекта (даже приватные), передавая ему имя метода в виде символа. Это полезно для вызова методов, которые обычно вызываются внутри определения класса или для интерполяции переменных для динамических вызовов метода.

define_method


В Ruby define_method дает нам возможность создавать методы не используя обычную процедуру при описании класса. Он принимает в качестве аргументов строку, которая будет именем метода и блок, который будет выполняться при вызове метода.

instance_eval


Это вещь, необходимая при создании DSL почти так же, как и блоки. Он принимает блок и выполняет его в контексте объекта-приемника. Например:

class MyClass
  def say_hello
    puts 'Hello!'
  end
end

MyClass.new.instance_eval { say_hello } # => 'Hello!'

В этом примере блок содержит вызов метода say_hello, несмотря на то, что в его контексте такого метода нет. Экземпляр класса, возвращенный из MyClass.new, является приемником для instance_eval и вызов say_hello происходит в его контексте.

class MyOtherClass
  def initialize(&block)
    instance_eval &block
  end

  def say_goodbye
    puts 'Goodbye'
  end
end

MyOtherClass.new { say_goodbye } # => 'Goodbye!'

Мы снова описываем блок, который вызывает неопределенный в его контексте метод. В этот раз мы передаем блок в конструктор класса MyOtherClass и выполняем его в контексте self приемника, который является экземпляром MyOtherClass. Отлично!

method_missing


Это та магия, благодаря которой работают методы find_by_* в Rails. Любой вызов неопределенного метода попадает в method_missing, который принимает на вход имя вызванного метода и все переданные ему аргументы. Это еще одна прекрасная вещь для DSL, потому что она позволяет создавать методы динамически, когда мы не знаем, что может быть реально вызвано. Это дает нам возможность создать очень гибкий синтаксис.

Проектирование и реализация DSL


Теперь, когда у нас есть некоторые знания о нашем наборе инструментов, пришло время подумать о том, каким мы хотим видеть наш DSL и как с ним будут дальше работать. В данном случае, мы будем работать «задом наперед»: вместо того, чтобы начинать с создания классов и методов, мы разработаем идеальный синтаксис и будем строить все остальное вокруг него. Будем считать этот синтаксис эскизом того, что мы хотим получить. Давайте снова взглянем на то, как все должно выглядеть в итоге:

StateBuilder.build('CA') do
  company do
    edd { format '\d{3}-\d{4}-\d' }
    sos { format '[A-Z]\d{7}' }
  end

  employee do
    filing_status { options ['Single', 'Married', 'Head of Household'] }
    withholding_allowance { max 99 }
    additional_withholding { max 10000 }
  end
end  

Давайте разобьем это на части и будем постепенно писать код, который облачит наш DSL в классы и методы, которые нам нужны, чтобы описать Калифорнию.


Если вы хотите следовать за мной с помощью предоставленного кода, то можете сделать git checkout step-0 и дописывать код вместе со мной в процессе чтения.


Наш DSL, который мы назвали StateBuilder — это класс. Мы начинаем создание каждого штата с вызова метода класса build с аббревиатурой имени штата и описывающего его блока в качестве параметров. В этом блоке, мы можем вызывать методы, которые мы назовем company и employee и передавать каждому из них собственный конфигурационный блок, который будет настраивать наши специализированные модели (CompanyStateField::CA и EmployeeStateField::CA)

# app/states/ca.rb

StateBuilder.build('CA') do
  company do
    # Конфигурируем CompanyStateField::CA
  end

  employee do
    # Конфигурируем EmployeeStateField::CA
  end
end  

Как было упомянуто ранее, наша логика инкапсулирована в класс StateBuilder. Мы вызываем блок, переданный в self.build в контексте нового экземпляра StateBuilder, поэтому company и employee должны быть определены и каждый из них должен принимать блок в качестве аргумента. Давайте начинем разработку с создания болванки класса, которая подходит под эти условия.

# app/models/state_builder.rb

class StateBuilder
  def self.build(state, &block)
    # Если не передан блок, выбрасываем исключение
    raise "You need a block to build!" unless block_given?

    StateBuilder.new(state, &block)
  end

  def initialize(state, &block)
    @state = state

    # Выполняем код переданного блока в контексте этого экземпляра StateBuilder
    instance_eval &block
  end

  def company(&block)
    # Конфигурируем CompanyStateField::CA
  end

  def employee(&block)
    # Конфигурируем EmployeeStateField::CA
  end
end  

Теперь у нас есть база для нашего StateBuilder. Так как методы company и employee будут определять классы CompanyStateField::CA и EmployeeStateField::CA, давайте определимся, как должны будут выглядеть блоки, которые мы будем передавать этим методам. Мы должны определить каждый атрибут, который будут иметь наши модели, а также некоторую информацию об этих атрибутах. Что особенно приятно в создании собственного DSL, так это то, что мы не обязаны использовать стандартный синтаксис Rails для методов-геттеров и сеттеров, а также валидаций. Вместо этого, давайте реализуем синтаксис, который мы описывали ранее.
Прим. переводчика. Спорная мысль. Я бы все-таки постарался минимизировать зоопарк синтаксисов в рамках приложения, пусть и за счет некоторой избыточности кода.


Пришло время сделать git checkout step-1.


Для калифорнийских компаний мы должны хранить два идентификационных номера: номер выданный Калифорнийским Департаментом Занятости (EDD) и номер, выданный секретариатом штата (SoS).

Формат номера EDD: "###-####-#", а формат номера SoS "@#######", где @ означает «любая буква», а # — «любая цифра».

В идеале, мы должны использовать имя нашего атрибута в качестве имени метода, которому в качестве параметра передавать блок, который определит формат этого поля (Кажется, пришло время для method_missing!).
Прим. переводчика. Возможно, со мной что-то не так, но синтаксис вида
field name, params
мне кажется более понятным и логичным, чем предложенный автором (сравните со стандартными миграциями). При использовании авторского синтаксиса с первого взгляда вовсе не очевидно, что в блоках, описывающих компанию или работника, допустимо писать любые имена, а также вы получаете прекрасный гранатомет для стрельбы в ногу (см. далее).
Давайте напишем, как будут выглядеть вызовы этих методов для номеров EDD и SoS.

#app/states/ca.rb

StateBuilder.build('CA') do
  company do
    edd { format '\d{3}-\d{4}-\d' }
    sos { format '[A-Z]\d{7}' }
  end

  employee do
    # Конфигурируем EmployeeStateField::CA
  end
end  

Обратите внимание, что здесь при описании блока мы сменили синтаксис с do end на фигурные скобки, но результат при этом не изменился — мы все так же передаем исполняемый блок кода в функцию. Теперь давайте проведем аналогичную процедуру и для сотрудников.

Согласно калифорнийскому свидетельству о льготах при начислении налогов, работников спрашивают о их статусе налогоплательщика, количестве льгот и любых других дополнительных удерживаемых суммах, которые у них могут быть. Статусом налогоплательщика может быть Одинок, Состоит в браке или Глава Семьи; налоговые льготы не должны превышать 99, а для дополнительных удерживаемых сумм давайте установим максимум в $10,000. Теперь давайте опишем их так же, как сделали это для полей компании.

#app/states/ca.rb

StateBuilder.build('CA') do
  company do
    edd { format '\d{3}-\d{4}-\d' }
    sos { format '[A-Z]\d{7}' }
  end

  employee do
    filing_status { options ['Single', 'Married', 'Head of Household'] }
    withholding_allowance { max 99 }
    additional_withholding { max 10000 }
  end
end  

Теперь у нас есть окончательная реализация для Калифорнии. Наш DSL описывает атрибуты и валидации для CompanyStateField::CA и EmployeeStateField::CA с использованием нашего собтвенного синтаксиса.

Все, что нам осталось — это перевести наш синтаксис в классы, геттеры/сеттеры и валидации. Давайте реализуем методы company и employee в классе StateBuilder и получим работающий код.


Третья часть марлезонского балета: git checkout step-2


Мы реализуем наши методы и валидации, определив, что делать с каждым из блоков в методах StateBuilder#company и StateBuilder#employee. Давайте воспользуемся подходом похожим на тот, который мы использовали определяя StateBuilder: создадим «контейнер», который будет содержать эти методы и выполнять с помощью instance_eval переданный блок в своем контексте.

Назовем наши контейнеры StateBuilder::CompanyScope и StateBuilder::EmployeeScope и создадим в StateBuilder методы, создающие экземпляры этих классов.

#app/models/state_builder.rb

class StateBuilder
  def self.build(state, &block)
    # Если не передан блок, выбрасываем исключение
    raise "You need a block to build!" unless block_given?

    StateBuilder.new(state, &block)
  end

  def initialize(state, &block)
    @state = state

    # Выполняем код переданного блока в контексте этого экземпляра StateBuilder
    instance_eval &block
  end

  def company(&block)
    StateBuilder::CompanyScope.new(@state, &block)
  end

  def employee(&block)
    StateBuilder::EmployeeScope.new(@state, &block)
  end
end  


#app/models/state_builder/company_scope.rb

class StateBuilder
  class CompanyScope
    def initialize(state, &block)
      @klass = CompanyStateField.const_set state, Class.new(CompanyStateField)

      instance_eval &block
    end
  end
end  


#app/models/state_builder/employee_scope.rb

class StateBuilder
  class EmployeeScope
    def initialize(state, &block)
      @klass = EmployeeStateField.const_set state, Class.new(EmployeeStateField)

      instance_eval &block
    end
  end
end  

Мы используем const_set для того, чтобы определить подклассы CompanyStateField и EmployeeStateField с именем нашего штата. Это создаст нам классы CompanyStateField::CA и EmployeeStateField::CA, каждый из которых наследуется от соответствующего родителя.

Теперь мы можем сосредоточиться на последнем этапе: блоках, переданных каждому из наших создаваемых атрибутов (sos, edd, additional_witholding и т.д.). Они будут выполнены в контексте CompanyScope и EmployeeScope, но если мы попробуем сейчас выполнить наш код, то получим ошибки о вызове неизвестных методов.

Воспользуемся методом method_missing чтобы обработать эти случаи. В текущем состоянии мы можем полагать, что любой вызванный метод является именем атрибута, а блоки, переданные в них, описывают то, как мы хотим его сконфигурировать. Это дает нам «магическую» возможность определять нужные атрибуты и сохранять их
в базу данных.

Внимание! Использование method_missing так, что не предусмотрено ситуации, когда может быть вызван super, может привести к неожиданному поведению. Опечатки будет трудно отслеживать, так как все они будут попадать в method_missing. Убедитесь, что созданы варианты, при которых method_missing вызовет super, когда будете писать что-то, основываясь на этих принципах.
Прим. переводчика. Вообще, лучше свести использование method_missing к минимуму, потому что оно очень сильно замедляет программу. В данном случае это не критично, так как весь этот код выполняется только при старте приложения


Определим метод method_missing и передадим эти аргументы в последний контейнер, который мы создадим — AttributesScope. Этот контейнер будет вызывать store_accessor и создавать валидации, основываясь на тех блоках, которые мы ему передадим.

#app/models/state_builder/company_scope.rb

class StateBuilder
  class CompanyScope
    def initialize(state, &block)
      @klass = CompanyStateField.const_set state, Class.new(CompanyStateField)

      instance_eval &block
    end

    def method_missing(attribute, &block)
      AttributesScope.new(@klass, attribute, &block)
    end
  end
end  


#app/models/state_builder/employee_scope.rb

class StateBuilder
  class EmployeeScope
    def initialize(state, &block)
      @klass = EmployeeStateField.const_set state, Class.new(EmployeeStateField)

      instance_eval &block
    end

    def method_missing(attribute, &block)
      AttributesScope.new(@klass, attribute, &block)
    end
  end
end  

Теперь каждый раз, когда мы будем вызывать метод в блоке company в app/states/ca.rb, он будет попадать в определенную нами функцию method_missing. Первым ее аргументом будет имя вызванного метода, оно же имя определяемого атрибута. Мы создаем новый экземпляр AttributesScope, передавая ему класс, который будем изменять, имя определяемого атрибута и блок, конфигурирующий атрибут. В AttributesScope мы будем вызывать store_accessor, который определит геттеры и сеттеры для атрибута, и использовать сериализованный хеш для хранения данных.

class StateBuilder
  class AttributesScope
    def initialize(klass, attribute, &block)
      klass.send(:store_accessor, :data, attribute)
      instance_eval &block
    end
  end
end  

Также нам надо определить методы, которые мы вызываем внутри блоков, конфигурирующих атрибуты (format, max, options) и превратить их в валидаторы. Мы сделаем это, преобразовывая вызовы этих методов в вызовы валидаций, которые ожидает Rails.

class StateBuilder
  class AttributesScope
    def initialize(klass, attribute, &block)
      @validation_options = []

      klass.send(:store_accessor, :data, attribute)
      instance_eval &block
      klass.send(:validates, attribute, *@validation_options)
    end

    private

    def format(regex)
      @validation_options << { format: { with: Regexp.new(regex) } }
    end

    def max(value)
      @validation_options << {
        numericality: {
          greater_than_or_equal_to: 0,
          less_than_or_equal_to: value
        }
      }
    end

    def options(values)
      @validation_options << { inclusion: { in: values } }
    end
  end
end  

Наш DSL готов к бою. Мы успешно определили модель CompanyStateField::CA, которая хранит и валидирует номера EDD и SoS, а также модель EmployeeStateField::CA, которая хранит и валидирует налоговые льготы, статус налогоплательщика и дополнительные сборы для сотрудников. несмотре на то, что наш DSL был создан
для автоматизации достаточно простых вещей, каждый из его компонентов может быть легко расширен. Мы можем легко добавить новые хуки в DSL, определить больше методов в моделях и развивать его дальше, основываясь на функционале, который мы реализовали сейчас.

Наша реализация заметно уменьшает повторения и шаблонный код в бэкэнде, но все-еще требует, чтобы у каждого штата были собственные представления (views) на стороне клиента. Мы расширили нашу внутреннюю разработку, чтобы она охватывала и клиентскую часть для новых штатов, и если в комментариях будет проявлен интерес, я напишу еще один пост, рассказывающий о том, как это работает у нас.

Эта статья показывает только часть того, как мы используем наш собственный DSL в качестве инструмента для расширения штатов. Подобные инструменты доказали потрясающую полезность в расширении нашего зарплатного сервиса на оставшуюся часть США, и если подобные задачи вас интересуют, то мы можем работать вместе!

Счастливого метапрограммирования!
Tags:
Hubs:
+25
Comments 26
Comments Comments 26

Articles