Русский
Русский
English
Статистика
Реклама

Перевод GraphQL на Rust

В этой статье я покажу как создать GraphQL сервер, используя Rust и его экосистему; будут приведены примеры реализации наиболее часто встречающихся задач при разработке GraphQL API. В итоге API трёх микросервисов будут объединены в единую точку доступа с помощью Apollo Server и Apollo Federation. Это позволит клиентам запрашивать данные одновременно из нескольких источников без необходимости знать какие данные приходят из какого сервиса.

Введение

Обзор

С точки зрения функциональности описываемый проект довольно похож на представленный в моей предыдущей статье, но в этот раз с использованием стэка Rust. Архитектурно проекта выглядит так:

Каждый компонент архитектуры освещает несколько вопросов, которые могут возникнуть при реализации GraphQL API. Доменная модель включает данные о планетах Солнечной системы и их спутниках. Проект имеет многомодульную структуру (или монорепозиторий) и состоит из следующих модулей:

Существуют две основных библиотеки для разработки GraphQL сервера на Rust: Juniper и Async-graphql, но только последняя поддерживает Apollo Federation, поэтому она была выбрана для реализации проекта (есть также открытый запрос на реализацию поддержки Federation в Juniper). Обе библиотеки предлагают code-first подход.

Помимо этого использованы PostgreSQL для реализации слоя данных, JWT для аутентификации и Kafka для асинхронного обмена сообщениями.

Стэк технологий

В следующей таблице показан стэк основных технологий, использованных в проекте:

Тип

Название

Сайт

GitHub

Язык программирования

Rust

link

link

GraphQL библиотека

Async-graphql

link

link

Единая GraphQL точка доступа

Apollo Server

link

link

Web фреймворк

actix-web

link

link

База даных

PostgreSQL

link

link

Брокер сообщений

Apache Kafka

link

link

Оркестрация контейнеров

Docker Compose

link

link

Также некоторые использованные Rust библиотеки:

Тип

Название

Сайт

GitHub

ORM

Diesel

link

link

Kafka клиент

rust-rdkafka

link

link

Хэширование паролей

argonautica

link

link

JWT библиотека

jsonwebtoken

link

link

Бибилиотека для тестирования

Testcontainers-rs

link

link

Необходимое ПО

Чтобы запустить проект локально, вам нужен только Docker Compose. В противном случае вам может понадобиться следующее:

  • Rust

  • Diesel CLI (для установки выполните cargo install diesel_cli --no-default-features --features postgres)

  • LLVM (это нужно для работы крэйта argonautica)

  • CMake (это нужно для работы крэйта rust-rdkafka)

  • PostgreSQL

  • Apache Kafka

  • npm

Реализация

В Cargo.toml в корне проекта указаны три приложения и одна библиотека:

Root Cargo.toml

[workspace]members = [    "auth-service",    "planets-service",    "satellites-service",    "common-utils",]

Начнём с planets-service.

Зависимости

Cargo.toml выглядит так:

Cargo.toml

[package]name = "planets-service"version = "0.1.0"edition = "2018"[dependencies]common-utils = { path = "../common-utils" }async-graphql = "2.4.3"async-graphql-actix-web = "2.4.3"actix-web = "3.3.2"actix-rt = "1.1.1"actix-web-actors = "3.0.0"futures = "0.3.8"async-trait = "0.1.42"bigdecimal = { version = "0.1.2", features = ["serde"] }serde = { version = "1.0.118", features = ["derive"] }serde_json = "1.0.60"diesel = { version = "1.4.5", features = ["postgres", "r2d2", "numeric"] }diesel_migrations = "1.4.0"dotenv = "0.15.0"strum = "0.20.0"strum_macros = "0.20.1"rdkafka = { version = "0.24.0", features = ["cmake-build"] }async-stream = "0.3.0"lazy_static = "1.4.0"[dev-dependencies]jsonpath_lib = "0.2.6"testcontainers = "0.9.1"

async-graphql это GraphQL библиотека, actix-web web фреймворк, а async-graphql-actix-web обеспечивает интеграцию между ними.

Ключевые функции

Начнём с main.rs:

main.rs

#[actix_rt::main]async fn main() -> std::io::Result<()> {    dotenv().ok();    let pool = create_connection_pool();    run_migrations(&pool);    let schema = create_schema_with_context(pool);    HttpServer::new(move || App::new()        .configure(configure_service)        .data(schema.clone())    )        .bind("0.0.0.0:8001")?        .run()        .await}

Здесь окружение и HTTP сервер конфигурируются с помощью функций, определённых в lib.rs:

lib.rs

pub fn configure_service(cfg: &mut web::ServiceConfig) {    cfg        .service(web::resource("/")            .route(web::post().to(index))            .route(web::get().guard(guard::Header("upgrade", "websocket")).to(index_ws))            .route(web::get().to(index_playground))        );}async fn index(schema: web::Data, http_req: HttpRequest, req: Request) -> Response {    let mut query = req.into_inner();    let maybe_role = common_utils::get_role(http_req);    if let Some(role) = maybe_role {        query = query.data(role);    }    schema.execute(query).await.into()}async fn index_ws(schema: web::Data, req: HttpRequest, payload: web::Payload) -> Result {    WSSubscription::start(Schema::clone(&*schema), &req, payload)}async fn index_playground() -> HttpResponse {    HttpResponse::Ok()        .content_type("text/html; charset=utf-8")        .body(playground_source(GraphQLPlaygroundConfig::new("/").subscription_endpoint("/")))}pub fn create_schema_with_context(pool: PgPool) -> Schema {    let arc_pool = Arc::new(pool);    let cloned_pool = Arc::clone(&arc_pool);    let details_batch_loader = Loader::new(DetailsBatchLoader {        pool: cloned_pool    }).with_max_batch_size(10);    let kafka_consumer_counter = Mutex::new(0);    Schema::build(Query, Mutation, Subscription)        .data(arc_pool)        .data(details_batch_loader)        .data(kafka::create_producer())        .data(kafka_consumer_counter)        .finish()}

Эти функции делают следующее:

  • index обрабатывает GraphQL запросы (query) и мутации

  • index_ws обрабатывает GraphQL подписки

  • index_playground предоставляет Playground GraphQL IDE

  • create_schema_with_context создаёт GraphQL схему с глобальным контекстом доступным в рантайме, например, пул соединений с БД

Определение GraphQL запроса и типа

Рассмотрим как определить запрос:

Определение запроса

#[Object]impl Query {    async fn get_planets(&self, ctx: &Context<'_>) -> Vec {        repository::get_all(&get_conn_from_ctx(ctx)).expect("Can't get planets")            .iter()            .map(|p| { Planet::from(p) })            .collect()    }    async fn get_planet(&self, ctx: &Context<'_>, id: ID) -> Option {        find_planet_by_id_internal(ctx, id)    }    #[graphql(entity)]    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option {        find_planet_by_id_internal(ctx, id)    }}fn find_planet_by_id_internal(ctx: &Context<'_>, id: ID) -> Option {    let id = id.to_string().parse::().expect("Can't get id from String");    repository::get(id, &get_conn_from_ctx(ctx)).ok()        .map(|p| { Planet::from(&p) })}

Эти запросы получают данные из БД используя слой репозитория. Полученные сущности конвертируются в GraphQL DTO (это позволяет соблюсти принцип единственной ответственности для каждой структуры). Запросы get_planets и get_planet могут быть выполнены из любой GraphQL IDE например так:

Пример использования запроса

{  getPlanets {    name    type  }}

Структура Planet определена так:

Определение GraphQL типа

#[derive(Serialize, Deserialize)]struct Planet {    id: ID,    name: String,    planet_type: PlanetType,}#[Object]impl Planet {    async fn id(&self) -> &ID {        &self.id    }    async fn name(&self) -> &String {        &self.name    }    /// From an astronomical point of view    #[graphql(name = "type")]    async fn planet_type(&self) -> &PlanetType {        &self.planet_type    }    #[graphql(deprecation = "Now it is not in doubt. Do not use this field")]    async fn is_rotating_around_sun(&self) -> bool {        true    }    async fn details(&self, ctx: &Context<'_>) -> Details {        let loader = ctx.data::>().expect("Can't get loader");        let planet_id = self.id.to_string().parse::().expect("Can't convert id");        loader.load(planet_id).await    }}

В impl определяется резолвер для каждого поля. Также для некоторых полей определены описание (в виде Rust комментария) и deprecation reason. Это будет отображено в GraphQL IDE.

Проблема N+1

В случае наивной реализации функции Planet.details выше возникла бы проблема N+1, то есть, при выполнении такого запроса:

Пример возможного ресурсоёмкого GraphQL запроса

{  getPlanets {    name    details {      meanRadius    }  }}

для поля details каждой из планет был бы сделан отдельный SQL запрос, т. к. Details отдельная от Planet сущность и хранится в собственной таблице.

Но с помощью DataLoader, реализованного в Async-graphql, резолвер details может быть определён так:

Определение резолвера

async fn details(&self, ctx: &Context<'_>) -> Result {    let data_loader = ctx.data::>().expect("Can't get data loader");    let planet_id = self.id.to_string().parse::().expect("Can't convert id");    let details = data_loader.load_one(planet_id).await?;    details.ok_or_else(|| "Not found".into())}

data_loader это объект в контектсе приложения, определённый так:

Определение DataLoader'а

let details_data_loader = DataLoader::new(DetailsLoader {    pool: cloned_pool}).max_batch_size(10);

DetailsLoader реализован следующим образом:

DetailsLoader definition

pub struct DetailsLoader {    pub pool: Arc}#[async_trait::async_trait]impl Loader for DetailsLoader {    type Value = Details;    type Error = Error;    async fn load(&self, keys: &[i32]) -> Result, Self::Error> {        let conn = self.pool.get().expect("Can't get DB connection");        let details = repository::get_details(keys, &conn).expect("Can't get planets' details");        Ok(details.iter()            .map(|details_entity| (details_entity.planet_id, Details::from(details_entity)))            .collect::>())    }}

Такой подход позволяет предотвратить проблему N+1, т. к. каждый вызов DetailsLoader.load выполняет только один SQL запрос, возвращающий пачку DetailsEntity.

Определение интерфейса

GraphQL интерфейс и его реализации могут быть определены следующим образом:

Определение GraphQL интерфейса

#[derive(Interface, Clone)]#[graphql(    field(name = "mean_radius", type = "&CustomBigDecimal"),    field(name = "mass", type = "&CustomBigInt"),)]pub enum Details {    InhabitedPlanetDetails(InhabitedPlanetDetails),    UninhabitedPlanetDetails(UninhabitedPlanetDetails),}#[derive(SimpleObject, Clone)]pub struct InhabitedPlanetDetails {    mean_radius: CustomBigDecimal,    mass: CustomBigInt,    /// In billions    population: CustomBigDecimal,}#[derive(SimpleObject, Clone)]pub struct UninhabitedPlanetDetails {    mean_radius: CustomBigDecimal,    mass: CustomBigInt,}

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

Определение кастомного скалярного типа

Кастомные скаляры позволяют определить как представлять и как парсить значения определённого типа. Проект содержит два примера определения кастомных скаляров; оба являются обёртками для числовых структур (т. к. невозможно определить внешний трейт на внешней структуре из-за orphan rule). Эти обёртки определены так:

Кастомный скаляр: обёртка для BigInt

#[derive(Clone)]pub struct CustomBigInt(BigDecimal);#[Scalar(name = "BigInt")]impl ScalarType for CustomBigInt {    fn parse(value: Value) -> InputValueResult {        match value {            Value::String(s) => {                let parsed_value = BigDecimal::from_str(&s)?;                Ok(CustomBigInt(parsed_value))            }            _ => Err(InputValueError::expected_type(value)),        }    }    fn to_value(&self) -> Value {        Value::String(format!("{:e}", &self))    }}impl LowerExp for CustomBigInt {    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {        let val = &self.0.to_f64().expect("Can't convert BigDecimal");        LowerExp::fmt(val, f)    }}

Кастомный скаляр: обёртка для BigDecimal

#[derive(Clone)]pub struct CustomBigDecimal(BigDecimal);#[Scalar(name = "BigDecimal")]impl ScalarType for CustomBigDecimal {    fn parse(value: Value) -> InputValueResult {        match value {            Value::String(s) => {                let parsed_value = BigDecimal::from_str(&s)?;                Ok(CustomBigDecimal(parsed_value))            }            _ => Err(InputValueError::expected_type(value)),        }    }    fn to_value(&self) -> Value {        Value::String(self.0.to_string())    }}

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

Определение мутации

Мутация может быть определена следующим образом:

Определение мутации

pub struct Mutation;#[Object]impl Mutation {    #[graphql(guard(RoleGuard(role = "Role::Admin")))]    async fn create_planet(&self, ctx: &Context<'_>, planet: PlanetInput) -> Result {        let new_planet = NewPlanetEntity {            name: planet.name,            planet_type: planet.planet_type.to_string(),        };        let details = planet.details;        let new_planet_details = NewDetailsEntity {            mean_radius: details.mean_radius.0,            mass: BigDecimal::from_str(&details.mass.0.to_string()).expect("Can't get BigDecimal from string"),            population: details.population.map(|wrapper| { wrapper.0 }),            planet_id: 0,        };        let created_planet_entity = repository::create(new_planet, new_planet_details, &get_conn_from_ctx(ctx))?;        let producer = ctx.data::().expect("Can't get Kafka producer");        let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");        kafka::send_message(producer, message).await;        Ok(Planet::from(&created_planet_entity))    }}

Чтобы использовать объект как входной параметр мутации, надо определить структуру следующим образом:

Определение input type

#[derive(InputObject)]struct PlanetInput {    name: String,    #[graphql(name = "type")]    planet_type: PlanetType,    details: DetailsInput,}

Мутация защищена RoleGuard'ом, который гарантирует что только пользователи с ролбю Admin могут выполнить её. Таким образом, для выполнения, например, следующей мутации:

Пример использования мутации

mutation {  createPlanet(    planet: {      name: "test_planet"      type: TERRESTRIAL_PLANET      details: { meanRadius: "10.5", mass: "8.8e24", population: "0.5" }    }  ) {    id  }}

вам нужно указать заголовок Authorization с JWT, полученным из auth-service (это будет описано далее).

Определение подписки

В определении мутации выше вы могли видеть что при добавлении новой планеты отправляется сообщение:

Отправка сообщения в Kafka

let producer = ctx.data::().expect("Can't get Kafka producer");let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");kafka::send_message(producer, message).await;

Клиент API может быть уведомлен об этом событии с помощью подписки, слушающей Kafka consumer:

Определение подписки

pub struct Subscription;#[Subscription]impl Subscription {    async fn latest_planet<'ctx>(&self, ctx: &'ctx Context<'_>) -> impl Stream + 'ctx {        let kafka_consumer_counter = ctx.data::>().expect("Can't get Kafka consumer counter");        let consumer_group_id = kafka::get_kafka_consumer_group_id(kafka_consumer_counter);        let consumer = kafka::create_consumer(consumer_group_id);        async_stream::stream! {            let mut stream = consumer.start();            while let Some(value) = stream.next().await {                yield match value {                    Ok(message) => {                        let payload = message.payload().expect("Kafka message should contain payload");                        let message = String::from_utf8_lossy(payload).to_string();                        serde_json::from_str(&message).expect("Can't deserialize a planet")                    }                    Err(e) => panic!("Error while Kafka message processing: {}", e)                };            }        }    }}

Подписка может быть использована так же, как запросы и мутации:

Пример использования подписки

subscription {  latestPlanet {    id    name    type    details {      meanRadius    }  }}

Подписки должны отправляться на ws://localhost:8001.

Интеграционные тесты

Тесты запросов и мутаций можно написать так:

Тест запроса

#[actix_rt::test]async fn test_get_planets() {    let docker = Cli::default();    let (_pg_container, pool) = common::setup(&docker);    let mut service = test::init_service(App::new()        .configure(configure_service)        .data(create_schema_with_context(pool))    ).await;    let query = "        {            getPlanets {                id                name                type                details {                    meanRadius                    mass                    ... on InhabitedPlanetDetails {                        population                    }                }            }        }        ".to_string();    let request_body = GraphQLCustomRequest {        query,        variables: Map::new(),    };    let request = test::TestRequest::post().uri("/").set_json(&request_body).to_request();    let response: GraphQLCustomResponse = test::read_response_json(&mut service, request).await;    fn get_planet_as_json(all_planets: &serde_json::Value, index: i32) -> &serde_json::Value {        jsonpath::select(all_planets, &format!("$.getPlanets[{}]", index)).expect("Can't get planet by JSON path")[0]    }    let mercury_json = get_planet_as_json(&response.data, 0);    common::check_planet(mercury_json, 1, "Mercury", "TERRESTRIAL_PLANET", "2439.7");    let earth_json = get_planet_as_json(&response.data, 2);    common::check_planet(earth_json, 3, "Earth", "TERRESTRIAL_PLANET", "6371.0");    let neptune_json = get_planet_as_json(&response.data, 7);    common::check_planet(neptune_json, 8, "Neptune", "ICE_GIANT", "24622.0");}

Если часть запроса может быть переиспользована в другом запросе, вы можете использовать фрагменты:

Тест запроса с использованием фрагмента

const PLANET_FRAGMENT: &str = "    fragment planetFragment on Planet {        id        name        type        details {            meanRadius            mass            ... on InhabitedPlanetDetails {                population            }        }    }";#[actix_rt::test]async fn test_get_planet_by_id() {    ...    let query = "        {            getPlanet(id: 3) {                ... planetFragment            }        }        ".to_string() + PLANET_FRAGMENT;    let request_body = GraphQLCustomRequest {        query,        variables: Map::new(),    };    ...}

Чтобы использовать переменные, запишите тест так:

Тест запроса с использованием фрагмента и переменной

#[actix_rt::test]async fn test_get_planet_by_id_with_variable() {    ...    let query = "        query testPlanetById($planetId: String!) {            getPlanet(id: $planetId) {                ... planetFragment            }        }".to_string() + PLANET_FRAGMENT;    let jupiter_id = 5;    let mut variables = Map::new();    variables.insert("planetId".to_string(), jupiter_id.into());    let request_body = GraphQLCustomRequest {        query,        variables,    };    ...}

В этом проекте используется библиотека Testcontainers-rs, что позволяет подготовить тестовое окружение, то есть, создать временную БД PostgreSQL.

Клиент к GraphQL API

Вы можете использовать код из предыдущего раздела для создания клиента к внешнему GraphQL API. Также для этого существуют специальные библиотеки, например, graphql-client, но я их не использовал.

Безопасность API

Существуют различные угрозы безопасности GraphQL API (см. список); рассмотрим некоторые из них.

Ограничения глубины и сложности запроса

Если бы структура Satellite содержала поле planet, был бы возможен такой запрос:

Пример тяжёлого запроса

{  getPlanet(id: "1") {    satellites {      planet {        satellites {          planet {            satellites {              ... # more deep nesting!            }          }        }      }    }  }}

Сделать такой запрос невалидным можно так:

Пример ограничения глубины и сложности запроса

pub fn create_schema_with_context(pool: PgPool) -> Schema {    ...    Schema::build(Query, Mutation, Subscription)        .limit_depth(3)        .limit_complexity(15)    ...}

Стоит отметить, что при указании ограничений выше может перестать отображаться документация сервиса в GraphQL IDE. Это происходит потому, что IDE пытается выполнить introspection query, который имеет заметные глубину и сложность.

Аутентификация

Эта функциональность реализована в auth-service с использованием крэйтов argonautica и jsonwebtoken. Первый отвечает за хэширование паролей пользователей с использованием алгоритма Argon2. Аутентификация и авторизация показаны исключительно в демонстрационных целях; пожалуйста, изучите вопрос более тщательно перед использованием в продакшене.

Рассмотрим как реализован вход в систему:

Реализация входа в систему

pub struct Mutation;#[Object]impl Mutation {    async fn sign_in(&self, ctx: &Context<'_>, input: SignInInput) -> Result {        let maybe_user = repository::get_user(&input.username, &get_conn_from_ctx(ctx)).ok();        if let Some(user) = maybe_user {            if let Ok(matching) = verify_password(&user.hash, &input.password) {                if matching {                    let role = AuthRole::from_str(user.role.as_str()).expect("Can't convert &str to AuthRole");                    return Ok(common_utils::create_token(user.username, role));                }            }        }        Err(Error::new("Can't authenticate a user"))    }}#[derive(InputObject)]struct SignInInput {    username: String,    password: String,}

Посмотреть реализацию функции verify_password можно в модуле utils, create_token в модуле common_utils. Как вы могли бы ожидать, функция sign_in возвращает JWT, который в дальнейшем может быть использован для авторизации в других сервисах.

Для получения JWT выполните следующую мутацию:

Получение JWT

mutation {  signIn(input: { username: "john_doe", password: "password" })}

Используйте параметры john_doe/password. Включение полученного JWT в последующие запросы позволит получить доступ к защищённым ресурсам (см. следующий раздел).

Авторизация

Чтобы запросить защищённые данные, добавьте заголовок в HTTP запрос в формате Authorization: Bearer $JWT. Функция index извлечёт роль пользователя из HTTP запроса и добавит её в параметры GraphQL запроса/мутации:

Получение роли

async fn index(schema: web::Data, http_req: HttpRequest, req: Request) -> Response {    let mut query = req.into_inner();    let maybe_role = common_utils::get_role(http_req);    if let Some(role) = maybe_role {        query = query.data(role);    }    schema.execute(query).await.into()}

К ранее показанной мутации create_planet применён следующий атрибут:

Использование гарда

#[graphql(guard(RoleGuard(role = "Role::Admin")))]

Сам гард реализован так:

Реализация гарда

struct RoleGuard {    role: Role,}#[async_trait::async_trait]impl Guard for RoleGuard {    async fn check(&self, ctx: &Context<'_>) -> Result<()> {        if ctx.data_opt::() == Some(&self.role) {            Ok(())        } else {            Err("Forbidden".into())        }    }}

Таким образом, если вы не укажете токен, сервер ответит сообщением "Forbidden".

Определение перечисления

GraphQL перечисление может быть определено так:

Определение перечисления

#[derive(SimpleObject)]struct Satellite {    ...    life_exists: LifeExists,}#[derive(Copy, Clone, Eq, PartialEq, Debug, Enum, EnumString)]#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]pub enum LifeExists {    Yes,    OpenQuestion,    NoData,}

Работа с датами

Async-graphql поддерживает типы даты/времени из библиотеки chrono, поэтому вы можете определить такие поля как обычно:

Определение поля с датой

#[derive(SimpleObject)]struct Satellite {    ...    first_spacecraft_landing_date: Option,}

Поддержка Apollo Federation

Одна из целей satellites-service продемонстрировать как распределённая GraphQL сущность (Planet) может резолвиться в двух (или более) сервисах и затем запрашиваться через Apollo Server.

Тип Planet был ранее определён в planets-service так:

Определение типа Planet в planets-service

#[derive(Serialize, Deserialize)]struct Planet {    id: ID,    name: String,    planet_type: PlanetType,}

Также в planets-service тип Planet является сущностью:

Определение сущности Planet

#[Object]impl Query {    #[graphql(entity)]    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option {        find_planet_by_id_internal(ctx, id)    }}

satellites-service расширяет сущность Planet путём добавления поля satellites:

Расширение типа Planet в satellites-service

struct Planet {    id: ID}#[Object(extends)]impl Planet {    #[graphql(external)]    async fn id(&self) -> &ID {        &self.id    }    async fn satellites(&self, ctx: &Context<'_>) -> Vec {        let id = self.id.to_string().parse::().expect("Can't get id from String");        repository::get_by_planet_id(id, &get_conn_from_ctx(ctx)).expect("Can't get satellites of planet")            .iter()            .map(|e| { Satellite::from(e) })            .collect()    }}

Также вам нужно реализовать функцию поиска для расширяемого типа. В примере ниже функция просто создаёт новый инстанс Planet:

Функция поиска для типа Planet

#[Object]impl Query {    #[graphql(entity)]    async fn get_planet_by_id(&self, id: ID) -> Planet {        Planet { id }    }}

Async-graphql генерирует два дополнительных запроса (_service and _entities), которые будут использованы Apollo Server'ом. Эти запросы внутренние, то есть они не будут отображены в API Apollo Server'а. Конечно, сервис с поддержкой Apollo Federation по-прежнему может работать автономно.

Apollo Server

Apollo Server и Apollo Federation позволяют достичь две основные цели:

  • создать единую точку доступа к нескольким GraphQL API

  • создать единый граф данных из распределённых сущностей

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

Существует и другой способ создания единой GraphQL схемы, schema stitching, но пока что я его не использовал.

Модуль включает следующий исходный код:

Мета-информация и зависимости

{  "name": "api-gateway",  "main": "gateway.js",  "scripts": {    "start-gateway": "nodemon gateway.js"  },  "devDependencies": {    "concurrently": "5.3.0",    "nodemon": "2.0.6"  },  "dependencies": {    "@apollo/gateway": "0.21.3",    "apollo-server": "2.19.0",    "graphql": "15.4.0"  }}

Определение Apollo Server

const {ApolloServer} = require("apollo-server");const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");class AuthenticatedDataSource extends RemoteGraphQLDataSource {    willSendRequest({request, context}) {        if (context.authHeaderValue) {            request.http.headers.set('Authorization', context.authHeaderValue);        }    }}let node_env = process.env.NODE_ENV;function get_service_url(service_name, port) {    let host;    switch (node_env) {        case 'docker':            host = service_name;            break;        case 'local': {            host = 'localhost';            break        }    }    return "http://" + host + ":" + port;}const gateway = new ApolloGateway({    serviceList: [        {name: "planets-service", url: get_service_url("planets-service", 8001)},        {name: "satellites-service", url: get_service_url("satellites-service", 8002)},        {name: "auth-service", url: get_service_url("auth-service", 8003)},    ],    buildService({name, url}) {        return new AuthenticatedDataSource({url});    },});const server = new ApolloServer({    gateway, subscriptions: false, context: ({req}) => ({        authHeaderValue: req.headers.authorization    })});server.listen({host: "0.0.0.0", port: 4000}).then(({url}) => {    console.log(`? Server ready at ${url}`);});

Если код выше может быть упрощён, не стесняйтесь поправить.

Авторизация в apollo-service работает так же, как было показано ранее для Rust сервисов (вам надо указать заголовок Authorization и его значение).

Приложение, написанное на любом языке или фреймворке, может быть добавлено в качестве нижележащего сервиса под Apollo Server, если оно реализует спецификацию Federation; список библиотек, добавляющих поддержку этой спецификации доступен в документации.

При реализации модуля я столкнулся со следующими ограничениями:

Взаимодействие с БД

Уровень хранения реализован с помощью PostgreSQL and Diesel. Если вы не используете Docker при локальном запуске, то нужно выполнить diesel setup, находясь в директории каждого из сервисов. Это создаст пустую БД, к которой далее будут применены миграции, создающие таблицы и инициализирующие данные.

Запуск проекта и тестирование API

Как было отмечено ранее, проект можно запустить двумя способами:

  • с использованием Docker Compose (docker-compose.yml)

    Здесь, в свою очередь, также возможны два варианта:

    • режим разработки (используя локально собранные образы)

      docker-compose up

    • production mode (используя релизные образы)

      docker-compose -f docker-compose.yml up

  • без Docker

    Запустите каждый Rust сервис с помощью cargo run, потом запустите Apollo Server:

    • cd в папку apollo-server

    • определите переменную среды NODE_ENV, например, set NODE_ENV=local (для Windows)

    • npm install

    • npm run start-gateway

Успешный запуск apollo-server должен выглядеть так:

Лог запуска Apollo Server

[nodemon] 2.0.6[nodemon] to restart at any time, enter `rs`[nodemon] watching path(s): *.*[nodemon] watching extensions: js,mjs,json[nodemon] starting `node gateway.js`Server ready at http://0.0.0.0:4000/

Вы можете перейти на http://localhost:4000 в браузере и использовать встроенную Playground IDE:

Здесь возможно выполнять запросы, мутации и подписки, определённые в нижележащих сервисах. Кроме того, каждый из этих сервисов имеет собственную Playground IDE.

Тест подписки

Чтобы убедиться в том, что подписка работает, откройте две вкладки любой GraphQL IDE; в первой подпишитесь таким образом:

Пример подписки

subscription {  latestPlanet {    name    type  }}

Во второй укажите заголовок Authorization как было описано ранее и выполните мутацию:

Пример мутации

mutation {  createPlanet(    planet: {      name: "Pluto"      type: DWARF_PLANET      details: { meanRadius: "1188", mass: "1.303e22" }    }  ) {    id  }}

Подписанный клиент будет уведомлен о событии:

CI/CD

CI/CD сконфигурирован с помощью GitHub Actions (workflow), который запускает тесты приложений, собирает их Docker образы и разворачивает их на Google Cloud Platform.

Вы можете посмотреть на описанные API здесь.

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

Заключение

В этой статье я рассмотрел как решать наиболее частые вопросы, которые могут возникнуть при разработке GraphQL API на Rust. Также было показано как объединить API Rust GraphQL микросервисов для получения единого GraphQL интерфейса; в подобной архитектуре сущность может быть распределена среди нескольких микросервисов. Это достигается за счёт использования Apollo Server, Apollo Federation и библиотеки Async-graphql. Исходный код рассмотренного проекта доступен на GitHub. Не стесняйтесь написать мне, если найдёте ошибки в статье или исходном коде. Благодарю за внимание!

Полезные ссылки

Источник: habr.com
К списку статей
Опубликовано: 09.03.2021 18:08:33
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Javascript

Api

Rust

Микросервисы

Graph

Microservices

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru