Исследование dry-rb – Интуиция результатов

dry-rb – это интересный набор инструментов и библиотек, но их использование может быть не совсем очевидным. Зачем добавлять эти библиотеки, если, возможно, есть гораздо более простые методы, которых вполне достаточно? Что стоит за абстракциями, и какие преимущества они могут дать нам, что может послужить аргументом в пользу их включения?

Именно об этом и пойдет речь в этой серии. Мы рассмотрим еще несколько нетривиальных примеров и объясним, зачем нужны эти инструменты, потому что наверняка есть случаи, когда возникающая сложность становится очень выгодной.

Однако для того, чтобы создать интуицию, нам сначала нужно изучить немного теории.

Формы и HTTP-ответы

Возможно, одной из самых мощных концепций в программировании, которую мы так часто принимаем как должное, является HTTP-ответ и коды ответов HTTP. При использовании конечных точек REST/JSON мы можем быть (достаточно) уверены, что получим ответ, который будет выглядеть примерно так:

HTTP::Response(body: "<json content>", code: 200)
Войти в полноэкранный режим Выход из полноэкранного режима

Назовите 100 различных API, использующих REST и JSON, и вы сможете назвать 100, которые очень точно следуют этому соглашению. Эти ответы имеют очень четкую “форму”, которая позволяет нам делать разумные предположения о том, как мы можем действовать в соответствии с ними, как, например, в случае с Rack Response:

response = get_data # Rack::Response returned
response_body = JSON.parse(response.body)

if response.successful? # Rack::Response::Helpers
  process(response_body)
else
  handle_errors(response_body)
end
Войти в полноэкранный режим Выйти из полноэкранного режима

Это исключительно мощный механизм, лежащий в основе очень многого в современном вебе, но при этом так часто воспринимаемый как должное. В этой единственной идее ответа заключены как данные, так и контекст для этих данных, например, был ли запрос успешным, ошибкой, может быть, плохой формой, кто знает, но она последовательна (пока мы играем по ее правилам).

Нарушитель правил

Теперь действительно существует опасность, о которой говорилось выше, что это предполагает разум и игру по набору правил, которые позволяют нам делать такие предположения. Существуют некоторые API, которые могут сделать что-то вроде этого:

HTTP::Response(body: "ERROR! IT BLEW UP!", code: 200)
Войти в полноэкранный режим Выйти из полноэкранного режима

Сделав это, мы нарушили разумные ожидания и потеряли доверие к интерфейсу, поэтому мы должны быть бдительны в соблюдении этих правил, поскольку возможности этого интерфейса стоят дополнительных затрат на его принятие.

Без контекста

Предположим, что на данный момент мы отказались от кодов, и ответ содержит только тело. Прежде чем продолжить, подумайте, почему это может существенно усложнить использование.

Понятно? Что ж, давайте рассмотрим несколько последствий удаления дополнительного контекста данных.

Как вы определяете успех? Ошибку? Где-то в теле? Что если ответ пустой, nil, falsy или любой другой формы? JSON вполне может принять любую из этих форм:

# Inline status
{ content: "<data>", status: "success" }

# Empty response
{  }

# No response
""

# String error
"Error: Something went boom"

# Number code
123456
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь возьмите это потенциальное несоответствие того, как мы определяем успех, и разложите его по всем API в мире, которые (в основном) хорошо играют по стандартам REST и JSON (о да, у JSON тоже есть правила), и вы увидите, как это быстро превращается в головную боль.

С этого момента каждый API, который вы используете, работает по своим собственным уникальным правилам, и в результате затраты на реализацию обработки ошибок и успехов ложатся на пользователя, а не на сервис. Как же больно работать в таком мире, особенно если вы используете сразу несколько API и создаете обертки для каждого из них, чтобы они хорошо работали в вашем собственном домене.

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

Межфирменное взаимодействие

Чем больше систем связаны друг с другом и чем больше они требуют глубокого знания базовых технологий для работы с ними, тем сложнее вам будет взаимодействовать.

Если компания создаст новый псевдостандарт поверх HTTP, подобный приведенному выше 200 Error bit, то теперь они напрямую связали весь ваш код со своей собственной системой обработки ошибок.

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

Я дам вам подсказку к следующему важному разделу: Представьте себе это внутри вашего собственного приложения/сервиса, и насколько это может быть неприятно.

Согласованность

Хотя принятие стандарта HTTP Response действительно связано с определенными затратами, мы, несомненно, получаем выгоду от игры по его правилам. Именно поэтому веб-серверы, включая Rack, используют его.

Неважно, как выглядят базовые данные, но важно, что мы можем ожидать последовательного ответа от любого разумного API, придерживающегося стандарта в той же форме и согласного с тем же языком, что он означает, когда мы говорим “успех”.

Зайдите в любую компанию, поработайте с любым API, используйте любой API или что-то еще в этой области, и вы, скорее всего, обнаружите очень знакомые вещи. Это мощно, невероятно мощно, и все же мы воспринимаем это как должное.

API – это не только внешняя среда

Все это хорошо, но как это относится к нашим приложениям? Почему это важно и почему мы должны заботиться о согласованности форм и HTTP-ответов?

Очень просто, потому что API означает публичный интерфейс, и это не обязательно относится к внешним интерфейсам. Каждый публичный метод – это API для вашего приложения, класса, библиотеки или чего угодно.

Задумайтесь на мгновение над вышесказанным и подумайте, как вы можете отразить неудачу в своем приложении, а затем продолжайте.

Что значит потерпеть неудачу?

Что вы придумали? Вероятно, несколько различных способов, каждый из которых имеет огромное количество предположений и требований для работы с ними. Давайте быстро рассмотрим несколько:

# Exceptions - String based
raise "It failed!"

# Exceptions - Class based
raise MyCustomSpecificUsefulError, "Something went wrong"

# Returning false or nil (what if falsy is valid? That's fun)
false
nil

# Reasonable empty defaults
""
[]
{}

# ...and probably many more
Войти в полноэкранный режим Выйти из полноэкранного режима

Еще хуже, когда вы понимаете, что та же самая головная боль относится и к тому, что значит добиться успеха, но мы пока пропустим этот вопрос.

Вы можете видеть, как каждое из этих значений, вероятно, имеет очень интересные последствия для кода, который полагается на этот интерфейс. Теперь вам нужно знать обо всех лежащих в основе исключениях, или, возможно, о том, действительны ли возвраты falsy, или, в более полезном случае, как в методах Enumerable, вы получаете обратно разумный пустой результат, как, например, в этом случае:

[2, 4, 6].select { |v| v.odd? } # => []
Войти в полноэкранный режим Выйти из полноэкранного режима

Последний вариант особенно интересен, поскольку мы можем продолжить цепочку вещей на конце этого select, и если у него действительно есть данные, то он продолжит движение по этому конвейеру. Звучит полезно, не так ли? Мы вернемся к тому, почему это так важно, но сначала…

Обработка исключений

Что если бы select вместо этого вызывал исключение, как показано ниже:

# Please don't actually do this to `select`:
module Enumerable
  def select(&block)
    found_elements = []
    self.each do |element|
      # If calling the block on the element is truthy, add it
      found_elements.push(element) if block.call(element)
    end

    raise NoResultsHere, "No data!" if found_elements.empty?
    found_elements
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

Можете ли вы представить себе работу с этим? Скорее всего, вам пришлось бы делать что-то вроде этого:

begin
    [1, 2, 3, 4].select { |v| v > 5 }
rescue NoResultsHere
  []
end
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь заставим каждый метод Enumerable делать это вместо этого, и мы навяжем эту обработку ошибок потребителям, что сделает работу с интерфейсом гораздо более неприятной.

Сила Enumerable

Тот факт, что select и другие методы Enumerable продолжают возвращать объекты в форме Enumerable, на самом деле очень полезен. Это даже позволяет нам сделать что-то вроде этого:

(1..100)
  .select { |v| v.even? }
  .map { |v| v * 5 }
  .group_by { |v| v > 50 }
  .transform_values { |vs| vs.sum }
# => {false=>150, true=>12600}
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы получаете все это, потому что Enumerable договаривается о том, какую форму объекта возвращать, позволяя вам выстраивать цепочки по своему усмотрению и даже обходить неудачи для достижения результата.

У всей этой концепции разумных умолчаний есть имя, и это имя – “Идентичность”. Это даже закон, причудливый закон, но в сочетании с двумя другими законами он становится гораздо интереснее.

Если вы еще не сделали этого, остановитесь и прочитайте Introducing Patterns for Parallelism for Ruby. Я обещаю, что вы найдете его весьма интересным с точки зрения того, куда ваша интуиция может в данный момент направиться с этими фигурами, о которых я говорил.

У этих форм есть имя, и это имя – “моноид”. Честно говоря, название не так важно, как интуиция, что эти однотипные вещи можно комбинировать и связывать вместе, как мы захотим. Как только вы это поняли, открываются очень интересные возможности.

А это? Это, друзья мои, то, где dry-rb выходит на сцену с типом Result.

Представляем нашего друга Результат

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

Сцена

Давайте быстро подготовим сцену. Вы работаете в большом Rails-приложении, которое использует что-то вроде Packwerk для четкого разграничения различных пакетов в вашем Rails-монолите. Допустим, у вас 100 пакетов, что не является чем-то необычным для больших приложений.

Теперь возьмите все 100 этих пакетов и дайте им публичные интерфейсы с минимальной связью. Мы скажем, что это происходит в пакете в файле типа public_api.rb или в чем-то подобном, в общем, не важно. Важно то, что есть одна точка входа в каждый пакет.

Открыв один из этих публичных API, мы можем найти что-то вроде этого:

# packs/app/public/service_name.rb
class ServiceName
  class << self
    def offering_a; end
    def offering_b; end
    def offering_c; end
    def offering_d; end
    def offering_e; end
  end
end
Войти в полноэкранный режим Выход из полноэкранного режима

Все они проникают во внутренние компоненты пакета, чтобы сделать очень важные деловые вещи, возможно, немного ActiveRecord, и вернуть результат.

Помните тот кусочек об определении неудачи, а также подсказку об определении успеха? О да:

# packs/app/public/service_name.rb
class ServiceName
  class << self
    # Exception
    def offering_a(id)
      ServiceModel.find(id)
    end

    # Nil
    def offering_b(id)
      ServiceModel.find_by(id: id)
    end

    # False
    def offering_c(id)
      ServiceModel.find_by(id: id) || false
    end

    # Empty Collection
    def offering_d(*ids)
      ServiceModel.where(id: ids)
    end

    # Invalid Object
    def offering_e(**model_info)
      ServiceModel.create!(**model_info)
    end
  end
end
Войти в полноэкранный режим Выход из полноэкранного режима

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

Теперь каждая частица этой обработки ошибок или сбоев накладывается на потребителя, и вы возвращаетесь к началу пути со связью, несмотря на наличие пакетов и очень жестких границ между ними. Даже прямое возвращение объектов или коллекций ActiveRecord вносит сцепление, но это уже другой вопрос, которого мы пока не будем касаться.

Что, если бы вместо этого они договорились о едином определении и форме того, что означает успех или неудачу? Вот тут-то и приходит на помощь Result.

Тип результата

dry-rb представляет очень интересное решение этой проблемы под названием Result, а также очень красивую документацию, которую я советую вам подождать, прежде чем читать, которую вы можете найти здесь. Ах, и не обращайте внимания на это слово “Монада”, оно не важно в данный момент.

Оно дает нам идею Success и Failure, двух частей, составляющих более крупный тип Result:

require "dry/monads"

extend Dry::Monads[:result]

result = if foo > bar
  Success(10)
else
  Failure("wrong")
end
Вход в полноэкранный режим Выход из полноэкранного режима

Думайте о Success и Failure почти как о дополнительном контексте, предоставляемом кодом состояния HTTP Responses. Это обертка, которая четко указывает нам, считается ли что-то успешным, или оно потерпело неудачу.

Как и Enumerable, мы можем объединять типы Result в цепочки, потому что их разумным значением по умолчанию все еще является тип Result:

require "dry/monads"

extend Dry::Monads[:result]

# Pretend it's a DB of some sort
IDS = { "a" => "Red", "b" => "Blue" }

def offering_a(id)
  return Failure("ID not found: #{id}") unless IDS.key?(id)

  Success(IDS[id])
end

offering_a("a")
# => Success("Red")

offering_a("nope")
# => Failure("ID not found: nope")

# Remember Enumerable? What happens if we chain each of these?

offering_a("a").fmap { |name| "We found #{name}!" }
# => Success("We found Red!")

offering_a("nope").fmap { |name| "We found #{name}!" }
# => Failure("ID not found: nope")
Войти в полноэкранный режим Выход из полноэкранного режима

Для типов Result мы можем вызвать специальный метод fmap, чтобы сделать что-то со значением внутри, но если это сбой? Это ничего не делает, сбой сохраняется, и мы получаем его в конце.

Однако это может быть гораздо интереснее, если применить его к подбору шаблонов в Ruby 2.7+:

result = offering_a("a")

case result
in Success("Red" | "Blue") then "Found who we were looking for"
in Success then "Not who we expected, but still ok"
in Failure(/_why/) then "He's still in our hearts though"
in Failure then "I give, you win"
end
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы можем добраться до каждого из этих значений, основываясь на том, что это тип Result, а на самом деле нам даже не нужно добираться:

result = offering_a("a")
result.value_or("Yellow")
Войти в полноэкранный режим Выход из полноэкранного режима

Хотя, возможно, вам нужна более привычная ветвь if:

if offering_a.success?
Войти в полноэкранный режим Выход из полноэкранного режима

Вы можете сделать довольно много с этим типом концепции, но, как и в случае с HTTP Response, потому что мы договорились об этой форме, и если все наши сервисы хорошо с ней играют, весь код потребителя может сделать те же предположения и обработать их тем же способом.

Эй, если все формы совпадают, это даже означает, что мы можем комбинировать их или делать другие интересные трюки, например, как Promise или Future работает с async в Javascript, или как Task может иметь транзакционный список выполнения, или, возможно, Validated объединяет несколько ошибок при попытке создать что-то не совсем правильное.

Коллективное умственное бремя, облегчающее навигацию по кодовой базе, со временем приносит дивиденды, точно так же, как вы, скорее всего, никогда не тратите много времени на размышления о HTTP Responses.

Хороший код полезен, но отличный код прозрачен. HTTP-ответы прозрачны, и я бы сказал, что типы результатов тоже вполне могут быть прозрачными.

Да, но базовые данные

Визг записи

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

Если вы пытаетесь сохранить четкие границы между пакетами, вероятно, не стоит напрямую передавать им объект ActiveRecord, который все еще может напрямую обращаться к базе данных, или который не может быть сериализован, или, возможно, даже проверен вне контекста базы данных. Это только ветвь успеха.

Ветка неудач еще более интересна. Мы теперь делаем строковые ошибки? А как же контекст, обратная трассировка, с какими параметрами она была вызвана, или любая другая прекрасная мета-информация? Конечно, Result поставляется с трассировкой неудач, но не суть.

Стандарты

Чем больше приложение, тем больше стандарты начинают становиться фактором, увеличивающим силу, а не раздражающими правилами, которым кто-то в инфраструктуре сказал вам следовать. Осталось невысказанным то, что определить и создать эти стандарты, особенно для таких вещей, как ошибки, очень и очень сложно.

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

Для меня такие вещи, как Promise, HTTP Response (и даже Request), Result, и другие подобные типы являются отличными краями для начала.

Подведение итогов

Rails не был создан за один день, как и ваше приложение. Это непрерывный и развивающийся процесс, в котором мы учимся и растем, и с нашим пониманием меняется и наше мнение. Если что и можно посоветовать, так это простоту изменений, простоту восстановления в случае неудачи и минимизацию того, как много системы знают друг о друге.

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

dry-rb, как мне кажется, представляет собой набор инструментов, которые определяют границы, способные выдержать вес больших приложений и уменьшить сцепление и сложность. Бесплатны ли они? Нет, но ничего бесплатного не бывает. Стоят ли они того? Решать вам, но я бы сказал, что да.

В этой серии я еще рассмотрю библиотеки dry-rb и использую схожий подход – строить пример, а не рассказывать, почему все так здорово или удивительно. Мы здесь не для блеска, мы здесь для решения проблем, и, возможно, это решит ваши.

Оцените статью
Procodings.ru
Добавить комментарий