[Перевод] Как быстро и просто написать DSL на Ruby

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

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

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

Написание DSL — это огромное количество работы, и оно далеко не всегда может помочь вам в решении задачи. В нашем случае, однако, достоинства перевесили недостатки: Весь специфичный код собран в одном месте.В нашем Rails-приложении есть несколько моделей, в которых мы должны реализовать специфичный для каждого штата код. Нам нужно генерировать формы, таблицы и манипулировать обязательной информацией, имеющей отношение к сотрудникам, компаниям, графикам подачи документов и ставкам налогов. Мы проводим платежи государственным структурам, подаем сгенерированные формы, вычисляем подоходный налог и многое другое. Реализация DSL позволяет нам собрать весь код, специфичный для шатата, в одном месте. Шаблонизация штатов.Вместо того, чтобы создавать с нуля каждый новый штат, использование DSL позволяет нам автоматизировать создание общих для штатов вещей и, в то же время, позволяет гибко настраивать каждый штат. Уменьшение количества мест, где можно ошибиться.Имея DSL, создающий для нас классы и методы, мы сокращаем шаблонный код и имеем меньше мест, куда вмешиваются разработчики. Качественно оттестировав 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 используется в многострочных конструкциях, а фигурные скобки — в однострочных.Практически наверняка вы их использовали, если пользовались методом типа 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 в качестве инструмента для расширения штатов. Подобные инструменты доказали потрясающую полезность в расширении нашего зарплатного сервиса на оставшуюся часть США, и если подобные задачи вас интересуют, то мы можем работать вместе!

Счастливого метапрограммирования!

© Habrahabr.ru