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

Каждый компонент архитектуры освещает несколько вопросов, которые могут возникнуть в процессе разработки 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), ответ и документация:

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

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

И комментарий для поля 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
Подписанный клиент будет уведомлён о событии:

Subscription’ы в конфиге Micronaut включаются так:
graphql: graphql-ws: enabled: true
Включение GraphQL по WebSocket (исходный код)
Ещё один пример 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, но не подошли этому проекту:
Это набор библиотек, написанных на Kotlin и использующих code-first подход (без необходимости создавать схему). Проект содержит модуль
graphql-kotlin-federation, но похоже, что эту библиотеку можно использовать только в связке с оcтальными.
Разработка проекта идёт довольно вяло и 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 запросов ко всем сервисам:

Также в браузере по адресу http://localhost:4000/playground вы можете использовать Playground IDE.
Обратите внимание, что сейчас, даже если вы ограничили query с помощью MaxQueryComplexityInstrumentation и/или MaxQueryDepthInstrumentation с разумными параметрами как было показано выше, GraphQL IDE отображает документацию. Это происходит потому, что Apollo Server получает схему каждого сервиса с помощью простой query { _service { sdl } } вместо основательной IntrospectionQuery.
В настоящий момент есть некоторые ограничения такой архитектуры, с которыми я столкнулся реализуя проект:
subscription’ы не поддерживаются Apollo Gateway’ем (но по-прежнему работают в standalone GraphQL Java сервисе)
Именно поэтому в Planet service было указано
.excludeSubscriptionsFromApolloSdl(true).
сервису, пытающемуся расширить GraphQL интерфейс, требуется информация о его конкретных имплементациях
Приложение, написанное на любом языке/фреймворке, может быть добавлено в качестве downstream сервиса под Apollo Server’ом, если оно реализует спецификацию Federation; список библиотек, добавляющих поддержку этой спецификации доступен в документации Apollo.
Заключение
В этой статье я постарался просуммировать свой опыт работы с GraphQL на JVM. Также было показано как объединить API нескольких GraphQL Java сервисов для получения единого GraphQL API; в подобной архитектуре сущность может быть распределена между несколькими микросервисами. Это достигается за счёт использования Apollo Server, Apollo Federation и библиотеки graphql-java-federation. Исходный код рассмотренного проекта доступен на GitHub. Благодарю за внимание!
