GraphQL — это язык запросов для API, разработанный Facebook. В сегодняшней статье вы увидите пример того, как реализовать API-интерфейс GraphQL в JVM, особенно с использованием языка Kotlin и платформы Micronaut . Большинство приведенных ниже примеров можно использовать в других средах Java / Kotlin. Затем мы рассмотрим, как объединить несколько сервисов GraphQL в один граф данных, чтобы обеспечить единый интерфейс для запроса всех ваших источников данных. Это реализовано с использованием Apollo Server и Apollo Federation . Наконец, будет получена следующая архитектура:
Каждый компонент архитектуры отвечает на несколько вопросов, которые могут возникнуть при реализации GraphQL API. Модель предметной области включает данные о планетах Солнечной системы и их спутниках.
Предпосылки
Вам также может понравиться: Микросервисы на практике: от архитектуры к развертыванию
Планета Сервис
Основные зависимости, связанные с GraphQL, приведены ниже:
Котлин
1
implementation("io.micronaut.graphql:micronaut-graphql:$micronautGraphQLVersion")
2
implementation("io.gqljf:graphql-java-federation:$graphqlJavaFederationVersion")
GraphQL зависимости ( исходный код )
Первый обеспечивает интеграцию между GraphQL Java и Micronaut, т. Е. Определяет общие компоненты, такие как контроллер GraphQL и другие. Контроллер GraphQL — это просто обычный контроллер с точки зрения Spring и Micronaut; он обрабатывает запросы GET и POST к /graphql
пути. Вторая зависимость — это библиотека, которая добавляет поддержку Apollo Federation в приложения, использующие GraphQL Java.
Схема GraphQL написана на языке определения схемы (SDL) и находится в ресурсах сервиса:
Джава
xxxxxxxxxx
1
type Query {
2
planets: [Planet!]!
3
planet(id: ID!): Planet
4
planetByName(name: String!): Planet
5
}
6
7
type Mutation {
8
createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
9
}
10
11
type Subscription {
12
latestPlanet: Planet!
13
}
14
15
type Planet (fields: "id") {
16
id: ID!
17
name: String!
18
# from an astronomical point of view
19
type: Type!
20
isRotatingAroundSun: Boolean! (reason: "Now it is not in doubt. Do not use this field")
21
details: Details!
22
}
23
24
interface Details {
25
meanRadius: Float!
26
mass: BigDecimal!
27
}
28
29
type InhabitedPlanetDetails implements Details {
30
meanRadius: Float!
31
mass: BigDecimal!
32
# in billions
33
population: Float!
34
}
35
36
type UninhabitedPlanetDetails implements Details {
37
meanRadius: Float!
38
mass: BigDecimal!
39
}
40
41
enum Type {
42
TERRESTRIAL_PLANET
43
GAS_GIANT
44
ICE_GIANT
45
DWARF_PLANET
46
}
47
48
input DetailsInput {
49
meanRadius: Float!
50
mass: MassInput!
51
population: Float
52
}
53
54
input MassInput {
55
number: Float!
56
tenPower: Int!
57
}
58
59
scalar BigDecimal
Схема сервиса Планета ( исходный код )
Planet.id
Поле имеет тип, ID
который является одним из 5 скалярных типов по умолчанию . GraphQL Java добавляет несколько скалярных типов и предоставляет возможность писать собственные скаляры. Наличие восклицательного знака после имени типа означает, что поле не может быть пустым и наоборот (вы можете заметить сходство между Kotlin и GraphQL в их способности определять типы, допускающие значения NULL). @directive
будет обсуждаться позже. Чтобы узнать больше о схемах GraphQL и их синтаксисе, смотрите, например, официальное руководство . Если вы используете IntelliJ IDEA, вы можете установить плагин JS GraphQL для работы со схемами.
Существует два подхода к разработке GraphQL API:
-
Схема-первых
Сначала спроектируйте схему (и, следовательно, API), затем внедрите ее в код
-
кода первой
Схема генерируется автоматически на основе кода
У обоих есть свои плюсы и минусы; Вы можете найти больше по теме в этом сообщении в блоге . Для этого проекта (и для статьи) я решил использовать схему в первую очередь. Вы можете найти инструмент для любого подхода на этой странице .
В конфигурации приложения Micronaut есть опция, которая включает GraphQL IDE — GraphiQL — что позволяет делать запросы GraphQL из браузера:
YAML
xxxxxxxxxx
1
graphql
2
graphiql
3
enabledtrue
Включение GraphiQL ( исходный код )
Основной класс не содержит ничего необычного:
Котлин
xxxxxxxxxx
1
object PlanetServiceApplication {
2
3
@JvmStatic
4
fun main(args: Array<String>) {
5
Micronaut.build()
6
.packages("io.graphqlfederation.planetservice")
7
.mainClass(PlanetServiceApplication.javaClass)
8
.start()
9
}
10
}
Основной класс ( исходный код )
Бин GraphQL определяется следующим образом:
Котлин
xxxxxxxxxx
1
@Bean
2
@Singleton
3
fun graphQL(resourceResolver: ResourceResolver): GraphQL {
4
val schemaInputStream = resourceResolver.getResourceAsStream("classpath:schema.graphqls").get()
5
val transformedGraphQLSchema = FederatedSchemaBuilder()
6
.schemaInputStream(schemaInputStream)
7
.runtimeWiring(createRuntimeWiring())
8
.excludeSubscriptionsFromApolloSdl(true)
9
.build()
10
11
return GraphQL.newGraphQL(transformedGraphQLSchema)
12
.instrumentation(
13
ChainedInstrumentation(
14
listOf(
15
FederatedTracingInstrumentation()
16
// uncomment if you need to enable the instrumentations. but this may affect showing documentation in a GraphQL client
17
// MaxQueryComplexityInstrumentation(50),
18
// MaxQueryDepthInstrumentation(5)
19
)
20
)
21
)
22
.build()
23
}
Конфигурация GraphQL ( исходный код )
FederatedSchemaBuilder
class делает приложение GraphQL адаптированным к спецификации федерации Apollo . Если вы не собираетесь объединять несколько Java-сервисов GraphQL в один граф, конфигурация будет другой (см. Это руководство ).
RuntimeWiring
объект — это спецификация сборщиков данных, распознавателей типов и пользовательских скаляров, которые необходимы для соединения функционала GraphQLSchema
; это определяется следующим образом:
Котлин
xxxxxxxxxx
1
private fun createRuntimeWiring(): RuntimeWiring {
2
val detailsTypeResolver = TypeResolver { env ->
3
when (val details = env.getObject() as DetailsDto) {
4
is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
5
is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
6
else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
7
}
8
}
9
10
return RuntimeWiring.newRuntimeWiring()
11
.type("Query") { builder ->
12
builder
13
.dataFetcher("planets", planetsDataFetcher)
14
.dataFetcher("planet", planetDataFetcher)
15
.dataFetcher("planetByName", planetByNameDataFetcher)
16
}
17
.type("Mutation") { builder ->
18
builder.dataFetcher("createPlanet", createPlanetDataFetcher)
19
}
20
.type("Subscription") { builder ->
21
builder.dataFetcher("latestPlanet", latestPlanetDataFetcher)
22
}
23
.type("Planet") { builder ->
24
builder.dataFetcher("details", detailsDataFetcher)
25
}
26
.type("Details") { builder ->
27
builder.typeResolver(detailsTypeResolver)
28
}
29
.type("Type") { builder ->
30
builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
31
}
32
.build()
33
}
Создание RuntimeWiring
объекта ( исходный код )
Например, для корневого типа Query
(например, других корневых типов Mutation
и Subscription
) planets
поле определено в схеме, поэтому для него необходимо указать DataFetcher
:
Котлин
xxxxxxxxxx
1
@Singleton
2
class PlanetsDataFetcher(
3
private val planetService: PlanetService,
4
private val planetConverter: PlanetConverter
5
) : DataFetcher<List<PlanetDto>> {
6
override fun get(env: DataFetchingEnvironment): List<PlanetDto> = planetService.getAll()
7
.map { planetConverter.toDto(it) }
8
}
PlanetsDataFetcher
( исходный код )
Здесь env
входной параметр содержит весь контекст, необходимый для извлечения значения. Метод просто получает все элементы из хранилища и преобразует их в DTO. Преобразование выполняется следующим образом:
Котлин
xxxxxxxxxx
1
@Singleton
2
class PlanetConverter : GenericConverter<Planet, PlanetDto> {
3
override fun toDto(entity: Planet): PlanetDto {
4
val details = DetailsDto(id = entity.detailsId)
5
6
return PlanetDto(
7
id = entity.id,
8
name = entity.name,
9
type = entity.type,
10
details = details
11
)
12
}
13
}
PlanetConverter
( исходный код )
GenericConverter
это просто общий интерфейс для Entity → DTO
преобразования. Давайте предположим, что details
это тяжелое поле, тогда мы должны возвращать его, только если оно было запрошено. Таким образом, в приведенном выше фрагменте преобразуются только простые свойства, а для details
объекта id
заполняется только поле. Ранее, в определении RuntimeWiring
объекта, DataFetcher
для details
поля Planet
типа был определен; это определяется следующим образом (необходимо знать значение details.id
поля):
Котлин
xxxxxxxxxx
1
@Singleton
2
class DetailsDataFetcher : DataFetcher<CompletableFuture<DetailsDto>> {
3
4
private val log = LoggerFactory.getLogger(this.javaClass)
5
6
override fun get(env: DataFetchingEnvironment): CompletableFuture<DetailsDto> {
7
val planetDto = env.getSource<PlanetDto>()
8
log.info("Resolve `details` field for planet: ${planetDto.name}")
9
10
val dataLoader: DataLoader<Long, DetailsDto> = env.getDataLoader("details")
11
12
return dataLoader.load(planetDto.details.id)
13
}
14
}
DetailsDataFetcher
( исходный код )
Здесь вы видите, что можно вернуть CompletableFuture
вместо реального объекта. Более простым было бы просто получить Details
сущность DetailsService
, но это была бы наивная реализация, которая приводит к проблеме N + 1: если бы мы сделали запрос GraphQL, скажем:
Джава
xxxxxxxxxx
1
{
2
planets {
3
name
4
details {
5
meanRadius
6
}
7
}
8
}
Пример возможного ресурсоемкого запроса GraphQL
тогда для каждого details
поля планеты будет сделан отдельный вызов SQL. Чтобы предотвратить это, используется библиотека java-dataloader ; BatchLoader
и DataLoaderRegistry
бобы должны быть определены:
Котлин
xxxxxxxxxx
1
// bean's scope is `Singleton`, because `BatchLoader` is stateless
2
@Bean
3
@Singleton
4
fun detailsBatchLoader(): BatchLoader<Long, DetailsDto> = BatchLoader { keys ->
5
CompletableFuture.supplyAsync {
6
detailsService.getByIds(keys)
7
.map { detailsConverter.toDto(it) }
8
}
9
}
10
11
// bean's (default) scope is `Prototype`, because `DataLoader` is stateful
12
@Bean
13
fun dataLoaderRegistry() = DataLoaderRegistry().apply {
14
val detailsDataLoader = DataLoader.newDataLoader(detailsBatchLoader())
15
register("details", detailsDataLoader)
16
}
BatchLoader
и DataLoaderRegistry
( исходный код )
BatchLoader
позволяет получить кучу Details
сразу. Следовательно, вместо N + 1 запросов будут выполняться только два вызова SQL. Вы можете убедиться в этом, сделав запрос GraphQL выше и увидев в журнале приложения, где будут отображаться реальные запросы SQL. BatchLoader
не имеет состояния, поэтому может быть одноэлементным объектом. DataLoader
просто указывает на BatchLoader
; поэтому он является состоянием, поэтому его следует создавать как для запроса, так и для DataLoaderRegistry
. В зависимости от ваших бизнес-требований вам может потребоваться обмен данными между веб-запросами, что также возможно. Подробнее о пакетировании и кэшировании можно найти в документации по GraphQL Java .
Details
В GraphQL схема определяется как интерфейс, поэтому в первой части RuntimeWiring
определения TypeResolver
объекта создается объект для указания, к какому конкретному типу GraphQL какой DTO должен быть разрешен:
Котлин
xxxxxxxxxx
1
val detailsTypeResolver = TypeResolver { env ->
2
when (val details = env.getObject() as DetailsDto) {
3
is InhabitedPlanetDetailsDto -> env.schema.getObjectType("InhabitedPlanetDetails")
4
is UninhabitedPlanetDetailsDto -> env.schema.getObjectType("UninhabitedPlanetDetails")
5
else -> throw RuntimeException("Unexpected details type: ${details.javaClass.name}")
6
}
7
}
TypeResolver
( исходный код )
Также необходимо указать значения времени выполнения Java для значений Type
перечисления, определенных в схеме (кажется, что это необходимо только для использования перечисления в мутациях):
Котлин
xxxxxxxxxx
1
.type("Type") { builder ->
2
builder.enumValues(NaturalEnumValuesProvider(Planet.Type::class.java))
3
}
Перечисление обработки ( исходный код )
После запуска службы вы можете перейти http://localhost:8082/graphiql
и увидеть GraphiQL IDE, в которой можно делать любые запросы, определенные в схеме; IDE делится на три части: запрос (запрос / мутация / подписка), ответ и документация:
Существуют и другие среды IDE для GraphQL, например, GraphQL Playground и Altair (которые доступны как настольное приложение, расширение для браузера и веб-страница). Последнее я буду использовать дальше:
В части документации есть два дополнительных запроса, помимо определенных в схеме: _service
и _entities
. Они создаются библиотекой, которая адаптирует приложение к спецификации федерации Apollo; этот вопрос будет обсуждаться позже.
Если вы Planet
перейдете к типу, вы увидите его определение:
И комментарий для type
поля, и @deprecated
директива для isRotatingAroundSun
поля указаны в схеме .
В схеме определена одна мутация :
Джава
xxxxxxxxxx
1
type Mutation {
2
createPlanet(name: String!, type: Type!, details: DetailsInput!): Planet!
3
}
Мутация ( исходный код )
Как запрос, он также позволяет запрашивать поля возвращаемого типа. Обратите внимание, что если вам нужно передать объект в качестве входного параметра, input
вместо запросов следует использовать тип type
:
Джава
xxxxxxxxxx
1
input DetailsInput {
2
meanRadius: Float!
3
mass: MassInput!
4
population: Float
5
}
6
input MassInput {
8
number: Float!
9
tenPower: Int!
10
}
Тип ввода
Что касается запроса, DataFetcher
должно быть определено для мутации:
Котлин
xxxxxxxxxx
1
@Singleton
2
class CreatePlanetDataFetcher(
3
private val objectMapper: ObjectMapper,
4
private val planetService: PlanetService,
5
private val planetConverter: PlanetConverter
6
) : DataFetcher<PlanetDto> {
7
8
private val log = LoggerFactory.getLogger(this.javaClass)
9
10
override fun get(env: DataFetchingEnvironment): PlanetDto {
11
log.info("Trying to create planet")
12
13
val name = env.getArgument<String>("name")
14
val type = env.getArgument<Planet.Type>("type")
15
val detailsInputDto = objectMapper.convertValue(env.getArgument("details"), DetailsInputDto::class.java)
16
17
val newPlanet = planetService.create(
18
name,
19
type,
20
detailsInputDto.meanRadius,
21
detailsInputDto.mass.number,
22
detailsInputDto.mass.tenPower,
23
detailsInputDto.population
24
)
25
26
return planetConverter.toDto(newPlanet)
27
}
28
}
DataFetcher
для мутации ( исходный код )
Предположим, что кто-то хочет получить уведомление о событии добавления планеты. Для этой цели может быть использована подписка:
Джава
xxxxxxxxxx
1
type Subscription {
2
latestPlanet: Planet!
3
}
Подписка ( исходный код )
DataFetcher
Возврат подписки Publisher
:
Котлин
xxxxxxxxxx
1
@Singleton
2
class LatestPlanetDataFetcher(
3
private val planetService: PlanetService,
4
private val planetConverter: PlanetConverter
5
) : DataFetcher<Publisher<PlanetDto>> {
6
7
override fun get(environment: DataFetchingEnvironment) = planetService.getLatestPlanet().map { planetConverter.toDto(it) }
8
}
DataFetcher
для подписки ( исходный код )
Чтобы проверить мутацию и подписку, откройте две вкладки любой GraphQL IDE или двух разных IDE; в первой подписке следующим образом (может потребоваться установить URL подписки ws://localhost:8082/graphql-ws
):
Джава
xxxxxxxxxx
1
subscription {
2
latestPlanet {
3
name
4
type
5
}
6
}
Запрос на подписку
Во втором выполните мутацию так:
Джава
xxxxxxxxxx
1
mutation {
2
createPlanet(
3
name: "Pluto"
4
type: DWARF_PLANET
5
details: { meanRadius: 50.0, mass: { number: 0.0146, tenPower: 24 } }
6
) {
7
id
8
}
9
}
Запрос на мутацию
Подписанный клиент будет уведомлен о создании планеты:
Подписки в Micronaut включаются с помощью следующей опции:
YAML
xxxxxxxxxx
1
graphql
2
graphql-ws
3
enabledtrue
Включение GraphQL через WebSocket ( исходный код )
Другим примером подписок в Micronaut является приложение чата . Дополнительную информацию о подписках смотрите в документации по GraphQL Java .
Тесты на запросы и мутации можно записать так:
Котлин
xxxxxxxxxx
1
@Test
2
fun testPlanets() {
3
val query = """
4
{
5
planets {
6
id
7
name
8
type
9
details {
10
meanRadius
11
mass
12
... on InhabitedPlanetDetails {
13
population
14
}
15
}
16
}
17
}
18
""".trimIndent()
19
20
val response = graphQLClient.sendRequest(query, object : TypeReference<List<PlanetDto>>() {})
21
22
assertThat(response, hasSize(8))
23
assertThat(
24
response, contains(
25
hasProperty("name", `is`("Mercury")),
26
hasProperty("name", `is`("Venus")),
27
hasProperty("name", `is`("Earth")),
28
hasProperty("name", `is`("Mars")),
29
hasProperty("name", `is`("Jupiter")),
30
hasProperty("name", `is`("Saturn")),
31
hasProperty("name", `is`("Uranus")),
32
hasProperty("name", `is`("Neptune"))
33
)
34
)
35
}
Тестовый запрос ( исходный код )
Если часть запроса может быть повторно использована в другом запросе, вы можете использовать фрагменты :
Котлин
xxxxxxxxxx
1
private val planetFragment = """
2
fragment planetFragment on Planet {
3
id
4
name
5
type
6
details {
7
meanRadius
8
mass
9
... on InhabitedPlanetDetails {
10
population
11
}
12
}
13
}
14
""".trimIndent()
15
16
@Test
17
fun testPlanetById() {
18
val earthId = 3
19
val query = """
20
{
21
planet(id: $earthId) {
22
... planetFragment
23
}
24
}
25
26
$planetFragment
27
""".trimIndent()
28
29
val response = graphQLClient.sendRequest(query, object : TypeReference<PlanetDto>() {})
30
31
// assertions
32
}
Проверка запроса с использованием фрагмента ( исходный код )
Чтобы использовать переменные , вы можете написать тесты следующим образом:
Котлин
xxxxxxxxxx
1
@Test
2
fun testPlanetByName() {
3
val variables = mapOf("name" to "Earth")
4
val query = """
5
query testPlanetByName(${'$'}name: String!){
6
planetByName(name: ${'$'}name) {
7
... planetFragment
8
}
9
}
10
11
$planetFragment
12
""".trimIndent()
13
14
val response = graphQLClient.sendRequest(query, variables, null, object : TypeReference<PlanetDto>() {})
15
16
// assertions
17
}
Проверка запроса с использованием фрагмента и переменных ( исходный код )
Этот подход выглядит немного странно, потому что в необработанных строках Kotlin или шаблонах строк вы не можете избежать символа, поэтому для представления $
вам нужно писать ${'$'}
.
Введенный GraphQLClient
в фрагментах выше только самостоятельно написанный класс (это основа агностик, используя библиотеку OkHttp). Существуют и другие клиенты Java GraphQL, например, клиент Apollo GraphQL для Android и JVM , но я их еще не использовал.
Данные всех 3 сервисов хранятся в базах данных H2 в памяти и доступны с помощью Hibernate ORM, предоставляемого micronaut-data-hibernate-jpa
библиотекой. Базы данных инициализируются данными при запуске приложений.
Аут Сервис
GraphQL не предоставляет средств для аутентификации и авторизации. Для этого проекта я решил использовать JWT . Служба аутентификации отвечает только за выдачу и проверку токена JWT и содержит только один запрос и одну мутацию:
Джава
xxxxxxxxxx
1
type Query {
2
validateToken(token: String!): Boolean!
3
}
4
type Mutation {
6
signIn(data: SignInData!): SignInResponse!
7
}
8
input SignInData {
10
username: String!
11
password: String!
12
}
13
type SignInResponse {
15
username: String!
16
token: String!
17
}
Схема аутентификации сервиса ( исходный код )
Чтобы получить JWT, вам нужно выполнить в GraphQL IDE следующую мутацию (URL-адрес службы аутентификации http://localhost:8081/graphql
):
Джава
xxxxxxxxxx
1
mutation {
2
signIn(data: {username: "john_doe", password: "password"}) {
3
token
4
}
5
}
Получение JWT
Включение Authorization
заголовка для дальнейших запросов (возможно в IDE Altair и GraphQL Playground) позволяет получить доступ к защищенным ресурсам; это будет показано в следующем разделе. Значение заголовка должно быть указано как Bearer $JWT
.
Работа с JWT осуществляется с помощью micronaut-security-jwt
библиотеки.
Спутниковая служба
Схема сервиса выглядит так:
Джава
xxxxxxxxxx
1
type Query {
2
satellites: [Satellite!]!
3
satellite(id: ID!): Satellite
4
satelliteByName(name: String!): Satellite
5
}
6
type Satellite {
8
id: ID!
9
name: String!
10
lifeExists: LifeExists!
11
firstSpacecraftLandingDate: Date
12
}
13
type Planet (fields: "id") {
15
id: ID!
16
satellites: [Satellite!]!
17
}
18
enum LifeExists {
20
YES,
21
OPEN_QUESTION,
22
NO_DATA
23
}
24
scalar Date
Схема спутниковой службы ( исходный код )
Скажем в Satellite
поле типа lifeExists
должно быть защищено. Многие фреймворки предлагают подход к безопасности, при котором вам просто нужно указать маршруты и разные политики безопасности для них, но такой подход не может быть использован для защиты некоторых конкретных полей запросов / мутаций / подписок GraphQL или типов, поскольку все запросы отправляются в /graphql
конечная точка. Только вы можете настроить пару конечных точек, специфичных для GraphQL, например, следующим образом (запросы на любые другие конечные точки будут запрещены):
YAML
xxxxxxxxxx
1
micronaut
2
security
3
enabledtrue
4
intercept-url-map
5
pattern /graphql
6
httpMethod POST
7
access
8
isAnonymous()
9
pattern /graphiql
10
httpMethod GET
11
access
12
isAnonymous()
Конфигурация безопасности ( исходный код )
Не рекомендуется помещать логику авторизации, DataFetcher
чтобы не сделать логику приложения хрупкой:
Котлин
xxxxxxxxxx
1
@Singleton
2
class LifeExistsDataFetcher(
3
private val satelliteService: SatelliteService
4
) : DataFetcher<Satellite.LifeExists> {
5
override fun get(env: DataFetchingEnvironment): Satellite.LifeExists {
6
val id = env.getSource<SatelliteDto>().id
7
return satelliteService.getLifeExists(id)
8
}
9
}
LifeExistsDataFetcher
( исходный код )
Защита поля может быть выполнена с использованием средств фреймворка и пользовательской логики:
Котлин
xxxxxxxxxx
1
@Singleton
2
class SatelliteService(
3
private val repository: SatelliteRepository,
4
private val securityService: SecurityService
5
) {
6
7
// other stuff
8
9
fun getLifeExists(id: Long): Satellite.LifeExists {
10
val userIsAuthenticated = securityService.isAuthenticated
11
if (userIsAuthenticated) {
12
return repository.findById(id)
13
.orElseThrow { RuntimeException("Can't find satellite by id=$id") }
14
.lifeExists
15
} else {
16
throw RuntimeException("`lifeExists` property can only be accessed by authenticated users")
17
}
18
}
19
}
SatelliteService
( исходный код )
Следующий запрос может быть успешным, только если вы укажете Authorization
заголовок с полученным JWT (см. Предыдущий раздел):
Джава
xxxxxxxxxx
1
{
2
satellite(id: "1") {
3
name
4
lifeExists
5
}
6
}
Запрос на охраняемое поле
Сервис автоматически проверяет токен, используя платформу. Секрет хранится в файле конфигурации (в форме Base64):
YAML
xxxxxxxxxx
1
micronaut
2
security
3
token
4
jwt
5
enabledtrue
6
signatures
7
secret
8
validation
9
base64true
10
# In real life, the secret should NOT be under source control (instead of it, for example, in environment variable).
11
# It is here just for simplicity.
12
secret'TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdA=='
13
jws-algorithm HS256
Конфигурация JWT ( исходный код )
В реальной жизни секрет может храниться в переменной среды, чтобы делиться ею с несколькими службами. Кроме того, вместо совместного использования валидации JWT может быть использован ( validateToken
метод был показан в предыдущем разделе).
Такие скалярные типы, как Date
, DateTime
и некоторые другие, могут быть добавлены в Java-сервис GraphQL с помощью библиотеки graphql-java-extended-scalars ( com.graphql-java:graphql-java-extended-scalars:$graphqlJavaExtendedScalarsVersion
в скрипте сборки). Затем требуемые типы должны быть объявлены в schema ( scalar Date
) и зарегистрированы:
Котлин
xxxxxxxxxx
1
private fun createRuntimeWiring(): RuntimeWiring = RuntimeWiring.newRuntimeWiring()
2
// other stuff
3
.scalar(ExtendedScalars.Date)
4
.build()
Регистрация дополнительного скалярного типа ( исходный код )
Тогда их можно использовать как другие:
Джава
xxxxxxxxxx
1
{
2
satelliteByName(name: "Moon") {
3
firstSpacecraftLandingDate
4
}
5
}
Запрос
JSON
xxxxxxxxxx
1
{
2
"data": {
3
"satelliteByName": {
4
"firstSpacecraftLandingDate": "1959-09-13"
5
}
6
}
7
}
отклик
Существуют различные угрозы безопасности для вашего API GraphQL (см. Этот контрольный список, чтобы узнать больше). Например, если модель предметной области описанного проекта была немного более сложной, следующий запрос был бы возможен:
Джава
xxxxxxxxxx
1
{
2
planet(id: "1") {
3
star {
4
planets {
5
star {
6
planets {
7
star {
8
... # more deep nesting!
9
}
10
}
11
}
12
}
13
}
14
}
15
}
Пример дорогого запроса
Чтобы сделать такой запрос недействительным, MaxQueryDepthInstrumentation
следует использовать. Чтобы ограничить сложность запроса, MaxQueryComplexityInstrumentation
можно указать; необязательно принимает, FieldComplexityCalculator
в котором можно определить детальные критерии расчета. Следующий фрагмент кода показывает пример того, как применять несколько контрольно-измерительных приборов ( FieldComplexityCalculator
здесь вычисляется сложность, подобная стандартной по умолчанию, исходя из предположения, что стоимость каждого поля равна 1):
Котлин
xxxxxxxxxx
1
return GraphQL.newGraphQL(transformedGraphQLSchema)
2
// other stuff
3
.instrumentation(
4
ChainedInstrumentation(
5
listOf(
6
FederatedTracingInstrumentation(),
7
MaxQueryComplexityInstrumentation(50, FieldComplexityCalculator { env, child ->
8
1 + child
9
}),
10
MaxQueryDepthInstrumentation(5)
11
)
12
)
13
)
14
.build()
Настройте инструментарий ( исходный код )
Обратите внимание, что если вы укажете MaxQueryDepthInstrumentation
и / или MaxQueryComplexityInstrumentation
, то документация службы может перестать отображаться в вашей GraphQL IDE. Это потому, что IDE пытается выполнить, IntrospectionQuery
что имеет значительный размер и сложность (обсуждение этого на GitHub ). FederatedTracingInstrumentation
используется для того, чтобы ваш сервер генерировал трассировки производительности и возвращал их вместе с ответами на Apollo Gateway (который затем может отправлять их в Apollo Graph Manager ; похоже, для использования этой функции требуется подписка). Подробнее об инструментах см. В документации по GraphQL Java .
Есть возможность настраивать запросы; он отличается в разных рамках. В Micronaut, например, это делается следующим образом:
Котлин
xxxxxxxxxx
1
@Singleton
2
// mark it as primary to override the default one
3
@Primary
4
class HeaderValueProviderGraphQLExecutionInputCustomizer : DefaultGraphQLExecutionInputCustomizer() {
5
6
override fun customize(executionInput: ExecutionInput, httpRequest: HttpRequest<*>): Publisher<ExecutionInput> {
7
val context = HTTPRequestHeaders { headerName ->
8
httpRequest.headers[headerName]
9
}
10
11
return Publishers.just(executionInput.transform {
12
it.context(context)
13
})
14
}
15
}
Пример GraphQLExecutionInputCustomizer
( исходный код )
Этот настройщик предоставляет возможность FederatedTracingInstrumentation
проверить, поступил ли запрос с сервера Apollo и нужно ли возвращать данные о производительности.
Чтобы иметь возможность обрабатывать все исключения во время выборки данных в одном месте и определять пользовательскую логику обработки исключений, вам необходимо предоставить bean-компонент следующим образом:
Котлин
xxxxxxxxxx
1
@Singleton
2
class CustomDataFetcherExceptionHandler : SimpleDataFetcherExceptionHandler() {
3
4
private val log = LoggerFactory.getLogger(this.javaClass)
5
6
override fun onException(handlerParameters: DataFetcherExceptionHandlerParameters): DataFetcherExceptionHandlerResult {
7
val exception = handlerParameters.exception
8
log.error("Exception while GraphQL data fetching", exception)
9
10
val error = object : GraphQLError {
11
override fun getMessage(): String = "There was an error: ${exception.message}"
12
13
override fun getErrorType(): ErrorType? = null
14
15
override fun getLocations(): MutableList<SourceLocation>? = null
16
}
17
18
return DataFetcherExceptionHandlerResult.newResult().error(error).build()
19
}
20
}
Пользовательский обработчик исключений ( исходный код )
Основная цель сервиса - показать, как распределенная сущность GraphQL ( Planet
) может быть разрешена в двух сервисах и затем доступна через Apollo Server. Planet
тип был ранее определен в сервисе Планета следующим образом:
Джава
xxxxxxxxxx
1
type Planet (fields: "id") {
2
id: ID!
3
name: String!
4
# from an astronomical point of view
5
type: Type!
6
isRotatingAroundSun: Boolean! (reason: "Now it is not in doubt. Do not use this field")
7
details: Details!
8
}
Определение Planet
типа в сервисе Планета ( исходный код )
Спутниковая служба добавляет satellites
поле (которое содержит только ненулевые элементы и само не может обнуляться, как следует из его объявления) к Planet
объекту:
Джава
xxxxxxxxxx
1
type Satellite {
2
id: ID!
3
name: String!
4
lifeExists: LifeExists!
5
firstSpacecraftLandingDate: Date
6
}
7
type Planet (fields: "id") {
9
id: ID!
10
satellites: [Satellite!]!
11
}
Расширение Planet
типа в спутниковой службе ( исходный код )
В терминах Apollo Federation Planet
это сущность - тип, на который может ссылаться другая служба (в данном случае спутниковая служба, которая определяет заглушку для Planet
типа). Объявление объекта осуществляется путем добавления @key
директивы в определение типа. Эта директива сообщает другим службам, какие поля использовать для уникальной идентификации конкретного экземпляра типа. В @extends
аннотации объявляется, что Planet
это объект, определенный в другом месте (в данном случае в сервисе Планета). Подробнее об основных понятиях федерации Apollo см. В документации Apollo .
Есть две библиотеки для поддержки Федерации Аполлона; оба построены на основе GraphQL Java, но не соответствуют проекту:
-
Это набор библиотек, написанных на Kotlin; он использует подход кода сначала без необходимости определять схему. Проект содержит
graphql-kotlin-federation
модуль, но, похоже, вам нужно использовать эту библиотеку вместе с другими библиотеками проекта. -
Разработка проекта не очень активна, и API можно улучшить.
Поэтому я решил провести реорганизацию второй библиотеки, чтобы улучшить API и сделать ее более удобной. Проект находится на GitHub .
Чтобы определить, как Planet
должен быть выбран конкретный экземпляр FederatedEntityResolver
объекта, определяется объект (в основном, он указывает, что должно быть заполнено в Planet.satellites
поле); тогда преобразователь передается FederatedSchemaBuilder
:
Котлин
xxxxxxxxxx
1
@Bean
2
@Singleton
3
fun graphQL(resourceResolver: ResourceResolver): GraphQL {
4
5
// other stuff
6
7
val planetEntityResolver = object : FederatedEntityResolver<Long, PlanetDto>("Planet", { id ->
8
log.info("`Planet` entity with id=$id was requested")
9
val satellites = satelliteService.getByPlanetId(id)
10
PlanetDto(id = id, satellites = satellites.map { satelliteConverter.toDto(it) })
11
}) {}
12
13
val transformedGraphQLSchema = FederatedSchemaBuilder()
14
.schemaInputStream(schemaInputStream)
15
.runtimeWiring(createRuntimeWiring())
16
.federatedEntitiesResolvers(listOf(planetEntityResolver))
17
.build()
18
19
// other stuff
20
}
Определение bean-компонента GraphQL в сервисе Satellite ( исходный код )
Библиотека генерирует два дополнительных запроса ( _service
и _entities
), которые будут использоваться сервером Apollo. Эти запросы являются внутренними, то есть они не будут выставлены сервером Apollo. Сервис с поддержкой Федерации Apollo все еще может работать независимо. API библиотеки может измениться в будущем.
Apollo Server
Apollo Server и Apollo Federation позволяют достичь 2 основных целей:
-
создать единую конечную точку для клиентов API GraphQL
-
создать единый граф данных из распределенных объектов
То есть, даже если вы не используете федеративные объекты, разработчикам внешнего интерфейса удобнее использовать одну конечную точку, чем несколько конечных точек.
Существует еще один способ создания единой схемы GraphQL - сшивание схемы, но теперь на сайте Apollo оно помечено как устаревшее. Однако есть библиотека, которая реализует этот подход: Надель . Он написан создателями GraphQL Java и не имеет ничего общего с Apollo Federation; Я еще не использовал это.
Этот модуль включает в себя следующие источники:
JSON
xxxxxxxxxx
1
{
2
"name": "api-gateway",
3
"main": "gateway.js",
4
"scripts": {
5
"start-gateway": "nodemon gateway.js"
6
},
7
"devDependencies": {
8
"concurrently": "5.1.0",
9
"nodemon": "2.0.2"
10
},
11
"dependencies": {
12
"@apollo/gateway": "0.12.0",
13
"apollo-server": "2.10.0",
14
"graphql": "14.6.0"
15
}
16
}
Мета-информация, зависимости и прочее ( исходный код )
JavaScript
xxxxxxxxxx
1
const {ApolloServer} = require("apollo-server");
2
const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");
3
4
class AuthenticatedDataSource extends RemoteGraphQLDataSource {
5
willSendRequest({request, context}) {
6
request.http.headers.set('Authorization', context.authHeaderValue);
7
}
8
}
9
10
const gateway = new ApolloGateway({
11
serviceList: [
12
{name: "auth-service", url: "http://localhost:8081/graphql"},
13
{name: "planet-service", url: "http://localhost:8082/graphql"},
14
{name: "satellite-service", url: "http://localhost:8083/graphql"}
15
],
16
buildService({name, url}) {
17
return new AuthenticatedDataSource({url});
18
},
19
});
20
21
const server = new ApolloServer({
22
gateway, subscriptions: false, context: ({req}) => ({
23
authHeaderValue: req.headers.authorization
24
})
25
});
26
27
server.listen().then(({url}) => {
28
console.log(`�� Server ready at ${url}`);
29
});
Определение сервера Apollo ( исходный код )
Возможно, приведенный выше источник может быть упрощен (особенно в части прохождения заголовка авторизации); Если это так, не стесняйтесь связаться со мной для изменения.
Аутентификация по-прежнему работает, как было описано ранее (вам просто нужно указать Authorization
заголовок и его значение). Также возможно изменить реализацию безопасности, например, переместить логику проверки JWT из нисходящих сервисов в apollo-server
модуль.
Чтобы запустить этот сервис, вам нужно убедиться, что вы запустили 3 Java-сервиса GraphQL, описанных выше, cd
в apollo-server
каталог и запустили следующее:
Оболочка
xxxxxxxxxx
1
npm install
2
npm run start-gateway
Успешный запуск должен выглядеть так:
Простой текст
xxxxxxxxxx
1
[nodemon] 2.0.2
2
[nodemon] to restart at any time, enter `rs`
3
[nodemon] watching dir(s): *.*
4
[nodemon] watching extensions: js,mjs,json
5
[nodemon] starting `node gateway.js`
6
� Server ready at http://localhost:4000/
7
[INFO] Sat Feb 15 2020 13:22:37 GMT+0300 (Moscow Standard Time) apollo-gateway: Gateway successfully loaded schema.
8
* Mode: unmanaged
Журнал запуска сервера Apollo
Затем вы можете использовать унифицированный интерфейс для выполнения запросов GraphQL ко всем вашим сервисам:
Кроме того, вы можете перейти http://localhost:4000/playground
в свой браузер и использовать встроенную игровую среду IDE.
Обратите внимание, что теперь, даже если вы установили ограничения для запросов, использующих MaxQueryComplexityInstrumentation
и / или MaxQueryDepthInstrumentation
с приемлемыми параметрами, как было описано выше, GraphQL IDE действительно показывает объединенную документацию. Это связано с тем, что Apollo Server получает схему каждой службы, выполняя простой { _service { sdl } }
запрос вместо большого IntrospectionQuery
.
В настоящее время существуют некоторые ограничения такой архитектуры, с которыми я столкнулся при реализации этого проекта:
-
Apollo Gateway не поддерживает подписки (но все еще работает в автономном Java-сервисе GraphQL)
Именно поэтому в сервисе Планета
.excludeSubscriptionsFromApolloSdl(true)
был указан. -
сервис, пытающийся расширить интерфейс GraphQL, требует знания конкретных реализаций
Приложение, написанное на любом языке или в любой среде, может быть добавлено в качестве нисходящего сервиса Apollo Server, если оно реализует спецификацию федерации ; Список библиотек, предлагающих такую поддержку, доступен в документации Apollo .
Заключение
В этой статье я попытался обобщить свой опыт работы с GraphQL на JVM. Также я показал, как объединять API Java-сервисов GraphQL для создания единого интерфейса GraphQL; в такой архитектуре объект может быть распределен по нескольким микросервисам. Это достигается с помощью Apollo Server, Apollo Federation и библиотеки graphql-java-federation . Исходный код рассматриваемого проекта есть на GitHub . Спасибо за чтение!