Как 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. Благодарю за внимание!

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

ссылка на оригинал статьи https://habr.com/ru/post/490002/


Комментарии

Добавить комментарий

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