В этой статье я хотел бы рассказать о том, какие проблемы с типами данных есть в 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
в этом со своей задачей отлично справляются!
Автор: evgenygarl