В этой статье я хотел бы рассказать о том, какие проблемы с типами данных есть в Ruby, с какими проблемами столкнулся я, как их можно решить и как сделать так, чтобы на данные, с которыми мы работаем, можно было положиться.
Для начала стоит определиться с тем, что такое типы данных. Крайне удачным мне видится определение этого термина, которое можно найти в HaskellWiki.
Как известно, в Ruby используется строгая динамическая типизация с поддержкой т.н. утиной типизации. Что это означает?
Строгая типизация требует явного приведения типов и не производит этого приведения самостоятельно, как это происходит, например, в JavaScript. Поэтому следующий листинг кода в Ruby закончится ошибкой:
В динамической типизации проверка типов происходит в рантайме, что позволяет нам не указывать типы переменных и использовать одну и ту же переменную для хранения значений разных типов:
В качестве объяснения понятия “утиная типизация” обычно приводят следующее высказывание: если это выглядит как утка, плавает как утка и крякает как утка, то это, скорее всего, и есть утка. Т.е. утиная типизация, полагаясь на поведение объектов, предоставляет нам дополнительную гибкость при написании наших систем. Например, в примере ниже значение для нас имеет не тип аргумента
Возможность создания подобных “уточек” — очень мощный инструмент. Однако, как и любой другой мощный инструмент, он требует большой осторожности при использовании. Убедиться в этом помогает исследование компании Rollbar, где они проанализировали более 1000 Rail-приложений и выявили наиболее частые ошибки. И 2 из 10 наиболее частых ошибок связаны именно с тем, что объект не может ответить на определенное сообщение. И поэтому проверки поведения объекта, что нам дает утиная типизация, во многих случаях может быть недостаточно.
Мы можем наблюдать, как в динамические языки в том или ином виде добавляется проверка типов:
Однако прежде, чем говорить о еще одном инструменте для более эффективной работы с типами в Ruby, давайте рассмотрим еще две проблемы, решение для которых хотелось бы найти.
Давайте вспомним определение типов данных, которое я привел в самом начале статьи:
Вот типичный пример Primitive Obsession:
Вместо того, чтобы описать тип данных для работы с деньгами, очень часто используются обычные числа. И это число, как и любые другие примитивные типы, ничего не говорят о нашей предметной области. На мой взгляд, это самая большая проблема использования примитивов вместо создания своей собственной системы типов, где эти типы будут описывать данные из нашей предметной области. Мы сами же отказываемся от тех преимуществ, которые можем получить с помощью использования типов.
Об этих преимуществах я расскажу сразу после освещения еще одной проблемы, к которой приучил нас наш любимый фреймворк Ruby on Rails, благодаря которому, я уверен, большинство из присутствующих тут и пришли в Ruby.
Ruby on Rails, а точнее встроенный в него ORM-фреймворк
Возьмем такой пример:
То, что объект
А теперь попытаемся передать этот объект в невалидном состоянии в сервис, который в качестве аргумента ожидает объект
В этом случае прошла бы даже проверка типов. Однако поскольку этот атрибут у объекта пустой, непонятно, каким образом сервис обработает этот случай. В любом случае, имея возможность создания объектов в невалидном состоянии, мы обрекаем себя на необходимость постоянно обрабатывать случаи, когда невалидные состояние просочилось в нашу систему.
Но давайте задумаемся над более глубинной проблемой. Вообще, почему мы проверяем валидность данных? Как правило, чтобы убедиться, что недопустимое состояние не просачивается в наши системы. Если так важно гарантировать, что недопустимое состояние не разрешено, то почему мы разрешаем создавать объекты с невалидным состоянием? Особенно, когда мы имеем дело с такими важными объектами, как модель ActiveRecord, которая часто относится к корневой бизнес-логике. На мой взгляд, это звучит как очень плохая идея.
Итак, обобщая все вышесказанное, мы получаем следующие проблемы в работе с данными в Ruby/Rails:
Я хотел бы рассмотреть один из вариантов решения проблем, описанных выше, на примере реализации реальной фичи в Appodeal. В процессе реализации сбора статистики по Daily Active Users (далее DAU) у приложений, которые используют для монетизации Appodeal, мы пришли примерно к следующей структуре данных, которые нам нужно собирать:
У этой структуры есть все те же проблемы, о которых я писал выше:
Для решения этих проблем мы решили использовать библиотеки
Для описания данных нашей предметной области, используемых в структуре для сбора DAU, была создана такая система типов:
Теперь мы получили описание тех данных, которые используются у нас в системе и которые мы можем использовать в структуре. Как видно, типы
Итак, с использованием самих типов разобрались. Теперь давайте применим их к нашей структуре. В итоге мы получили вот что:
Что мы видим сейчас в структуре данных для DAU? За счет использования
Что же касается проблемы с объектами в невалидном состоянии, то
И помимо невозможности инициализировать объекты в невалидном состоянии,
В данной статье я попытался описать те проблемы, с которыми вы можете столкнуться при работе с данными в Ruby, а также рассказать об инструментах, которыми мы используем для решения этих проблем. И благодаря внедрению этих инструментов я абсолютно перестал переживать о корректности данных, с которыми мы работаем. Разве это не прекрасно? Разве не в этом цель любого инструмента — облегчить нашу жизнь в каком-то ее аспекте? И на мой взгляд,
Для начала стоит определиться с тем, что такое типы данных. Крайне удачным мне видится определение этого термина, которое можно найти в HaskellWiki.
Типы — это то, как вы описываете данные, с которыми будет работать ваша программа.Но что же не так с типами данных в Ruby? Чтобы описать проблему комплексно, я хотел бы выделить несколько причин.
Причина 1. Проблемы самого Ruby
Как известно, в Ruby используется строгая динамическая типизация с поддержкой т.н. утиной типизации. Что это означает?
Строгая типизация требует явного приведения типов и не производит этого приведения самостоятельно, как это происходит, например, в JavaScript. Поэтому следующий листинг кода в Ruby закончится ошибкой:
1 + '1' - 1
#=> TypeError (String can't be coerced into Integer)
В динамической типизации проверка типов происходит в рантайме, что позволяет нам не указывать типы переменных и использовать одну и ту же переменную для хранения значений разных типов:
x = 123
x = "123"
x = [1, 2, 3]
В качестве объяснения понятия “утиная типизация” обычно приводят следующее высказывание: если это выглядит как утка, плавает как утка и крякает как утка, то это, скорее всего, и есть утка. Т.е. утиная типизация, полагаясь на поведение объектов, предоставляет нам дополнительную гибкость при написании наших систем. Например, в примере ниже значение для нас имеет не тип аргумента
collection
, а его возможность ответить на сообщения blank?
и map
:def process(collection)
return if collection.blank?
collection.map { |item| do_something_with(item) }
end
Возможность создания подобных “уточек” — очень мощный инструмент. Однако, как и любой другой мощный инструмент, он требует большой осторожности при использовании. Убедиться в этом помогает исследование компании Rollbar, где они проанализировали более 1000 Rail-приложений и выявили наиболее частые ошибки. И 2 из 10 наиболее частых ошибок связаны именно с тем, что объект не может ответить на определенное сообщение. И поэтому проверки поведения объекта, что нам дает утиная типизация, во многих случаях может быть недостаточно.
Мы можем наблюдать, как в динамические языки в том или ином виде добавляется проверка типов:
- TypeScript привнес проверку типов для JavaScript-разработчиков
- Type hints были добавлены в Python 3
- Dialyzer неплохо справляется с задачей проверки типов для Erlang/Elixir
- Steep и Sorbet добавляют проверку типов в Ruby 2.x
Однако прежде, чем говорить о еще одном инструменте для более эффективной работы с типами в Ruby, давайте рассмотрим еще две проблемы, решение для которых хотелось бы найти.
Причина 2. Общая проблема разработчиков на различных языках программирования
Давайте вспомним определение типов данных, которое я привел в самом начале статьи:
Типы — это то, как вы описываете данные, с которыми будет работать ваша программа.Т.е. типы призваны помочь нам описывать данные из нашей предметной области, в которых работают наши системы. Однако часто вместо оперирования созданными нами типами данных из нашей предметной области мы используем примитивные типы, такие как числа, строки, массивы и др., которые о нашей предметной области не говорят ровным счетом ничего. Эту проблему принято классифицировать как Primitive Obsession (одержимость примитивами).
Вот типичный пример Primitive Obsession:
price = 9.99
# vs
Money = Struct.new(:amount_cents, :currency)
price = Money.new(9_99, 'USD')
Вместо того, чтобы описать тип данных для работы с деньгами, очень часто используются обычные числа. И это число, как и любые другие примитивные типы, ничего не говорят о нашей предметной области. На мой взгляд, это самая большая проблема использования примитивов вместо создания своей собственной системы типов, где эти типы будут описывать данные из нашей предметной области. Мы сами же отказываемся от тех преимуществ, которые можем получить с помощью использования типов.
Об этих преимуществах я расскажу сразу после освещения еще одной проблемы, к которой приучил нас наш любимый фреймворк Ruby on Rails, благодаря которому, я уверен, большинство из присутствующих тут и пришли в Ruby.
Причина 3. Проблема, к которой нас приучил фреймворк Ruby on Rails
Ruby on Rails, а точнее встроенный в него ORM-фреймворк
ActiveRecord
, приучил нас к тому, что объекты, находящиеся в невалидном состоянии, — это нормально. На мой взгляд, это далеко не самая лучшая идея. И я попытаюсь это объяснить.Возьмем такой пример:
class App < ApplicationRecord
validates :platform, presence: true
end
app = App.new
app.valid?
# => false
То, что объект
app
будет иметь невалидные состояние, понять несложно: валидация модели App
требует наличия у объектов этой модели атрибута platform
, а у нашего объекта этот атрибут пустой.А теперь попытаемся передать этот объект в невалидном состоянии в сервис, который в качестве аргумента ожидает объект
App
и производит какие-то действия, зависящие от атрибута platform
этого объекта:class DoSomethingWithAppPlatform
# @param [App] app
#
# @return [void]
def call(app)
# do something with app.platform
end
end
DoSomethingWithAppPlatform.new.call(app)
В этом случае прошла бы даже проверка типов. Однако поскольку этот атрибут у объекта пустой, непонятно, каким образом сервис обработает этот случай. В любом случае, имея возможность создания объектов в невалидном состоянии, мы обрекаем себя на необходимость постоянно обрабатывать случаи, когда невалидные состояние просочилось в нашу систему.
Но давайте задумаемся над более глубинной проблемой. Вообще, почему мы проверяем валидность данных? Как правило, чтобы убедиться, что недопустимое состояние не просачивается в наши системы. Если так важно гарантировать, что недопустимое состояние не разрешено, то почему мы разрешаем создавать объекты с невалидным состоянием? Особенно, когда мы имеем дело с такими важными объектами, как модель ActiveRecord, которая часто относится к корневой бизнес-логике. На мой взгляд, это звучит как очень плохая идея.
Итак, обобщая все вышесказанное, мы получаем следующие проблемы в работе с данными в Ruby/Rails:
- в самом языке есть механизм проверки поведения, но не данных
- мы, как и разработчики на других языках, склонны использовать примитивные типы данных вместо создания системы типов нашей предметной области
- Rails приучил нас к тому, что наличие объектов в невалидном состоянии — это нормально, хотя такое решение видится довольно плохой идеей
Как можно решить эти проблемы?
Я хотел бы рассмотреть один из вариантов решения проблем, описанных выше, на примере реализации реальной фичи в Appodeal. В процессе реализации сбора статистики по Daily Active Users (далее DAU) у приложений, которые используют для монетизации Appodeal, мы пришли примерно к следующей структуре данных, которые нам нужно собирать:
DailyActiveUsersData = Struct.new(
:app_id,
:country_id,
:user_id,
:ad_type,
:platform_id,
:ad_id,
:first_request_date,
keyword_init: true
)
У этой структуры есть все те же проблемы, о которых я писал выше:
- полностью отсутствует какая-либо проверка типов, из-за чего непонятно, какие значения могут принимать атрибуты данной структуры
- отсутствует какое-либо описание данных, которые используются в этой структуре, и вместо специфичных для нашей предметной области типов используются примитивы
- структура может существовать в невалидном состоянии
Для решения этих проблем мы решили использовать библиотеки
dry-types
и dry-struct
. dry-types
— это простая и расширяемая система типов для Ruby, полезная для приведения типов, применения различных ограничений, определения сложных структур и др. dry-struct
— это библиотека, построенная поверх dry-types
, которая предоставляет удобный DSL для определения типизированных структур/классов.Для описания данных нашей предметной области, используемых в структуре для сбора DAU, была создана такая система типов:
module Types
include Dry::Types.module
AdTypeId = Types::Strict::Integer.enum(AD_TYPES.invert)
EntityId = Types::Strict::Integer.constrained(gt: 0)
PlatformId = Types::Strict::Integer.enum(PLATFORMS.invert)
Uuid = Types::Strict::String.constrained(format: UUID_REGEX)
Zero = Types.Constant(0)
end
Теперь мы получили описание тех данных, которые используются у нас в системе и которые мы можем использовать в структуре. Как видно, типы
EntityId
и Uuid
имеют некоторые ограничения, а enumerable-типы AdTypeId
и PlatformId
могут иметь значения только из определенного набора. Как работать с этими типами? Рассмотрим на примере PlatformId
:# набор допустимых значений для enumerable-типа
PLATFORMS = {
'android' => 1,
'fire_os' => 2,
'ios' => 3
}.freeze
# мы можем использовать как непосредственно сами значения,
# так и их обозначения
Types::PlatformId[1] == Types::PlatformId['android']
# если передать корректное значение, в качестве результата
# получаем значение примитива, на котором построен тип
Types::PlatformId['fire_os']
# => 2
# если передать не корректное значение, получим ошибку
Types::PlatformId['windows']
# => Dry::Types::ConstraintError
Итак, с использованием самих типов разобрались. Теперь давайте применим их к нашей структуре. В итоге мы получили вот что:
class DailyActiveUsersData < Dry::Struct
attribute :app_id, Types::EntityId
attribute :country_id, Types::EntityId
attribute :user_id, Types::EntityId
attribute :ad_type, (Types::AdTypeId ? Types::Zero)
attribute :platform_id, Types::PlarformId
attribute :ad_id, Types::Uuid
attribute :first_request_date, Types::Strict::Date
end
Что мы видим сейчас в структуре данных для DAU? За счет использования
dry-types
и dry-struct
мы избавились от проблем, связанных с отсутствием проверки типов данных и отсутствием описания данных. Теперь любой человек, посмотрев на эту структуру и на описание типов, используемых в ней, может понять, какие значения может принимать каждый из атрибутов.Что же касается проблемы с объектами в невалидном состоянии, то
dry-struct
избавляет нас и от этого: если мы попытаемся проинициализировать структуру невалидными значениями, то в результате мы получим ошибку. И для тех случаев, когда корректность данных имеет существенное значение (а в случае со сбором DAU у нас дела обстоят именно так), на мой взгляд, получить исключение куда лучше, чем потом пытаться разобраться с невалидными данными. К тому же, если процесс тестирования у вас хорошо налажен (а у нас все именно так), то с большой вероятностью до production-окружения код, генерирующий подобные ошибки, просто-напросто не дойдет.И помимо невозможности инициализировать объекты в невалидном состоянии,
dry-struct
также не позволяет изменять объекты после инициализации. Благодаря этим двум факторам мы получаем гарантию того, что объекты таких структур будут находиться в валидном состоянии и на эти данные вы можете спокойно положиться в любом другом месте вашей системы.Итог
В данной статье я попытался описать те проблемы, с которыми вы можете столкнуться при работе с данными в Ruby, а также рассказать об инструментах, которыми мы используем для решения этих проблем. И благодаря внедрению этих инструментов я абсолютно перестал переживать о корректности данных, с которыми мы работаем. Разве это не прекрасно? Разве не в этом цель любого инструмента — облегчить нашу жизнь в каком-то ее аспекте? И на мой взгляд,
dry-types
и dry-struct
в этом со своей задачей отлично справляются! Комментарии (4)
sl_bug
15.12.2018 12:55# если передать не корректное значение, получим ошибку Types::PlatformId['windows'] # => Dry::Types::ConstraintError
а fetch зачем придумали? нафигачат костылей нафиг не нужных никому и потом пытайся сообразить как с этим говном работать.
niko1aev
17.12.2018 00:31Я бы разделил две проблемы: проблему валидации и проблему типов.
Начну с валидации
Зачем мы разрешаем создавать объекты с невалидным состоянием?
1. Потому что это нам требуется для решения наших задач. Например при создании объекта и для возвращения ошибок валидации. Создается объект, проходит его валидация, если объект не валиден, то мы возвращаем невалидный объект с ошибками в контроллер и рендер формы.
Из данных объекта мы проставляем значения в полях, из объекта ошибок мы выводим ошибки.
Т.е. например если бы наш объект Order не мог бы находиться в невалидном состоянии, нам бы пришлось создать еще какой-то класс NonValidOrder и периодически мы рендерили бы форму/json из Order, а периодически из NonValidOrder, и пришлось бы еще делать какие-то механизмы превращения одного в другое. Зачем, если мы всегда можно вызывать метод valid? везде где оно требуется.
2. Часто мы создаем объект и потом до-обогащаем его данными. Применяем купоны, добавляем скидки, привязываем заказ к клиенту и т.д. До обогащения данными наш Order не валидный. Валидность мы проверяем уже перед сохранением в базу. Если бы мы не могли создать невалидный объект, то нам бы пришлось создавать объекты вроде MayBeValidOrder, со сходным функционалом нашего Order
3. Наш Order может быть валидным с заполненными client_id, manager_id, а может быть валидным и без них, поэтому проверять их наличие нам придется все равно.
4. Ну и последнее самое интересное: тот факт, что у нас в
записаноorder.client_id
не дает нам никаких гарантий, что у заказа есть Client, потому что не факт, что у нас есть клиент с таким id. То есть типами мы все равно не избавимся от невалидных объектов.Types::Strict::Integer.constrained(gt: 0)
А теперь что касается типов
Если хочется использовать типы, то IMHO лучше не писать на Ruby)
Для этого прекрасно подойдет тот же Rust, в добавок к типам еще будет очень умный компилятор (который поможет избежать много ошибок) и прирост в скорости на пару порядков.
Ну и на мой взгляд код на Rust с типами намного приятнее, кода на Ruby c типами.
В Ruby с псевдотипами приходится писать сильно больше кода, да еще и зависеть от гемов. Уж лучше тогда писать на Rust или Haskell))
// Код на Rust extern crate uuid; use uuid::Uuid; const IOS: i32 = 1; const ANDROID: i32 = 2; const FIRE_OS: i32 = 3; enum Platform { IOS, ANDROID, FIRE_OS, } struct DailyActiveUsersData { app_id: i32, country_id: i32, user_id: i32, platform_id: Platform, ad_id: Uuid, first_request_date: &'static str, } fn main() { let uuid = Uuid::parse_str("6a2f41a3-c54c-fce8-32d2-0324e1c32e22").unwrap(); let data = DailyActiveUsersData { app_id: 1, country_id: 2, user_id: 3, platform_id: Platform::IOS, ad_id: uuid, first_request_date: "2018-12-16", }; println!("Data {:?}", data); }
# Код на Ruby (я обожаю Ruby и пишу на нем каждый день, и мне кажется Ruby не об этом) require 'dry-types' require 'dry-struct' module Types include Dry::Types.module PLATFORMS = { 'android' => 1, 'fire_os' => 2, 'ios' => 3 }.freeze UUID_REGEXP = /[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/ ENTITY_ID = Types::Strict::Integer.constrained(gt: 0) PLATFORM_ID = Types::Strict::Integer.enum(PLATFORMS.invert) UUID = Types::Strict::String.constrained(format: UUID_REGEXP) ZERO = Types.Constant(0) end class DailyActiveUsersData < Dry::Struct attribute :app_id, Types::ENTITY_ID attribute :country_id, Types::ENTITY_ID attribute :user_id, Types::ENTITY_ID attribute :platform_id, Types::PLATFORM_ID attribute :ad_id, Types::UUID attribute :first_request_date, Types::Strict::Date end data = DailyActiveUsersData.new( app_id: 1, country_id: 2, user_id: 3, platform_id: 1, ad_id: '6a2f41a3-c54c-fce8-32d2-0324e1c32e22', first_request_date: Date.today ) puts data
k4ir05
17.12.2018 05:251. Потому что это нам требуется для решения наших задач. Например при создании объекта и для возвращения ошибок валидации. Создается объект, проходит его валидация, если объект не валиден, то мы возвращаем невалидный объект с ошибками в контроллер и рендер формы.
Из данных объекта мы проставляем значения в полях, из объекта ошибок мы выводим ошибки.
Т.е. например если бы наш объект Order не мог бы находиться в невалидном состоянии, нам бы пришлось создать еще какой-то класс NonValidOrder и периодически мы рендерили бы форму/json из Order, а периодически из NonValidOrder, и пришлось бы еще делать какие-то механизмы превращения одного в другое. Зачем, если мы всегда можно вызывать метод valid? везде где оно требуется.
А если валидация в разных ситуациях различается? Или на форме потребуются производные данные (например, опции для поля выбора или форма будет содержать данные нескольких моделей)? Да и слишком много ответсвенности возникает у модели. Не лучше ли для формы рендеринга формы использовать отдельный объект (form object)? А то получается интерфейс тесно связанный с бизнес-моделью (да и, как правило, с БД через модели).
disyukvitaliy
Здравствуйте
Подобную проблему также можно решить и с помощью модуля ActiveModel::Model
Вы можете сбилдить объект, передав все аргументы в new
Либо же наполнять объект в процессе
В тот момент, когда вы решаете что новые данные уже не поступят, вы можете вызвать на объекте #validate!.. Теперь вы можете быть уверенным, что объект содержит корректные данные
Чтобы разработчик мог понять, из чего состоит объект, он может просмотреть правила валидации определенные в классе
Если жы мы хотим быть уверенными в том, что невалидного объекта существовать не должно, мы можем сделать следующее
Ну, или же на крайний случай вот так (правда, я не любитель переопределять такие штуки)
P.S. для ad_type я использовал простой inclusion. Естественно это не полноценная замена, например, enum-а
Надеюсь идея кому-то пригодится, хотя она и не нова. В инете много статей на тему использования form object-ов или чего-то подобного.