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