Выбор альтернативы технологического стека: взлеты и падения

Опубликовано: 2022-03-11

Если веб-приложение большое и достаточно старое, может наступить момент, когда вам нужно будет разбить его на более мелкие, изолированные части и извлечь из него сервисы, некоторые из которых будут более независимыми, чем другие. Некоторые из причин, которые могут побудить к такому решению, включают: сокращение времени на выполнение тестов, возможность независимого развертывания различных частей приложения или обеспечение соблюдения границ между подсистемами. Извлечение сервиса требует от разработчиков программного обеспечения принятия многих жизненно важных решений, и одно из них — какой технологический стек использовать для нового сервиса.

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

Служба Toptal Chronicles — это приложение, которое обрабатывает все действия пользователя, выполняемые на платформе Toptal. Действия — это, по сути, записи в журнале. Когда пользователь что-то делает (например, публикует сообщение в блоге, утверждает задание и т. д.), создается новая запись в журнале.

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

Наше решение выделить сервис и улучшить стек обусловлено рядом причин:

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

Таблица действий - таблицы базы данных

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

Обзор архитектуры

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

  • Потребитель Kafka — это очень тонкий потребитель Kafka на основе Karafka для сообщений о создании записей. Он помещает все полученные сообщения в Sidekiq.
  • Рабочий процесс Sidekiq — это рабочий процесс, который обрабатывает сообщения Kafka и создает записи в таблице базы данных.
  • Конечные точки GraphQL:
    • Общедоступная конечная точка предоставляет API поиска записей, который используется для различных функций платформы (например, для отображения всплывающих подсказок комментариев на кнопках скрининга или отображения истории изменений заданий).
    • Внутренняя конечная точка позволяет создавать правила и шаблоны тегов на основе миграции данных.

Раньше Хроники подключались к двум разным базам данных:

  • Собственная база данных (где мы храним правила тегов и шаблоны)
  • База данных платформы (где мы храним выполненные пользователем действия и их теги и пометки)

В процессе извлечения приложения мы перенесли данные из базы данных платформы и отключили соединение с платформой.

Начальный план

Изначально мы решили использовать Hanami и всю экосистему, которую она предоставляет по умолчанию (модель hanami, поддерживаемая ROM.rb, dry-rb, hanami-newrelic и т. д.). Следование «стандартному» способу ведения дел обещало нам низкий уровень трения, высокую скорость реализации и очень хорошую «гуглимость» любых проблем, с которыми мы можем столкнуться. Кроме того, экосистема ханами является зрелой и популярной, а библиотека тщательно поддерживается уважаемыми членами сообщества Ruby.

Более того, большая часть системы уже была реализована на стороне платформы (например, конечная точка GraphQL Entry Search и операция CreateEntry), поэтому мы планировали скопировать большую часть кода с платформы на Chronicles как есть, без каких-либо изменений. Это также было одной из основных причин, по которой мы отказались от Эликсира, поскольку Эликсир этого не допускал.

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

Когда план идет на юг

Хотя мы изо всех сил старались придерживаться плана, вскоре он был сорван по ряду причин. Одним из них было отсутствие у нас опыта работы с выбранным стеком, за которым последовали настоящие проблемы с самим стеком, а затем была наша нестандартная установка (две базы данных). В конце концов, мы решили избавиться от hanami-model , а затем и от самой Ханами, заменив ее Синатрой.

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

Несовместимые зависимости

Извлечение Chronicles началось в июне 2019 года, и тогда Hanami не был совместим с последними версиями драгоценных камней dry-rb. А именно, последняя на тот момент версия Hanami (1.3.1) поддерживала только сухую проверку 0.12, а мы хотели сухую проверку 1.0.0. Мы планировали использовать контракты с сухой проверкой, которые появились только в 1.0.0.

Кроме того, Kafka 1.2 несовместима с dry gems, поэтому мы использовали его версию из репозитория. В настоящее время мы используем версию 1.3.0.rc1, которая зависит от новейших сухих драгоценных камней.

Ненужные зависимости

Кроме того, гем Hanami включал слишком много зависимостей, которые мы не планировали использовать, таких как hanami-cli , hanami-assets , hanami-mailer , hanami-view и даже hanami-controller . Также, посмотрев ридми ханами-модели, стало понятно, что по умолчанию она поддерживает только одну базу данных. С другой стороны, ROM.rb, на котором основана hanami-model , изначально поддерживает конфигурации с несколькими базами данных.

В целом, ханами в целом и hanami-model в частности выглядели ненужным уровнем абстракции.

Итак, через 10 дней после того, как мы сделали первый осмысленный пиар «Хроникам», мы полностью заменили ханами на Синатру. Мы могли бы использовать и чистый Rack, потому что нам не нужна сложная маршрутизация (у нас есть четыре «статических» конечных точки — две конечных точки GraphQL, конечная точка /ping и веб-интерфейс sidekiq), но мы решили не заходить слишком далеко. Синатра нас вполне устраивал. Если вы хотите узнать больше, ознакомьтесь с нашим руководством по Sinatra and Sequel.

Непонимание сухой схемы и сухой проверки

Нам потребовалось некоторое время и много проб и ошибок, чтобы понять, как правильно «приготовить» сухую валидацию.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

В приведенном выше фрагменте параметр url определяется несколькими немного разными способами. Некоторые определения эквивалентны, а другие не имеют никакого смысла. Вначале мы не могли определить разницу между всеми этими определениями, так как не понимали их полностью. В результате первая версия наших контрактов была довольно сумбурной. Со временем мы научились правильно читать и писать СУХИЕ контракты, и теперь они выглядят последовательно и элегантно — на самом деле, не только элегантно, но и просто красиво. Мы даже проверяем конфигурацию приложения с помощью контрактов.

Проблемы с ROM.rb и Sequel

ROM.rb и Sequel отличаются от ActiveRecord, что неудивительно. Наша первоначальная идея о том, что мы сможем копировать и вставлять большую часть кода с платформы, провалилась. Проблема в том, что платформа была очень загружена дополненной реальностью, поэтому почти все пришлось переписать в ROM/Sequel. Нам удалось скопировать только небольшие части кода, не зависящие от фреймворка. По пути мы столкнулись с несколькими неприятными проблемами и некоторыми ошибками.

Фильтрация по подзапросу

Например, мне потребовалось несколько часов, чтобы понять, как сделать подзапрос в ROM.rb/Sequel. Это то, что я бы написал, даже не просыпаясь, в Rails: scope.where(sequence_code: subquery ). Однако в «Сиквеле» все оказалось не так просто.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

Таким образом, вместо простой однострочной строки, такой как base_query.where(sequence_code: bild_subquery(params)) , у нас должна быть дюжина строк с нетривиальным кодом, необработанными фрагментами SQL и многострочным комментарием, объясняющим, что вызвало этот досадный случай. раздуваться.

Ассоциации с нетривиальными полями соединения

Отношение entry (таблица performed_actions ) имеет основное поле id . Однако для объединения с таблицами *taggings используется столбец sequence_code . В ActiveRecord это выражается достаточно просто:

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

То же самое можно записать и в ПЗУ.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

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

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

Нам повезло, что типы id и sequence_code разные, поэтому PG выдает ошибку типа. Если бы типы были одинаковыми, кто знает, сколько часов я потратил бы на отладку этого.

Итак, entries.join(:access_taggings) не работает. Что, если мы укажем условие соединения явно? Как и в entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , как предлагает официальная документация.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

Теперь он почему-то думает, что :access_taggings — это имя таблицы. Хорошо, давайте заменим его реальным именем таблицы.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

Наконец, он что-то вернул и не потерпел неудачу, хотя и получил дырявую абстракцию. Имя таблицы не должно просачиваться в код приложения.

Интерполяция параметров SQL

В поиске Chronicles есть функция, которая позволяет пользователям искать по полезной нагрузке. Запрос выглядит следующим образом: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , где path всегда представляет собой массив строк, а значение любое допустимое значение JSON.

В ActiveRecord это выглядит так:

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

В Sequel мне не удалось правильно интерполировать :path , поэтому мне пришлось прибегнуть к этому:

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

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

Тихая магия ROM-фабрики

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

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

Нет, ожидание не обмануто, ожидание прекрасно.

Проблема в том, что вторая строка завершается с ошибкой уникальной проверки ограничения. Причина в том, что action не является атрибутом модели Action . Настоящее имя — action_name , поэтому правильный способ создания действий должен выглядеть так:

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

Поскольку атрибут с опечаткой был проигнорирован, он возвращается к значению по умолчанию, указанному в фабрике ( action_name { 'created' } ), и у нас есть уникальное нарушение ограничения, поскольку мы пытаемся создать два идентичных действия. Нам приходилось решать этот вопрос несколько раз, что оказалось обременительным.

К счастью, это было исправлено в 0.9.0. Dependabot автоматически отправил нам запрос на извлечение с обновлением библиотеки, которое мы объединили после исправления нескольких опечаток в атрибутах, которые были у нас в тестах.

Общая эргономика

Этим все сказано:

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

А в более сложных примерах разница еще больше.

Хорошие части

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

Тест скорости

Локальный запуск всего набора тестов занимает 5-10 секунд, и столько же времени для RuboCop. Время CI намного больше (3-4 минуты), но это не проблема, потому что мы все равно можем запускать все локально, благодаря чему вероятность сбоя в CI гораздо меньше.

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

Время развертывания

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

Производительность приложений

Самая требовательная к производительности часть Chronicles — поиск записей. На данный момент в серверной части платформы есть около 20 мест, которые извлекают записи истории из Хроник. Это означает, что время отклика Chronicles вносит свой вклад в 60-секундный бюджет времени отклика платформы, поэтому Chronicles должен быть быстрым, что и есть.

Несмотря на огромный размер лога действий (30 миллионов строк и их количество растет), среднее время отклика составляет менее 100 мс. Взгляните на эту красивую диаграмму:

Диаграмма производительности приложений

В среднем 80-90% времени приложения тратится на базу данных. Вот как должна выглядеть правильная диаграмма производительности.

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

Структура

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

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

Заключительные слова

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

Несмотря на то, что мы с самого начала старались принимать очень взвешенные и просчитанные решения — мы выбрали стандартный стек Hanami — нам пришлось пересмотреть наш стек по ходу дела из-за нестандартных технических требований проекта. В итоге мы остановились на Sinatra и стеке на основе DRY.

Выбрали бы мы Hanami снова, если бы нам пришлось извлекать новое приложение? Вероятно, да. Теперь мы знаем больше о библиотеке, ее плюсах и минусах, поэтому можем принимать более обоснованные решения с самого начала любого нового проекта. Однако мы также серьезно рассматриваем возможность использования простого приложения Sinatra/DRY.rb.

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