Как GraphQL-ить на Kotlin и создать единую точку доступа к API нескольких микросервисов

GraphQL — это язык запросов к API, разработанный Facebook. В этой статье вы увидите пример реализации GraphQL API на JVM, в частности, с использованием языка Kotlin и фреймворка Micronaut; большая часть примеров может быть переиспользована на других Java/Kotlin фреймворках. Затем будет показано как объединить несколько GraphQL сервисов в единый граф данных, чтобы предоставить единый интерфейс доступа ко всем источникам данных. Это реализовано с использованием Apollo Server и Apollo Federation. В итоге будет получена следующая архитектура:


architecture


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


Для работы с проектом должны быть установлены:



Planet service


Основные зависимости, связанные с GraphQL, приведены далее:


implementation("io.micronaut.graphql:micronaut-graphql:$micronautGraphQLVersion")
implementation("io.gqljf:graphql-java-federation:$graphqlJavaFederationVersion")

Зависимости (исходный код)


Первая обеспечивает интеграцию между GraphQL Java и Micronaut, то есть, определяет необходимые бины, например, GraphQL контролер. Это обычный контроллер в терминах Spring and Micronaut; он обрабатывает GET и POST запросы к эндпоинту /graphql. Вторая зависимость — это библиотека, которая добавляет GraphQL Java приложению поддержку Apollo Federation.


GraphQL схема написана на Schema Definition Language (SDL) и находится в ресурсах сервиса:


type Query {
    planets: [Planet!]!
    planet(id: ID!): Planet
    planetByName(name: String!): Planet
}

type Mutation {
    createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
}

type Subscription {
    latestPlanet: Planet!
}

type Planet @key(fields: "id") {
    id: ID!
    name: String!
    # from an astronomical point of view
    type: Type!
    isRotatingAroundSun: Boolean! @deprecated(reason: "Now it is not in doubt. Do not use this field")
    details: Details!
}

interface Details {
    meanRadius: Float!
    mass: BigDecimal!
}

type InhabitedPlanetDetails implements Details {
    meanRadius: Float!
    mass: BigDecimal!
    # in billions
    population: Float!
}

type UninhabitedPlanetDetails implements Details {
    meanRadius: Float!
    mass: BigDecimal!
}

enum Type {
    TERRESTRIAL_PLANET
    GAS_GIANT
    ICE_GIANT
    DWARF_PLANET
}

input DetailsInput {
    meanRadius: Float!
    mass: MassInput!
    population: Float
}

input MassInput {
    number: Float!
    tenPower: Int!
}

scalar BigDecimal

Схема Planet service (исходный код)


Поле Planet.id имеет тип ID, который является одним из 5-и дефолтных скалярных типов. GraphQL Java добавляет ещё несколько скаляров и обеспчивает возможность написания собственных. Наличие восклицательного знака после названия типа означает, что поле не может принимать значение null, и наоборот (вы можете заметить сходство между Kotlin и GraphQL в их возможности определения nullable типов). @directive’ы будут рассмотрены далее. Больше информации о схемах и их синтаксисе можно найти в официальном гайде. Если вы используете IntelliJ IDEA, можно установить JS GraphQL plugin для работы со схемами.


Существет два подхода к разработке GraphQL API:


  • schema-first


    Сначала разработать схему (и, соответственно, API), после чего реализовать её в коде


  • code-first


    Схема генерируется автоматически на основе кода



Оба подхода имеют достоинства и недостатки; более детально это рассматривается в этом посте. Для этого проекта я использовал schema-first способ. Здесь вы можете найти инструмент для обоих подходов.


В конфиге Micronaut доступна опция, позволяющая включить GraphQL IDE — GraphiQL — с помощью которой можно выполнять GraphQL запросы из браузера:


graphql:
  graphiql:
    enabled: true

Включение GraphiQL (исходный код)


Main класс не содержит ничего необычного:


object PlanetServiceApplication {

    @JvmStatic
    fun main(args: Array<String>) {
        Micronaut.build()
            .packages("io.graphqlfederation.planetservice")
            .mainClass(PlanetServiceApplication.javaClass)
            .start()
    }
}

Main класс (исходный код)


GraphQL бин определён так:


@Bean
@Singleton
fun graphQL(resourceResolver: ResourceResolver): GraphQL {
    val schemaInputStream = resourceResolver.getResourceAsStream("classpath:schema.graphqls").get()
    val transformedGraphQLSchema = FederatedSchemaBuilder()
        .schemaInputStream(schemaInputStream)
        .runtimeWiring(createRuntimeWiring())
        .excludeSubscriptionsFromApolloSdl(true)
        .build()

    return GraphQL.newGraphQL(transformedGraphQLSchema)
        .instrumentation(
            ChainedInstrumentation(
                listOf(
                    FederatedTracingInstrumentation()
                    // uncomment if you need to enable the instrumentations. but this may affect showing documentation in a GraphQL client
                    // MaxQueryComplexityInstrumentation(50),
                    // MaxQueryDepthInstrumentation(5)
                )
            )
        )
        .build()
}

Конфиг GraphQL (исходный код)


Класс FederatedSchemaBuilder добавляет приложению поддержку спецификации Apollo Federation. Если вы не планируете объединять GraphQL Java сервисы в единый граф, конфиг будет отличаться (см. гайд).


Объект RuntimeWiring — это спецификация data fetcher’ов, type resolver’ов и кастомных скаляров, которые необходимы для создания GraphQLSchema; определяется так:


private fun createRuntimeWiring(): RuntimeWiring {
    val detailsTypeResolver = TypeResolver { env ->
        when (val details = env.getObject() as DetailsDto) {
            is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
            is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
            else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
        }
    }

    return RuntimeWiring.newRuntimeWiring()
        .type("Query") { builder ->
            builder
                .dataFetcher("planets", planetsDataFetcher)
                .dataFetcher("planet", planetDataFetcher)
                .dataFetcher("planetByName", planetByNameDataFetcher)
        }
        .type("Mutation") { builder ->
            builder.dataFetcher("createPlanet", createPlanetDataFetcher)
        }
        .type("Subscription") { builder ->
            builder.dataFetcher("latestPlanet", latestPlanetDataFetcher)
        }
        .type("Planet") { builder ->
            builder.dataFetcher("details", detailsDataFetcher)
        }
        .type("Details") { builder ->
            builder.typeResolver(detailsTypeResolver)
        }
        .type("Type") { builder ->
            builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
        }
        .build()
}

Создание RuntimeWiring объекта (исходный код)


Для root-типа Query (другие root-типы это Mutation и Subscription), в частности, определено поле planets, соответственно, надо сопоставить ему DataFetcher:


@Singleton
class PlanetsDataFetcher(
    private val planetService: PlanetService,
    private val planetConverter: PlanetConverter
) : DataFetcher<List<PlanetDto>> {
    override fun get(env: DataFetchingEnvironment): List<PlanetDto> = planetService.getAll()
        .map { planetConverter.toDto(it) }
}

PlanetsDataFetcher (исходный код)


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


@Singleton
class PlanetConverter : GenericConverter<Planet, PlanetDto> {
    override fun toDto(entity: Planet): PlanetDto {
        val details = DetailsDto(id = entity.detailsId)

        return PlanetDto(
            id = entity.id,
            name = entity.name,
            type = entity.type,
            details = details
        )
    }
}

PlanetConverter (исходный код)


GenericConverter — это общий интерефейс для преобразования Entity → DTO. Предположим, что details — это тяжёлое поле, в таком случае его надо возвращать, только если оно было реально запрошено клиентом API. Поэтому в примере выше конвертируются только простые поля, а для details заполняется только поле id. Ранее, в определении объекта RuntimeWiring для поля details типа Planet был указан DataFetcher, определяемый так (он использует значение поля details.id, установленное ранее):


@Singleton
class DetailsDataFetcher : DataFetcher<CompletableFuture<DetailsDto>> {

    private val log = LoggerFactory.getLogger(this.javaClass)

    override fun get(env: DataFetchingEnvironment): CompletableFuture<DetailsDto> {
        val planetDto = env.getSource<PlanetDto>()
        log.info("Resolve `details` field for planet: ${planetDto.name}")

        val dataLoader: DataLoader<Long, DetailsDto> = env.getDataLoader("details")

        return dataLoader.load(planetDto.details.id)
    }
}

DetailsDataFetcher (исходный код)


Здесь видно, что возможно возвращать CompletableFuture вместо реального объекта. Можно было бы просто получить сущность Details из DetailsService, но это было бы наивной реализацией, которая приводит к проблеме N+1: например, при таком запросе:


{
  planets {
    name
    details {
      meanRadius
    }
  }
}

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


для поля details каждой из планет был бы сделан отдельный SQL запрос. Чтобы избежать этого используется библиотека java-dataloader; надо определить бины BatchLoader и DataLoaderRegistry:


// bean's scope is `Singleton`, because `BatchLoader` is stateless
@Bean
@Singleton
fun detailsBatchLoader(): BatchLoader<Long, DetailsDto> = BatchLoader { keys ->
    CompletableFuture.supplyAsync {
        detailsService.getByIds(keys)
            .map { detailsConverter.toDto(it) }
    }
}

// bean's (default) scope is `Prototype`, because `DataLoader` is stateful
@Bean
fun dataLoaderRegistry() = DataLoaderRegistry().apply {
    val detailsDataLoader = DataLoader.newDataLoader(detailsBatchLoader())
    register("details", detailsDataLoader)
}

BatchLoader и DataLoaderRegistry (исходный код)


BatchLoader делает возможным получения множества объектов Details за один раз. Соответственно, будет выполнено только два SQL вызова вместо N+1. Вы можете убедиться в этом, если выполните GraphQL запрос выше и посмотрите в лог приложения, в котором будут показаны SQL запросы. BatchLoader является stateless объектом, поэтому может быть синглтоном. DataLoader просто указывает на BatchLoader; он stateful, поэтому должен создаваться на каждый запрос, так же как и DataLoaderRegistry. В зависимости от бизес-требований может понадобиться шарить данные между GraphQL запросами, что тоже возможно. Больше информации по батчингу и кэшированию вы найдёте в документации GraphQL Java.


Details в GraphQL схеме объявлен как интерфейс, поэтому в первой части определения объекта RuntimeWiring создаётся объект TypeResolver, который указывает какому конкретному GraphQL типу какой DTO соответствует:


val detailsTypeResolver = TypeResolver { env ->
    when (val details = env.getObject() as DetailsDto) {
        is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
        is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
        else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
    }
}

TypeResolver (исходный код)


При использовании enum’ов в мутациях надо указать как они будут разрешаться:


.type("Type") { builder ->
    builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
}

Обработка enum (исходный код)


После запуска сервиса вы можете перейти по http://localhost:8082/graphiql и увидеть GraphiQL IDE, в которой можно выполнить любой запрос, определённый в схеме; IDE разделена на 3 части: запрос (query/mutation/subscription), ответ и документация:


graphiql


Существуют и другие GraphQL IDE, например, GraphQL Playground и Altair (доступный как desktop приложение, расширение браузера и web-страница). Последний я буду использовать далее:


altair


В документации помимо query, указанных в схеме, присутствует две дополнительных: _service и _entities. Они создаются библиотекой, которая адаптирует GraphQL Java приложения к спецификации Apollo Federation; этот вопрос будет освещён далее.


Если вы перейдёте в тип Planet, то увидите его определение:


altair docs


И комментарий для поля type, и директива @deprecated для поля isRotatingAroundSun указаны в схеме.


В схеме определена одна мутация:


type Mutation {
    createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
}

Мутация (исходный код)


Как и query, мутация позволяет запрашивать поля возвращаемого типа. Обратите внимание, что если вам нужно использовать объект в качестве входного параметра, то должна быть использована структура input вместо type:


input DetailsInput {
    meanRadius: Float!
    mass: MassInput!
    population: Float
}

input MassInput {
    number: Float!
    tenPower: Int!
}

Пример структуры Input


Как и для Query, для мутации должен быть определён DataFetcher:


@Singleton
class CreatePlanetDataFetcher(
    private val objectMapper: ObjectMapper,
    private val planetService: PlanetService,
    private val planetConverter: PlanetConverter
) : DataFetcher<PlanetDto> {

    private val log = LoggerFactory.getLogger(this.javaClass)

    override fun get(env: DataFetchingEnvironment): PlanetDto {
        log.info("Trying to create planet")

        val name = env.getArgument<String>("name")
        val type = env.getArgument<Planet.Type>("type")
        val detailsInputDto = objectMapper.convertValue(env.getArgument("details"), DetailsInputDto::class.java)

        val newPlanet = planetService.create(
            name,
            type,
            detailsInputDto.meanRadius,
            detailsInputDto.mass.number,
            detailsInputDto.mass.tenPower,
            detailsInputDto.population
        )

        return planetConverter.toDto(newPlanet)
    }
}

DataFetcher для мутации (исходный код)


Предположим, что кто-то хочет быть уведомлённым о событии добавления планеты в систему. Для этого может быть использован subscription:


type Subscription {
    latestPlanet: Planet!
}

Subscription (исходный код)


DataFetcher для subscription возвращает Publisher:


@Singleton
class LatestPlanetDataFetcher(
    private val planetService: PlanetService,
    private val planetConverter: PlanetConverter
) : DataFetcher<Publisher<PlanetDto>> {

    override fun get(environment: DataFetchingEnvironment) = planetService.getLatestPlanet().map { planetConverter.toDto(it) }
}

DataFetcher для subscription (исходный код)


Чтобы протестировать работу mutation и subscription откройте два таба любой GraphQL IDE или две разных IDE; в первой подпишитесь таким образом (возможно в IDE потребуется установить subscription URL ws://localhost:8082/graphql-ws):


subscription {
  latestPlanet {
    name
    type
  }
}

Пример subscription


Во второй выполните мутацию:


mutation {
  createPlanet(
    name: "Pluto"
    type: DWARF_PLANET
    details: { meanRadius: 50.0, mass: { number: 0.0146, tenPower: 24 } }
  ) {
    id
  }
}

Пример mutation


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


mutation subscription


Subscription’ы в конфиге Micronaut включаются так:


graphql:
  graphql-ws:
    enabled: true

Включение GraphQL по web sockets (исходный код)


Ещё один пример subscription’ов в Micronaut — это chat application. Для более детальной информации по подпискам смотрите документацию GraphQL Java.


Тесты для query и mutation могут быть написаны так:


@Test
fun testPlanets() {
    val query = """
        {
            planets {
                id
                name
                type
                details {
                    meanRadius
                    mass
                    ... on InhabitedPlanetDetails {
                        population
                    }
                }
            }
        }
    """.trimIndent()

    val response = graphQLClient.sendRequest(query, object : TypeReference<List<PlanetDto>>() {})

    assertThat(response, hasSize(8))
    assertThat(
        response, contains(
            hasProperty("name", `is`("Mercury")),
            hasProperty("name", `is`("Venus")),
            hasProperty("name", `is`("Earth")),
            hasProperty("name", `is`("Mars")),
            hasProperty("name", `is`("Jupiter")),
            hasProperty("name", `is`("Saturn")),
            hasProperty("name", `is`("Uranus")),
            hasProperty("name", `is`("Neptune"))
        )
    )
}

Тест query (исходный код)


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


private val planetFragment = """
    fragment planetFragment on Planet {
        id
        name
        type
        details {
            meanRadius
            mass
            ... on InhabitedPlanetDetails {
                population
            }
        }
    }
""".trimIndent()

@Test
fun testPlanetById() {
    val earthId = 3
    val query = """
        {
            planet(id: $earthId) {
                ... planetFragment
            }
        }

        $planetFragment
    """.trimIndent()

    val response = graphQLClient.sendRequest(query, object : TypeReference<PlanetDto>() {})

    // assertions
}

Тест Query с использованием фрагментов (исходный код)


Также можно использовать variables, тогда тесты будут выглядеть так:


@Test
fun testPlanetByName() {
    val variables = mapOf("name" to "Earth")
    val query = """
        query testPlanetByName(${'$'}name: String!){
            planetByName(name: ${'$'}name) {
                ... planetFragment
            }
        }

        $planetFragment
    """.trimIndent()

    val response = graphQLClient.sendRequest(query, variables, null, object : TypeReference<PlanetDto>() {})

    // assertions
}

Тест Query с использованием фрагментов и переменных (исходный код)


Выглядит немного странно, т. к. в Kotlin в raw strings, или string templates, нельзя экранировать символы, поэтому чтобы указать $ (символ переменной в GraphQL) надо написать ${'$'}.


Инжектируемый GraphQLClient в примерах кода выше — это самописный класс (framework-agnostic за счёт использования библиотеки OkHttp). Существуют и другие Java GraphQL клиенты, например, Apollo GraphQL Client for Android and the JVM, но я их не использовал.


Данные всех трёх сервисов хранятся в in-memory БД H2 и доступны с использованием ORM Hibernate, предоставляемого библиотекой micronaut-data-hibernate-jpa. Базы инициализируются данными во время старта приложений.


Auth service


GraphQL не предоставляет средств для аутентификации и авторизации. В этом проекте я использовал JWT. Auth service отвечает только за выпуск и валидацию JWT и содержит по одной query и mutation:


type Query {
    validateToken(token: String!): Boolean!
}

type Mutation {
    signIn(data: SignInData!): SignInResponse!
}

input SignInData {
    username: String!
    password: String!
}

type SignInResponse {
    username: String!
    token: String!
}

Схема Auth service (исходный код)


Чтобы получить JWT, надо выполнить в GraphQL IDE следующую мутацию (Auth service находится по URL http://localhost:8081/graphql):


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

Получение JWT


Включение Authorization хэдера в последующие запросы (что возможно в Altair и GraphQL Playground IDE) позволит получить доступ к защищённым ресурсам; это будет показано в следующем разделе. Значение хэдера указывается в формате Bearer $JWT.


Работа с JWT в этом проекте осуществляется с помощью библиотеки micronaut-security-jwt.


Satellite service


Схема сервиса выглядит так:


type Query {
    satellites: [Satellite!]!
    satellite(id: ID!): Satellite
    satelliteByName(name: String!): Satellite
}

type Satellite {
    id: ID!
    name: String!
    lifeExists: LifeExists!
    firstSpacecraftLandingDate: Date
}

type Planet @key(fields: "id") @extends {
    id: ID! @external
    satellites: [Satellite!]!
}

enum LifeExists {
    YES,
    OPEN_QUESTION,
    NO_DATA
}

scalar Date

Схема Satellite service (исходный код)


Допустим, в типе Satellite поле lifeExists должно быть засекьюрено. Многие фреймворки предлагаю способ, когда для определённых роутов указываются определённые политики безопасности, но такой подход не может быть применён для ограничения доступа к определённым GraphQL query/mutation/subscription или полям типов, т. к. все GraphQL запросы приходят на один и тот же эндпоинт /graphql. Всё, что можно сделать — это настроить пару GraphQL-specific эндпоинтов (тогда запросы к любым другим эндпоинтам будут запрещены):


micronaut:
  security:
    enabled: true
    intercept-url-map:
      - pattern: /graphql
        httpMethod: POST
        access:
          - isAnonymous()
      - pattern: /graphiql
        httpMethod: GET
        access:
          - isAnonymous()

Security конфиг (исходный код)


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


@Singleton
class LifeExistsDataFetcher(
    private val satelliteService: SatelliteService
) : DataFetcher<Satellite.LifeExists> {
    override fun get(env: DataFetchingEnvironment): Satellite.LifeExists {
        val id = env.getSource<SatelliteDto>().id
        return satelliteService.getLifeExists(id)
    }
}

LifeExistsDataFetcher (исходный код)


Защита определённого поля осуществляется с помощью средств фреймворка и кастомной логики:


@Singleton
class SatelliteService(
    private val repository: SatelliteRepository,
    private val securityService: SecurityService
) {

    // other stuff

    fun getLifeExists(id: Long): Satellite.LifeExists {
        val userIsAuthenticated = securityService.isAuthenticated
        if (userIsAuthenticated) {
            return repository.findById(id)
                .orElseThrow { RuntimeException("Can't find satellite by id=$id") }
                .lifeExists
        } else {
            throw RuntimeException("`lifeExists` property can only be accessed by authenticated users")
        }
    }
}

SatelliteService (исходный код)


Следующий запрос может быть успешно выполнен только если вы укажете Authorization хэдер с полученным JWT (см. предыдущий раздел):


{
  satellite(id: "1") {
    name
    lifeExists
  }
}

Запрос защищённого поля


Сервис валидирует токен автоматически с помощью фреймворка. Секрет хранится в конфиге (в Base64 форме):


micronaut:
  security:
    token:
      jwt:
        enabled: true
        signatures:
          secret:
            validation:
              base64: true
              # In real life, the secret should NOT be under source control (instead of it, for example, in environment variable).
              # It is here just for simplicity.
              secret: 'TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdA=='
              jws-algorithm: HS256

Конфигурация JWT (исходный код)


На практике секрет также может храниться в переменной среды для использования в нескольких сервисах. Вместо хранения секрета можно использовать валидацию JWT (с помощью метода validateToken, показанного в предыдущем разделе).


Такие скаляры как Date, DateTime и некоторые другие могут быть добавлены в GraphQL Java сервис с помощью библиотеки graphql-java-extended-scalars (com.graphql-java:graphql-java-extended-scalars:$graphqlJavaExtendedScalarsVersion в билд-скрипте). Затем требуемые типы надо объявить в схеме (scalar Date) и зарегистрировать:


private fun createRuntimeWiring(): RuntimeWiring = RuntimeWiring.newRuntimeWiring()
    // other stuff
    .scalar(ExtendedScalars.Date)
    .build()

Регистрация дополнительного скаляра (исходный код)


После чего они могут быть использованы как обычно:


{
  satelliteByName(name: "Moon") {
    firstSpacecraftLandingDate
  }
}

Request


{
  "data": {
    "satelliteByName": {
      "firstSpacecraftLandingDate": "1959-09-13"
    }
  }
}

Response


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


{
  planet(id: "1") {
    star {
      planets {
        star {
          planets {
            star {
              ... # more deep nesting!
            }
          }
        }
      }
    }
  }
}

Пример “дорогой” query


Чтобы сделать такой запрос невалидным, надо использовать MaxQueryDepthInstrumentation. Для ограничения сложности query может быть использована MaxQueryComplexityInstrumentation; она опционально принимает FieldComplexityCalculator, в котором возможно более тонко настроить критерии вычисления сложности поля. Следующий пример кода показывает пример использования нескольких инструментаций (указанный FieldComplexityCalculator вычисляет сложность так же, как дефолтный, — основываясь на предположении, что сложность каждого поля равна 1):


return GraphQL.newGraphQL(transformedGraphQLSchema)
    // other stuff
    .instrumentation(
        ChainedInstrumentation(
            listOf(
                FederatedTracingInstrumentation(),
                MaxQueryComplexityInstrumentation(50, FieldComplexityCalculator { env, child ->
                    1 + child
                }),
                MaxQueryDepthInstrumentation(5)
            )
        )
    )
    .build()

Настройка инструментации (исходный код)


Обратите внимание, что если вы укажете MaxQueryDepthInstrumentation и/или MaxQueryComplexityInstrumentation, то документация сервиса может перестать отображаться в IDE, т. к. IDE попытается выполнить IntrospectionQuery, имеющую заметные глубину и сложность (этот вопрос обсуждался на GitHub). FederatedTracingInstrumentation используется, чтобы сервис генерировал метрики производительности и возвращал их Apollo Gateway вместе с респонсами (далее эти метрики могли бы быть отправлены Apollo Graph Manager; похоже, для использования этой функции нужна подписка). Для получения дополнительных сведений по инструментации используйте документацию GraphQL Java.


GraphQL запросы возможно кастомизировать. В различных фреймворках это делается по-разному, например, в Micronaut:


@Singleton
// mark it as primary to override the default one
@Primary
class HeaderValueProviderGraphQLExecutionInputCustomizer : DefaultGraphQLExecutionInputCustomizer() {

    override fun customize(executionInput: ExecutionInput, httpRequest: HttpRequest<*>): Publisher<ExecutionInput> {
        val context = HTTPRequestHeaders { headerName ->
            httpRequest.headers[headerName]
        }

        return Publishers.just(executionInput.transform {
            it.context(context)
        })
    }
}

Пример GraphQLExecutionInputCustomizer (исходный код)


Этот кастомайзер даёт FederatedTracingInstrumentation доступ к хэдерам и, соответственно, возможность проверить, пришёл ли запрос от Apollo Server или напрямую к сервису, в зависимости от чего возвращаются или нет метрики производительности.


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


@Singleton
class CustomDataFetcherExceptionHandler : SimpleDataFetcherExceptionHandler() {

    private val log = LoggerFactory.getLogger(this.javaClass)

    override fun onException(handlerParameters: DataFetcherExceptionHandlerParameters): DataFetcherExceptionHandlerResult {
        val exception = handlerParameters.exception
        log.error("Exception while GraphQL data fetching", exception)

        val error = object : GraphQLError {
            override fun getMessage(): String = "There was an error: ${exception.message}"

            override fun getErrorType(): ErrorType? = null

            override fun getLocations(): MutableList<SourceLocation>? = null
        }

        return DataFetcherExceptionHandlerResult.newResult().error(error).build()
    }
}

Кастомный обработчик исключений (исходный код)


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


type Planet @key(fields: "id") {
    id: ID!
    name: String!
    # from an astronomical point of view
    type: Type!
    isRotatingAroundSun: Boolean! @deprecated(reason: "Now it is not in doubt. Do not use this field")
    details: Details!
}

Определение типа Planet в Planet service (исходный код)


Satellite service добавляет к сущности Planet поле satellites (которое, как следует из его определения, является non-nullable и может содержать только non-nullable элементы):


type Satellite {
    id: ID!
    name: String!
    lifeExists: LifeExists!
    firstSpacecraftLandingDate: Date
}

type Planet @key(fields: "id") @extends {
    id: ID! @external
    satellites: [Satellite!]!
}

Расширение типа Planet в Satellite service (исходный код)


В терминологии Apollo Federation Planet — это entity — тип, на который можно ссылаться в других сервисах (в данном случае в Satellite service, который определяет stub для типа Planet). Объявление сущности осуществляется с помощью добавления директивы @key в определение типа, которая указывает другим сервисам, какие поля использовать для однозначной идентификации определённого инстанса типа. Аннотация @extends говорит о том, что тип Planet — это сущность, определённая в другом сервисе (в данном случае в Planet service). Информацию по ключевым концепциям Apollo Federation вы можете найти в документации Apollo.


Существует две библиотеки для поддержки Apollo Federation; обе работают поверх GraphQL Java, но не подошли этому проекту:


  • GraphQL Kotlin


    Это набор библиотек, написанных на Kotlin и использующих code-first подход (без необходимости создавать схему). Проект содержит модуль graphql-kotlin-federation, но похоже, что эту библиотеку можно использовать только в связке с оcтальными.


  • Apollo Federation on the JVM


    Разработка проекта идёт довольно вяло и API мог бы быть удобнее.



Я решил отрефакторить вторую библиотеку для улучшения API; проект находится на GitHub.


Чтобы указать, как получить определённый инстанс сущности Planet надо определить объект типа FederatedEntityResolver (по существу, он говорит о том, чем запонить поле Planet.satellites); далее этот резолвер передаётся в FederatedSchemaBuilder:


@Bean
@Singleton
fun graphQL(resourceResolver: ResourceResolver): GraphQL {

    // other stuff

    val planetEntityResolver = object : FederatedEntityResolver<Long, PlanetDto>("Planet", { id ->
        log.info("`Planet` entity with id=$id was requested")
        val satellites = satelliteService.getByPlanetId(id)
        PlanetDto(id = id, satellites = satellites.map { satelliteConverter.toDto(it) })
    }) {}

    val transformedGraphQLSchema = FederatedSchemaBuilder()
        .schemaInputStream(schemaInputStream)
        .runtimeWiring(createRuntimeWiring())
        .federatedEntitiesResolvers(listOf(planetEntityResolver))
        .build()

    // other stuff
}

Определение бина типа GraphQL в Satellite service (исходный код)


Эта библиотека генерирует две дополнительных query (_service and _entities), которые будут использованы Apollo Server. Эти query предназначены для внутреннего применения, то есть они не будут выставлены наружу Apollo Server’ом. Сервис с поддержкой Apollo Federation по-прежнему может работать и в standalone-режиме. API библиотеки может измениться в будущем.


Apollo Server


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


  • создать единую точку доступа к GraphQL сервисам для их клиентов


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



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


Существует и другой способ создания единой схемы — schema stitching — но на сайте Apollo он отмечен как deprecated. Однако, разрабатывается библиотека, реализующая этот подход: Nadel. Она написана создателями GraphQL Java и не имеет ничего общего с Apollo Federation; я не пробовал этот подход.


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


{
  "name": "api-gateway",
  "main": "gateway.js",
  "scripts": {
    "start-gateway": "nodemon gateway.js"
  },
  "devDependencies": {
    "concurrently": "5.1.0",
    "nodemon": "2.0.2"
  },
  "dependencies": {
    "@apollo/gateway": "0.12.0",
    "apollo-server": "2.10.0",
    "graphql": "14.6.0"
  }
}

Мета-информация, зависимости и другое (исходный код)


const {ApolloServer} = require("apollo-server");
const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");

class AuthenticatedDataSource extends RemoteGraphQLDataSource {
    willSendRequest({request, context}) {
        request.http.headers.set('Authorization', context.authHeaderValue);
    }
}

const gateway = new ApolloGateway({
    serviceList: [
        {name: "auth-service", url: "http://localhost:8081/graphql"},
        {name: "planet-service", url: "http://localhost:8082/graphql"},
        {name: "satellite-service", url: "http://localhost:8083/graphql"}
    ],
    buildService({name, url}) {
        return new AuthenticatedDataSource({url});
    },
});

const server = new ApolloServer({
    gateway, subscriptions: false, context: ({req}) => ({
        authHeaderValue: req.headers.authorization
    })
});

server.listen().then(({url}) => {
    console.log(` Server ready at ${url}`);
});

Определение Apollo Server (исходный код)


Возможно, этот код может быть упрощён (особенно в части передачи хэдера авторизации); если так, не стесняйтесь поправить.


Аутентификация продолжит работать, как было описано ранее (надо указать Authorization хэдер и его значение). Также становится возможным изменить реализацию security, например, переместить логику валидации JWT из нижележащих сервисов в модуль apollo-server.


Для запуска этого сервиса убедитесь, что запущены 3 GraphQL Java сервиса, описанные ранее, cd в папку apollo-server, и выполните следующее:


npm install
npm run start-gateway

Успешный запуск будет выглядеть так:


[nodemon] 2.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node gateway.js`
� Server ready at http://localhost:4000/
[INFO] Sat Feb 15 2020 13:22:37 GMT+0300 (Moscow Standard Time) apollo-gateway: Gateway successfully loaded schema.
        * Mode: unmanaged

Лог старта Apollo Server


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


altair apollo server


Также в браузере по адресу http://localhost:4000/playground вы можете использовать Playground IDE.


Обратите внимание, что сейчас, даже если вы ограничили query с помощью MaxQueryComplexityInstrumentation и/или MaxQueryDepthInstrumentation с разумными параметрами как было показано выше, GraphQL IDE отображает документацию. Это происходит потому, что Apollo Server получает схему каждого сервиса с помощью простой query { _service { sdl } } вместо основательной IntrospectionQuery.


В настоящий момент есть некоторые ограничения такой архитектуры, с которыми я столкнулся реализуя проект:



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


Заключение


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


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


Источник: habr.ru