Pull to refresh

Решаем проблемы типов данных в Ruby или Make data reliable again

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

image

Для начала стоит определиться с тем, что такое типы данных. Крайне удачным мне видится определение этого термина, которое можно найти в 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 в этом со своей задачей отлично справляются!
Tags:
Hubs:
+14
Comments 9
Comments Comments 9

Articles