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

Комментарии (4)


  1. disyukvitaliy
    15.12.2018 08:08

    Здравствуйте
    Подобную проблему также можно решить и с помощью модуля ActiveModel::Model

    class DailyActiveUsersData
      include ActiveModel::Model
    
      attr_accessor :app_id, :ad_type, :first_request_date
    
      validates :app_id, :ad_type, :first_request_date, presence: true
      validates :app_id, numericality: { greater_than: 0 }
      validates :ad_type, inclusion: { in: [:android, :ios] }
      validates :first_request_date, date: true
    end
    

    Вы можете сбилдить объект, передав все аргументы в new
    DailyActiveUsersData.new(app_id: 1, ad_type: :ios, first_request_date: Time.now)
    

    Либо же наполнять объект в процессе
    data = DailyActiveUsersData.new
    data.app_id = 1
    data.ad_type = :ios
    

    В тот момент, когда вы решаете что новые данные уже не поступят, вы можете вызвать на объекте #validate!.. Теперь вы можете быть уверенным, что объект содержит корректные данные
    data.validate!
    

    Чтобы разработчик мог понять, из чего состоит объект, он может просмотреть правила валидации определенные в классе
    Если жы мы хотим быть уверенными в том, что невалидного объекта существовать не должно, мы можем сделать следующее
    class DailyActiveUsersData
      include ActiveModel::Model
    
      def self.new!(attrs)
        object = new(attrs)
        object.validate!
        object
      end
    end
    

    Ну, или же на крайний случай вот так (правда, я не любитель переопределять такие штуки)
    class DailyActiveUsersData
      include ActiveModel::Model
    
      def initialize(attrs)
        super(attrs)
        validate!
      end
    end
    

    P.S. для ad_type я использовал простой inclusion. Естественно это не полноценная замена, например, enum-а
    Надеюсь идея кому-то пригодится, хотя она и не нова. В инете много статей на тему использования form object-ов или чего-то подобного.


  1. sl_bug
    15.12.2018 12:55

    # если передать не корректное значение, получим ошибку
    Types::PlatformId['windows']
    # => Dry::Types::ConstraintError
    


    а fetch зачем придумали? нафигачат костылей нафиг не нужных никому и потом пытайся сообразить как с этим говном работать.


  1. 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
    записано
    Types::Strict::Integer.constrained(gt: 0)
    не дает нам никаких гарантий, что у заказа есть Client, потому что не факт, что у нас есть клиент с таким id. То есть типами мы все равно не избавимся от невалидных объектов.

    А теперь что касается типов
    Если хочется использовать типы, то 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
    


    1. k4ir05
      17.12.2018 05:25

      1. Потому что это нам требуется для решения наших задач. Например при создании объекта и для возвращения ошибок валидации. Создается объект, проходит его валидация, если объект не валиден, то мы возвращаем невалидный объект с ошибками в контроллер и рендер формы.
      Из данных объекта мы проставляем значения в полях, из объекта ошибок мы выводим ошибки.
      Т.е. например если бы наш объект Order не мог бы находиться в невалидном состоянии, нам бы пришлось создать еще какой-то класс NonValidOrder и периодически мы рендерили бы форму/json из Order, а периодически из NonValidOrder, и пришлось бы еще делать какие-то механизмы превращения одного в другое. Зачем, если мы всегда можно вызывать метод valid? везде где оно требуется.

      А если валидация в разных ситуациях различается? Или на форме потребуются производные данные (например, опции для поля выбора или форма будет содержать данные нескольких моделей)? Да и слишком много ответсвенности возникает у модели. Не лучше ли для формы рендеринга формы использовать отдельный объект (form object)? А то получается интерфейс тесно связанный с бизнес-моделью (да и, как правило, с БД через модели).