Поточна архітектура Prom.ua побудована на мікросервісах і GraphQL API, але так було не завжди. Максим Кіндріцький, Senior Python Engineer/Team Lead в EVO розповів, як команді вдалося зробити використання графів в мікросервісній архітектурі зручним і простим. Під час виступу на конференції Python + DS fwdays’24 він поділився, чому gRPC — ненайкраще рішення, щоби пов'язувати мікросервіси в GraphQL інфраструктурі, а також як технологія Apollo Federation допомогла зробили розробку більш доступною, розгортання — швидшим, а залишки моноліту — менш навантаженими. Публікуємо головне з його виступу.
> GraphQL та мікросервіси в Prom.ua
> Проблеми розподіленого GraphQL на gRPC/REST
> Як зробити розподілений GraphQL на Apollo Federation
> Як працювати з Entities в Apollo Federation
> Композиція схем та GraphQL Registry
> Результати
GraphQL та мікросервіси в Prom.ua
Prom.ua – це один із найбільших маркетплейсів в Україні, яким щодня користуються понад три мільйони людей. Він написаний повністю на Python, також ми використовуємо GraphQL. Навантаження на бекенди складає близько 10 000 rps (моноліт+сервіси).
GraphQL — це мова запитів, а також Runtime (серверне середовище, яке підтримує GraphQL-специфікацію і вміє обробляти такі запити). Так виглядає найпростіший подібний запит: ми викликаємо поле User, щоби дістати з нього інші поля. Праворуч — приклад простого GraphQL-сервера на фреймворку Strawberry, який дістає і повертає дані користувача.
Розберемо приклад Кабінету Покупця на Prom.ua. До 2020 року архітектура виглядала так: найпростіший React SPA здійснював GraphQL-запити на моноліт, де були зосереджені всі дані та бізнес-логіка. З часом у нас з'явилися мікросервіси: Opinions, Favorites тощо. Водночас для користувача нічого не змінилося: GraphQL-запити так само йдуть на моноліт, який вирішує, як їх виконати. Наприклад, деякі поля дістає з бази, а за іншими звертається до сервісів через gRPC.
У схемі нижче і надалі розглядатимемо один мікросервіс Opinions для зручності.
Проблеми розподіленого GraphQL на gRPC/REST
Звʼязок між монолітом і мікросервісом через gRPC має деякі проблеми:
Ми виділили мікросервіс, проте весь граф залишився в моноліті. Для будь-яких змін, пов'язаних з Opinions, коментарями тощо, потрібно код писати в моноліт.
Час на розробку фічей збільшився. Щоби додати якесь одне поле, наприклад, User ID, нам потрібно оновити протобафи, згенерувати з них клієнти та оновити їх у мікросервісі та моноліті. Потім у монолітному графі додати це поле із gRPC-респонсу, оновити мікросервіс та моноліт. Це займає багато часу.
Час деплою обмежений найповільнішим сервісом — монолітом. В той час, як мікросервіс може деплоїтись 10-15 разів на день, з монолітом працює багато людей, отже він потребує більшого тестування та перевірок.
Трафік мікросервісів все ще проходить через моноліт, який фактично виступає певним шлюзом та передає запити в мікросервіс по gRPC, і ці дані повертаються назад. Ми ж прагнули менше залежати від моноліту, швидко деплоїтися та розробляти фічі тільки в мікросервісі.
Оверфетчинг серверу. Загалом однією з переваг GraphQL є розв'язання цієї проблеми: якщо ви запитали з клієнта два поля, вам сервер поверне два поля. Проте коли ми почали використовувати gRPC, ми стикнулися з цією проблемою: коли ми запитуємо якийсь opinion, сервер повертає весь рядок із 20 полями, з яких ми беремо тільки два. Це не дуже оптимально.
Забагато серіалізацій: json > dict > protobuf > dataclass > dict > json. Нижче видно, як трансформуються дані в одному GraphQL-запиті. Виглядає не дуже оптимально.
@dataclass
class Opinion:
id: int
date_created: datetime
def handle_request(body: str) -> str:
data = json.loads(body)
pb = api.get_opinion(data)
dcl = Opinion(id=pb.id, date_created=pb.date_created.ToDatetime())
result = db.to_dict()
return json.dumps(result)
Як зробити розподілений GraphQL на Apollo Federation
Чи існує рішення, яке може розвʼязати усі ці проблеми одночасно? Тобто таке, що дозволить перенести частину графа в мікросервіс та вести в ньому розробку фічей, прибрати частину трафіку з моноліту, щоби він не займався обробкою запитів, які не стосуються його даних, а також швидко деплоїтись незалежно від моноліту. Ці можливості дала нам технологія Apollo Federation. Це інструмент, який дозволяє взяти всі графи ваших мікросервісів і обʼєднати їх в одну велику схему.
Як це працює:
Весь трафік тепер проходить через шлюз (в Apollo він ще називається роутер), який аналізує, куди за якими даними сходити. Цей роутер написаний на Rust та є досить швидким, з latency близько 5 мс.
Щоби роутер знав, у які сервіси за якими даними сходити, йому, потрібна supergraph-схема. Для цього ми маємо скомпозувати схеми всіх мікросервісів.
Supergraph-схема зберігається в GraphQL Registry, до якого має доступ шлюз.
Якщо раніше GraphQL-запит з кабінету покупця йшов на моноліт, а далі на мікросервіс через gRPC, то в новій архітектурі зʼявився роутер. Він отримує запит, аналізує його, будує query-план, і далі робить запити в мікросервіси або Monolith тільки за даними, які там лежать.
Тепер в сервісі Opinions з'являються графові API, і всі його типи ми переносимо з моноліту в мікросервіс. За даними, що стосуються сервісу Opinions, роутер ходить одразу в мікросервіс, зменшуючи трафік на моноліт. Також роутер тепер запитує в мікросервісі тільки ті поля, які потрібні клієнту. Таким чином ми уникаємо оверфетчингу.
Як це працює: якщо у вас є мікросервіс, який імплементує граф, потрібно включити в ньому підтримку Apollo Federation. Кожен із сервісів пушить свою схему в GraphQL Registry, який потім збирає supergraph-схему. При експорті схеми із цього графа в GraphQL Registry туди додаються деякі поля, директиви й типи, які потрібні Apollo роутеру.
Так виглядає найпростіший GraphQL-сервер:
@strawberry.type
class Product:
id: int
name: str
@strawberry.type
class Query:
products: list[Product] = strawberry.federation.field(resolver=get_products)
schema = strawberry.federation.Schema(query=Query, enable_federation_v2=True)
Як працювати з Entities в Apollo Federation
Ще одна корисна фіча в Apollo Federation — Entities. Це типи, які «живуть» в одному сервісі та можуть перевикористовуватися в інших. При цьому вам не потрібно описувати один і той самий тип в кожному із сервісів. Також можна розширювати тип одного сервісу в іншому.
Уявімо, у нас є тип Product, в якого є ID та Name. Згодом зʼявляється потреба додати туди ще Opinions, щоби коли ми запитуємо продукт, ми могли дізнатися відгуки про нього. Проте тип Opinions живе в іншому сервісі. У старій схемі нам би довелося писати тип Opinions в моноліті, потім зробити gRPC-запит, розкладати його в цей тип і повертати.
В Apollo ми декларуємо тип продукту в мікросервісі Opinions. Насправді ж цей тип належить моноліту, а тут використовується як сигнатура (в термінології Apollo називається Stub Type). Директива @key вказує, що цей тип можна знайти в іншому сервісі по ID. У ньому ми додаємо поле Opinions. У такий спосіб ми можемо розширити «чужий» тип своїми даними, не змінюючи його в іншому сервісі.
Як це виглядає в моноліті:
@strawberry.federation.type(keys=["id"])
class Product:
id: int
name: str
@classmethod
def resolve_reference(cls, id: strawberry.ID, info: Info):
product = info.context.products.get(int(id))
return Product(id=product.id, name=product.name)
Додаємо директиву Key до типу продукту, а також імплементуємо Resolve Reference метод, який Apollo Federation просить реалізувати для кожного Entity типу. Він буде викликатися роутером, після чого кожен мікросервіс зможе використовувати Entity тип Product.
Як це виглядає у сервісі Opinions:
@strawberry.type
class Opinion:
id: int
product_id: int
@strawberry.federation.type(keys=[“id”])
class Product:
id: int
@classmethod
def resolve_reference(cls, id: strawberry.ID, info: Info):
opinions = info.context.opinions.get_by_product(int(id))
return Product(id=id, opinions=opinions)
Описуємо, що цей тип ми використовуємо через ID, імплементуємо Resolve Reference. Тепер роутер прийде до нас з ID продукту, по якому ми дістанемо та повернемо усі відгуки.
Водночас у цій схемі може виникнути N+1 проблема. Коли роутер робить запит на ваш сервіс, він агрегує ID і надсилає їх масивом. Але у більшості фреймворків Resolve Reference здійснює окремі запити бази для кожного ID. Щоби пофіксити це, варто використовувати Data Loader — це фіча, яка посилається на Loading функцію, яка отримує перелік ID, агрегує їх під капотом та надсилає один запит в базу.
Композиція схем та GraphQL Registry
Є два способи обʼєднати сабграфи в одну велику схему. Перший: можна використовувати тулзу Apollo Rover. Ви вказуєте в YAML-файлі всі ендпоінти ваших сервісів і запускаєте Apollo Rover, який по інтерспекції ходить у всі сервіси, дістає їхні схеми й генерує окрему supergraph-схему. Другий: можна використовувати GraphQL Registry і налаштувати CI/CD-пайплайни — саме такий спосіб використовують у Prom.
На схемі зображено два репозиторії, які на push в мастер і створення тегу передають схеми на CI. Щоразу, коли GraphQL Registry отримує нову схему, він обʼєднує їх в одну і позначає, як активну. Apollo Router з певною періодичністю, яку ви можете налаштувати, (наприклад, раз на 10 секунд) підтягує нову схему, перезапускає конфіги, і вона потрапляє на продакшен.
Якщо, наприклад, до GraphQL Registry потрапила невалідна схема, через що він не зміг її скомпозувати і впав з помилкою, то нова схема не буде позначена як активна, і не потрапить через роутер на продакшен.
Заекспортити схему можна з допомогою таких CLI команд:
# strawberry export-schema reviews.graph:schema —output schema.graphql
# hive schema:publish —service reviews —url http://reviews.svc/graphql schema.graphql
Отже, GraphQL Registry має такі можливості:
Зберігання схем.
Валідація. Якщо ви здійснили певні зміни в GraphQL, наприклад, видалили поле, GraphQL Registry перевірить і повідомить про breaking change.
Композиція сабграфів у supergraph схему.
API для витягування схем (альтернатива інтроспекції на продакшені).
Результати
Швидша реалізація фічей та деплой. Зараз в моноліті ми лише визначаємо entity-типи, все інше робимо в мікросервісі.
Моноліту стало легше, адже тепер роутер самостійно фільтрує запити. Якщо в запиті немає полів, які потрібні дістати із моноліта, то він піде напряму на мікросервіс.
Приємний бонус — менший асинхронний сервер та окрема база. Функціонал працює швидше.
Можливість відстежувати та безпечно видаляти поля, які більше не використовуються через GraphQL Registry. У такий спосіб ми тримаємо актуальну схему.
Можливість додати в Graph будь-який сервіс в мережі. Для цього не потрібно писати gRPC, а заекспортити його в GraphQL Registry Graph API і одразу задеплоїти. Тоді роутер, отримавши запит з певними полями, автоматично сходить до нового мікросервісу.
Корисні посилання: