Будуємо платформу керування платіжними провайдерами без DevOps і готових рішень
Привіт! На звʼязку Володимир Корнієнко та Данило Бурковський, Back-end-інженери в Headway. Це EdTech компанія, що створює продукти з мікронавчання, якими користуються понад 110 млн людей. Ми маємо шість та п’ять років досвіду відповідно, останні два з них займалися розробкою власної платіжної платформи для продуктів Headway.
У цьому матеріалі ми поділимося, як нам вдалося створити платформне рішення для обробки платежів та менеджменту життєвого циклу підписки мільйонів користувачів на базі хмарних компонентів GCP. Як за допомогою event-driven підходу досягти безлімітного масштабування платіжних провайдерів та аналітичних сервісів. А також як за відсутності DevOps керувати інфраструктурою з допомогою Terraform.
Ми розповімо про загальну архітектуру, а потім декомпозуємо її та опишемо роботу ключових компонентів.
Контекст: трансформація у платформну команду і ключові виклики
Флагманські продукти компанії — застосунки Headway та Impulse. Також до екосистеми входять продукти Nibble та AddMile. Найближчим часом ми готуємо запуск ще кількох R&D проєктів. Зі зростанням бізнесу Back-end-команда також трансформувалася: ми стали командою платформи, яка надає сервісні послуги різним продуктам компанії.
З таким форматом роботи зʼявилися нові виклики:
- Керування інфраструктурою.
У нас немає DevOps-спеціалістів, тому налаштуванням інфраструктури в Google Cloud займається команда платформи. Наприклад, коли потрібно створити новий проєкт в GCP або додати новий ресурс (це може бути база даних, віртуальна машина або щось інше), ми робимо це за допомогою IaC-інструментів у Terraform. Також у нас є окремий репозиторій, де описані доступи до проєктів для кожного співробітника з використанням кастомних або базових ролей у GCP. Це реалізовано за допомогою коду Terraform.
- Перевикористані рішення на рівні коду або сервісів.
У нас є чимало проєктів, для яких можна перевикористовувати цілі домени коду. Щоби організувати роботу, ми спочатку створили репозиторій, куди скидали всі спільні пакети. Проте це була погана ідея: код містив багато бізнес-логіки, яка змінювалася і не завжди метчилася між проєктами. Також ці пакети були тісно пов’язані між собою, і доводилось імпортувати безліч методів, полів, які взагалі були не потрібні.
Другим кроком було створення репозиторію BackendLibs, куди ми складали модулі, які вже не містили бізнес-логіки, а також різні обгортки до відомих бібліотек та клієнти для роботи з різними інтеграціями (Amplituda, Facebook, TikTok, поштові сервіси та багато іншого). Це рішення пришвидшило розробку, проте проблема перевикористання коду на рівні доменів залишилася. Зокрема, робота з платежами.
Стара архітектура роботи з платежами
Флагманський продукт Headway монетизується через підписки й паблішиться в App Store, Play Market, а також у вебі, кожен з яких має власні платіжні провайдери. Загальна система працювала через Google Cloud Functions. Також були воркери на віртуальних машинах, які керувалися супервізором. Основна база даних — Firestore, для аналітики використовували BigQuery. Нижче — схема роботи з App Store, яка ілюструє, наскільки старе рішення було важким, заплутаним та складним у підтримці.
У книзі «Чиста архітектура» Роберт Мартін пише: якщо не приділяти достатньо уваги архітектурі, з часом вашу систему буде майже неможливо підтримувати. Ми переконалися в цьому на власному досвіді.
З якими проблемами ми стикнулися:
- Розробка нових фічей займала чимало часу через технічний борг і звʼязність компонентів — зміни в одному призводили до змін в інших, і так далі. Зокрема, коли потрібно було додати новий функціонал, ми мали вносити зміни у велику кількість воркерів і хмарних функцій та переналаштовувати їх. Також для кожного з них потрібно було окремо переглядати логи, що ускладнювало відстеження і діагностику проблем.
- Система була немасштабованою. Водночас у нас відбувалося стрімке зростання кількості користувачів та об’ємів, які було складно підтримувати.
- Постійні баги було складно знаходити навіть розробникам, які вже тривалий час працювали з кодом.
Так кількість покупок в App Store Connect часто не сходилася з даними наших сервісів. Маркетологи не раз скаржилися на те, що кількість відправлених івентів відрізнялася: для одного сервісу відправили N, а для іншого N+1 або N-1. Команда розробки не могла пояснити ці розбіжності, оскільки сама не мала точної інформації.
Восени 2021 року стався сумнозвісний збій API Facebook, до якого ми були не готові: декілька днів парсили логи та намагалися зібрати івенти, частину з яких було втрачено.
Пошук нової архітектури
Ми розуміли, що потребуємо великих змін, і рефакторингом тут не обійтися. Нам був потрібен новий платіжний сервіс. Ми почали вивчати досвід різних компаній і звернули увагу на Revenue Cat та Adapty: перечитали документацію, поклацали адмінку та перейняли деякі їхні підходи, зокрема з уніфікації івентів.
Нова система була створена за допомогою сервісів Google Cloud Platform (Cloud Run, PostgreSQL, Pub/Sub, Load Balancer, Terraform), у яких ми вже мали сильну експертизу.
Ключові моменти нової архітектури:
- Documentation Driven Development
Спершу ми мали загальне уявлення, що потрібно зробити, і одразу почали писати пакети для обробки нотифікацій і налаштовували інфраструктуру за допомогою Terraform. Ми робили перші коміти та пул-реквести. Проте вони довго не затверджувалися, і процес просувався повільно. Тоді вирішили зробити крок назад і детально описати весь робочий процес: моделі, структуру сервісу, базу даних. Після цього залишилося описати кодом все, що було на схемах, і рухатись стало значно легше.
- Multi-Tenant Setup
Цей підхід дає сервісу можливість працювати з багатьма клієнтами без додавання нової логіки, коду або бази даних. У нас Multi-Tenant Setup реалізований на рівні архітектури. Усі Entry Points мають Tenant ID, зашитий як префікс. Для Pub/Sub запитів Tenant ID включається в тіло кожного запиту. Для баз даних ми використовуємо підхід Schema per Tenant. Також у нас є динамічні конфігурації, які оновлюються щохвилини та кешуються окремо для кожного тенанта.
- Гексагональна архітектура
Ця архітектура спрямована на те, щоби створювати незалежні модулі, які легко тестуються, замінюються або прибираються. Вони оперують такими сутностями, як порти та адаптери. Це допомагає уникати надмірної зв’язності між компонентами, а також забруднення бізнес-логіки деталями реалізації.
На схемі нижче — приклад відправки івентів до певного аналітичного сервісу, наприклад, до Facebook. Цей сервіс залежить від абстракції стореджа та черги повідомлень. Ми пишемо на Go, тому просто описуємо інтерфейси всередині кожного модуля.
Наприклад, у нас є інтерфейс стореджа, який реалізує певні методи. Коли ми працюємо з об’єктом ззовні, неважливо, яка саме база даних використовується, або що відбувається з цим об’єктом. Важливо лише, щоби він реалізовував потрібні методи, описані всередині модуля. Це дозволяє легко тестувати код і робить його незалежним від конкретної реалізації.
- Event Driven Architecture
У нашій системі івент — це логічна одиниця, яка драйвить весь флоу. На схемі нижче — статуси кожної нотифікації, яка приходить до системи. Також є внутрішні статуси для будь-якої одиниці: нотифікація, підписочний івент, one-time purchase івент. По-перше, це гарантує ідемпотентність на кожному кроці. А по-друге, допомагає швидко відловлювати помилки, адже ми одразу бачимо статус.
Як працює нова платіжна платформа
Ця схема — спрощений артефакт нашого Documentation Driven Development. Далі ми декомпозуємо її та розберемо деталі.
Коли у продукті підключені різні платіжні провайдери (Apple, Google або Web), кожен з них повідомляє про усі зміни у підписці, наприклад, якщо користувач оформлює тріал. Проте залежно від провайдера, нотифікації приходять у різних форматах, які неможливо використовувати разом. Саме тому потрібна уніфікація: щоби кожен із модулів платіжних провайдерів на output віддавав івенти, максимально абстраговані від деталей.
Маючи Core з уніфікованими івентами, ми можемо легко взаємодіяти з різними платформами та трансформувати івенти під їхні специфічні формати. Якби для кожного провайдера була окрема реалізація, таку систему було б важко масштабувати та додавати нові інтеграції.
Далі розберемо кожен з компонентів.
Платіжні провайдери
Крок 1. Отримання платіжного повідомлення. Ми маємо здійснити з ним певні дії:
- Провалідувати (перевірити сигнатуру, токен тощо), щоби пересвідчитись, що повідомлення нескомпрометоване, і зловмисник не хоче отримати доступ, користуючись покупкою іншої людини.
- Зберегти повідомлення в базу зі статусом Pending. У нашому випадку — у PostgreSQL у форматі JSONB. Це дає можливість мати повну інформацію про нотифікацію.
- Опублікувати нотифікацію в Pub/Sub. Важливо те, що ми публікуємо лише унікальний ідентифікатор повідомлення (UUID), а вся детальна інформація зберігається в базі даних. У такий спосіб можна без проблем модифікувати дані за потреби.
Крок 2. Перетворення в уніфікований івент.
- Використовуємо той самий UUID, як і в попередньому етапі. Сигнатура кожного ресивера містить мінімум параметрів. Так ми абстрагуємося від того, де використовуємо його: Pub/Sub, API, UI-адмінки, щоби перепроцесити певні нотифікації, або CLI, який ми активно використовуємо під час виправлення багів.
- Здійснюємо логічну валідацію повідомлення. Є декілька статусів, у які вони можуть переходити, наприклад, Unsupported (коли користувач отримав доступ до фічі платіжного провайдера, яку ми поки не підтримуємо) або Invalid (коли нотифікації зламані).
- Згенерувати уніфікований івент, оновити статус на Proccesed і запаблішити платіжний івент (також зі статусом Pending).
Додавання платіжного провайдера потребує лише двох кроків (іноді трьох) і не впливає на решту кодової бази.
Core
Крок 1. Отримавши UUID івента, система застосовує його до підписки.
По суті, наші івенти — це івент-лог, а підписка — агрегація цих івентів. Це дає можливість швидко зрозуміти, чому підписка знаходиться в тому чи іншому стані, переглянувши усі попередні івенти.
Крок 2. Переводимо івент у статус Processed.
Крок 3. Далі іде два паралельних флоу.
Перше — відправляємо цей івент на Tenant, повідомляємо, що змінився статус підписки. На цьому етапі продуктовому Back-end слід зазначити у своїй базі цю подію та змінити статус підписки. Також разом з івентом ми відправляємо сутність підписок (не одну, а усіх). Іноді користувач може мати декілька підписок від одного чи різних провайдерів. Це важливо розуміти, адже якщо у нього закінчилася підписка на Apple, але є активна на вебі, у нього не можна забирати доступ.
Друге — збагачуємо івент даними про конверсію та оновлюємо статус на Enriched. Далі івент відправляється у сторонні системи.
Destinations
У сторонніх системах ми використовуємо підхід fan-out: публікуємо івент в один топік, і в нас є n-кількість підписок на нього. Це дає своєрідну інверсію залежностей на рівні комунікації, адже Core неважливо, скільки у нас сторонніх систем, і чи є вони взагалі.
Також ми логуємо усі відправлені запити в базу разом із вмістом. Це розв’язання однієї з проблем, коли маркетологи питають, чому в одному сервісі недотрек, а в іншому перетрек. Цей підхід дає можливість у будь-який момент здійснити Debug Query, подивитися кількість івентів, їхні параметри та знайти потенційні причини проблем з атрибуцією.
Результати
Завдяки новій архітектурі додавання провайдерів та інтеграцій стало можливим без внесення змін в решту кодової бази. Наприклад, імплементація TikTok-інтеграції у старому коді зайняла три тижневі спринти, а на новій платформі — два дні. Знаходження багів і виправлення тепер вимірюються в годинах та навіть хвилинах замість днів. Якщо, наприклад, ми отримали алерт, що івенти в Facebook не відправляються, достатньо просто витягнути усі проблемні івенти та опублікувати їх знову.
У старій системі розробники постійно стикалися зі страхом вносити зміни, ризикуючи щось зламати через велику зв’язність та крихкість коду, а також через неможливість провести аудит та повернути систему до нормального стану.
Цифри:
- Утилізація оперативної пам’яті на рівні 50 Мб. Це всього 10% від пропускної здатності одного інстанса (512 Мб).
- 99% часу Cloud Run працює всього в один інстанс при максимальному скейлінгу на рівні 10. CPU утилізація одного інстанса складає 10%.
- У якості Persistent Layer ми використовуємо Cloud SQL — простий інстанс з двома корами та 7,5 Гб RAM. Водночас уважно стежимо за query insights, які повідомляють про проблеми з запитами, та оптимізуємо їх.
- 400 тисяч відправлених івентів на день в різні інтеграції.
- 200 rps. Під час міграції у нас були спалахи Write-heavy трафіку у 20Х, але система це перенесла без проблем.
І найголовніший показник, який всіх цікавить, — скільки це коштувало. Якщо порівнювати з інтеграцією готових рішень, то наша платформа обійшлася приблизно у 100 разів дешевше.
Якщо після прочитання цієї статті у вас залишились питання або є бажання познайомитись, пишіть нам в LinkedIn: Володимиру та Данилу.
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів