Хабрахабр

[Из песочницы] Решаем проблемы типов данных в Ruby или Make data reliable again

В этой статье я хотел бы рассказать о том, какие проблемы с типами данных есть в Ruby, с какими проблемами столкнулся я, как их можно решить и как сделать так, чтобы на данные, с которыми мы работаем, можно было положиться.

Крайне удачным мне видится определение этого термина, которое можно найти в HaskellWiki.
image
Для начала стоит определиться с тем, что такое типы данных.

Типы — это то, как вы описываете данные, с которыми будет работать ваша программа.

Но что же не так с типами данных в Ruby? Чтобы описать проблему комплексно, я хотел бы выделить несколько причин.

Причина 1. Проблемы самого Ruby

Как известно, в Ruby используется строгая динамическая типизация с поддержкой т.н. утиной типизации. Что это означает?

Поэтому следующий листинг кода в Ruby закончится ошибкой: Строгая типизация требует явного приведения типов и не производит этого приведения самостоятельно, как это происходит, например, в JavaScript.

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
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 мы избавились от проблем, связанных с отсутствием проверки типов данных и отсутствием описания данных. Теперь любой человек, посмотрев на эту структуру и на описание типов, используемых в ней, может понять, какие значения может принимать каждый из атрибутов.

И для тех случаев, когда корректность данных имеет существенное значение (а в случае со сбором DAU у нас дела обстоят именно так), на мой взгляд, получить исключение куда лучше, чем потом пытаться разобраться с невалидными данными. Что же касается проблемы с объектами в невалидном состоянии, то dry-struct избавляет нас и от этого: если мы попытаемся проинициализировать структуру невалидными значениями, то в результате мы получим ошибку. К тому же, если процесс тестирования у вас хорошо налажен (а у нас все именно так), то с большой вероятностью до production-окружения код, генерирующий подобные ошибки, просто-напросто не дойдет.

Благодаря этим двум факторам мы получаем гарантию того, что объекты таких структур будут находиться в валидном состоянии и на эти данные вы можете спокойно положиться в любом другом месте вашей системы. И помимо невозможности инициализировать объекты в невалидном состоянии, dry-struct также не позволяет изменять объекты после инициализации.

Итог

В данной статье я попытался описать те проблемы, с которыми вы можете столкнуться при работе с данными в Ruby, а также рассказать об инструментах, которыми мы используем для решения этих проблем. И благодаря внедрению этих инструментов я абсолютно перестал переживать о корректности данных, с которыми мы работаем. Разве это не прекрасно? Разве не в этом цель любого инструмента — облегчить нашу жизнь в каком-то ее аспекте? И на мой взгляд, dry-types и dry-struct в этом со своей задачей отлично справляются!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть