В предыдущей статье мы поговорили о том, что такое graphQL, почему решили на него переходить, какие у него есть достоинства и недостатки. Но что делать дальше, если вы всё-таки решились внедрить graphQL в java-проект? Какие на данный момент есть фреймворки, чем они отличаются и какой вообще выбрать? Именно с этими проблемами мы и столкнулись почти год назад и не нашли адекватного ответа в одном источнике. Собрав по крупицам инфу из разных ресурсов (статьи, документации, доклады) и потыкав палочкой несколько фреймворков, мы сформировали общую базу знаний о них, которой и захотелось поделиться.
Если вам интересно, какие есть современные graphQL-фреймворки на java, как выбрать подходящий и посмотреть, как он может выглядеть в продакшне — эта статья для вас.
Всем привет! Меня зовут Артем, я бэкенд-разработчик в hh.ru. Сегодня мы поговорим о современных решениях в GraphQL-мире на Java.
Статья также доступна в видеоформате.
Тишина в библиотеке!
Сегодня почти на всех популярных языках программирования есть хотя бы одно GraphQL-решение. Конечно, больше всего эта тема развита в JS-мире. Java скорее на “догоняющей” позиции, но это не значит, что там всё сыро и грустно. Эти решения вполне production-ready, большинство из них проверены временем, и ими спокойно можно пользоваться.
GraphQL-java
Первое, на что стоит обратить внимание — библиотека Graphql-java. Это настоящий движок GraphQL, и более того – он единственный. А значит, какими бы фреймворками вы ни пользовались, в итоге в кишочках всё равно будет использована именно эта либа. Реализация data fetching, работа с контекстом, обработка ошибок, мониторинг, ограничение запроса, видимость полей и даже dataloader — всё это уже реализовано в движке. Соответственно, можно его использовать напрямую или менять фреймворки довольно смело, чтобы определиться, какой подходит вам больше всего. Graphql-java — opensource, она разрабатывается простыми смертными ребятами, а последний коммит был буквально на днях. В общем, этот движок активно развивается.
Впрочем, несмотря на все плюсы вам следует хорошенько подумать, стоит ли использовать её в прямом виде. Мы вот не используем. Эта библиотека низкоуровневая, гибкая, а значит вербозная. Фреймворки в том числе помогают справиться с этим. Разумеется, движок вполне можно использовать напрямую, но это будет менее удобно.
Кроме этой библиотеки я выделил три фреймворка, заслуживающих внимания. Всё остальное — это, в основном, довольно мелкие библиотеки.
Итого
Graphql-java:
Достоинства | Недостатки |
Движок graphql | Вербозная |
Нет аналогов | |
Низкоуровневая | |
Гибкая |
Schema-first vs Code-first
Но для начала давайте рассмотрим два ключевых подхода к проектированию graphql-API на бекенде. Есть два противоборствующих лагеря — schema-first и code-first решения.
При классическом schema-first подходе мы сначала описываем graphql-схему и потом на её основе в коде реализуем модели и data fetchers. Плюсы такого подхода в том, что проектированием и разработкой схемы могут заниматься разные люди и даже департаменты — например, аналитики проектируют схему, а разработчики её имплементируют. Также может быть удобно написать схему и сразу отдать её клиентам, а самим параллельно разрабатывать бекенд. Минусом же можно отметить необходимость в реализации как схемы, так и кода — это может занимать чуть больше времени при разработке API + теперь появляется 2 источника, которые обязаны не конфликтовать друг с другом и быть полностью синхронизированы - лишнее звено, которое может поломаться.
При code-first подходе мы пишем только код и на основе аннотаций фреймворк сам генерирует схему. Здесь у нас только 1 источник правды, но и graphql-схему без кода не построишь.
Итоговое сравнение
Code-first | Schema-first |
1 источник правды - код | Описываем и схему, и код |
Нет кода - нет схемы | Сначала разрабатываем схему, на основе неё пишем код |
Domain Graph Service
И первый фреймворк, на который мы обратим внимание — это DGS (Domain Graph Service). Если вы были на докладе Пола Беккера на JPoint 2021 — вы уже знаете, о чём я. Остальным рекомендую ознакомиться, доклад очень классный.
Изначально был придуман Netflix в 2019 году, а уже в 2020 его выложили в opensource. И это полноценный фреймворк — он помогает работать с обслуживающим GraphQL-кодом, писать юнит-тесты, предоставляет свой error handling, code-gen для генерации data fetchers на основе схемы, ну и так далее. Это schema-first решение. И всё это production-ready, Netflix на полную его используют.
И всё-таки мы выбрали другое решение.
Во-первых, DGS — schema-first, а нам бы хотелось заиспользовать code-first подход - легче поднять, чуть быстрее разрабатывать, нет необходимости в разработке схемы без кода.
Во-вторых, DGS использует spring boot. И это прекрасно! Но мы внутри компании его не используем - у нас свой фреймворк, который использует чистый spring-core. Конечно, это не значит, что его не получится поднять — у нас получилось завести, предварительно пообщавшись с Полом на тему норм ли вообще поднимать без бута или авторы так не рекомендуют (норм). Но для этого нужно было разобраться в самом коде фреймворка, найти и объявить вручную с десяток незадокументированных и не всегда понятных бинов, которые в новых версиях DGS могут поломаться. В общем, не бесплатно в обслуживании.
Ну и в-третьих, даже несмотря на то, что это — полноценный фреймворк, вам всё равно придётся дописывать его для работы с юнит-тестами, error handling, мониторингом etc. Просто потому что ваш проект растёт и вам не будет хватать существующих решений.
Тем не менее, он очень крутой. Поэтому мы пометили его для себя “звездочкой” — решили, что в случае чего вернемся к нему.
Итого
DGS:
schema-first
opensource от Netflix
На Spring-boot
Полноценный фреймворк
Java SPQR
Следующая либа, которую мы разберём — это Java SPQR.
Проверенная годами opensource-библиотека. Кроме того, это ещё и единственное code-first решение, к тому же не полноценный фреймворк, что довольно круто. Всё, что делает эта либа — реализует code-first подход и немножко помогает работать с обслуживающим GraphQL-кодом. Нас это абсолютно устраивало, на ней мы и остановились.
Но не смотря на наш выбор, сложно советовать её использовать на данный момент, ибо она забросилась. Последний коммит был больше года назад, ответов на issues не было, поддержки тоже нет.
Почему это может быть важно - как пример, graphql поддерживает наследование, и в 2020 году graphql-спека, а потом уже graphql-java, подхватила возможность работы с множественным наследованием интерфейсов. И вот уже 2022 год, но в SPQR нельзя воспользоваться этой новой фичей.
Однако, совсем недавно ментейнер ответил о планах возобновить работу над проектом, что не может не радовать.
Итого
Единственное code-first решение
Минималистичен, не фреймворк
Заброшен, но есть планы по возобновлению проекта
Spring GraphQL
Последний фреймворк, о котором хочется поговорить — это Spring GraphQL.
Совсем свежачок, вышел в июле 2021. Джош Лонг о нём рассказывал на осеннем Joker 2021. Тоже schema-first подход, интеграция со спрингом (спасибо кэп), немного повторяет DGS — также есть свои обработчики ошибок, поддержка написания юнит-тестов, более удобная работа с data fetchers.
Стоит присмотреться, но аккуратно — фреймворк вышел недавно.
Итого
Spring GraphQL:
Schema-first
Интеграция со спрингом
Полноценный фреймворк
Вышел совсем недавно
Ну и как это выглядит?
Теперь давайте создадим простой graphql-сервер. В качестве стандартного стека будем использовать Java и Spring, а в качестве GraphQL — SPQR, в которой используется движок Graphql-java. Го в код!
Полный прототип можно скачать здесь.
GraphQL bean
Для начала создадим главный GraphQL бин, который будет выполнять все запросы.
@Configuration
public class GraphQLConfig {
private final CandidateResolver candidateResolver;
private final ResumeResolver resumeResolver;
public GraphQLConfig(CandidateResolver candidateResolver,
ResumeResolver resumeResolver) {
this.candidateResolver = candidateResolver;
this.resumeResolver = resumeResolver;
}
@Bean
public GraphQLSchema getGraphQLSchema() {
return new GraphQLSchemaGenerator()
.withBasePackages("com.example.graphql.demo.models")
.withOperationsFromSingletons(candidateResolver, resumeResolver)
.generate();
}
@Bean
public GraphQL getGraphQL(GraphQLSchema graphQLSchema) {
return GraphQL.newGraphQL(graphQLSchema)
.queryExecutionStrategy(new AsyncExecutionStrategy())
.instrumentation(new CustomTracingInstrumentation())
.build();
}
}
Для выполнения ему нужно знать схему - GraphQLSchema
- но так как SPQR про code-first подход, то мы заиспользуем генератор схемы, который по полям моделей из корневого package сам её построит.
После этого мы определим ExecutionStrategy
— стратегия выполнения graphql-запроса. По дефолту каждая нода в графе выполняется асинхронно и за это как раз отвечает AsyncExecutionStrategy
, который в случае чего можно поменять.
После этого переопределим инструментейшены (о них поговорим отдельно) и запустим бин.
Endpoint
Нам нужно откуда-то получить запрос, поэтому создадим обычный POST-метод, принимающий query. Он будет един для всех graphql-запросов, в отличие от REST, где на каждый запрос мы делали отдельный метод.
И после этого передадим запрос на выполнение graphql-бину.
@RestController
public class DemoController {
private final GraphQL graphQL;
@Autowired
DemoController(GraphQL graphQL) {
this.graphQL = graphQL;
}
@PostMapping(path = "graphql",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ExecutionResult graphql(@RequestBody EntryPoint entryPoint) {
ExecutionInput executionInput = ExecutionInput.newExecutionInput()
.query(entryPoint.query)
.build();
return graphQL.execute(executionInput);
}
public static class EntryPoint {
public String query;
}
}
Входные точки
Мы описали схему, мы умеем принимать запросы — но где описать входные точки в этот граф? За это в graphql отвечают Data Fetchers (или по-другому Resolvers) — бин, в котором мы опишем ноды графа.
@GraphQLQuery(name = "candidates")
public CompletableFuture<List<Candidate>> getCandidates() {
return CompletableFuture.supplyAsync(candidateService::getCandidates);
}
В данном случае мы создали входную точку candidates
, которая возвращает некую модель Candidate
.
public class Candidate {
private Integer id;
private String firstName;
private String lastName;
private String email;
private String phone;
// getters and setters are omitted
}
Более того, как раз по моделям в резолверах SPQR и построит схему.
Конечно, можно и нужно, чтобы таких нод было как можно больше, чтобы они переплетались между собой, создавая граф. Поэтому создадим ещё одну ноду resumes
и свяжем её с кандидатами с помощью @GraphQLContext
.
@GraphQLQuery(name = "resumes")
public CompletableFuture<List<Resume>> getResumes(@GraphQLContext Candidate candidate) {
return CompletableFuture.supplyAsync(() -> resumeService.getResumes(candidate));
}
public class Resume {
private Integer id;
private String lastExperience;
private Salary salary;
// getters and setters are omitted
}
public class Salary {
private String currency;
private Integer amount;
// getters and setters are omitted
}
Работает это так - если в самом candidates
запросить что-то из resumes
, только тогда отработает этот резолвер.
Instrumentation
Помимо всего прочего, мы наверняка захотим мониторить состояние выполнения запроса: сколько времени выполняется каждый резолвер, сколько выполняется полный запрос, какие ошибки мы можем словить. Для этого при регистрации graphql-бина можно прописать Instrumentations — как дефолтные, так и кастомные.
Технически это класс, имплементящий interface Instrumentation
(в нашем случае отнаследовались от class SimpleInstrumentation
, обычной заглушки, чтобы не реализовывать все методы).
В нём прописаны методы, вызывающиеся при определённом состоянии запроса: когда запрос только начался выполняться, когда вызвался резолвер, когда он закончился выполняться etc.
CustomTracingInstrumentation
public class CustomTracingInstrumentation extends SimpleInstrumentation {
Logger logger = LoggerFactory.getLogger(CustomTracingInstrumentation.class);
static class TracingState implements InstrumentationState {
long startTime;
}
// Cоздаём контекст трэйсинга для конкретного запроса
@Override
public InstrumentationState createState() {
return new TracingState();
}
// Выполняется перед каждым запросом. Инициализируем контекст трейсинга для замеров времени выполнения
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
TracingState tracingState = parameters.getInstrumentationState();
tracingState.startTime = System.currentTimeMillis();
return super.beginExecution(parameters);
}
// Выполняется при завершении запроса. С помощью totalTime мерим время выполнения всего запроса
@Override
public CompletableFuture<ExecutionResult> instrumentExecutionResult(ExecutionResult executionResult, InstrumentationExecutionParameters parameters) {
TracingState tracingState = parameters.getInstrumentationState();
long totalTime = System.currentTimeMillis() - tracingState.startTime;
logger.info("Total execution time: {} ms", totalTime);
return super.instrumentExecutionResult(executionResult, parameters);
}
// Выполняется при каждом вызове DataFetcher/Resolver. С помощью него будем мерить время выполнения каждого резолвера
@Override
public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters) {
// Так как любое поле в графе потенциально может быть резолвером, оставим только те, которые хотя бы что-то делают
if (parameters.isTrivialDataFetcher()) {
return dataFetcher;
}
return environment {
long startTime = System.currentTimeMillis();
Object result = dataFetcher.get(environment);
// Так как все ноды в нашем случае выполняются асинхронно, замерим время только для них
if(result instanceof CompletableFuture) {
((CompletableFuture<?>) result).whenComplete((r, ex); {
long totalTime = System.currentTimeMillis() - startTime;
logger.info("Resolver {} took {} ms", findResolverTag(parameters), totalTime);
});
}
return result;
};
}
// Ветьеватая логика получения имени резолвера и его родителя (для лучшего понимания откуда вызывалась нода)
private String findResolverTag(InstrumentationFieldFetchParameters parameters) {
GraphQLOutputType type = parameters.getExecutionStepInfo().getParent().getType();
GraphQLObjectType parent;
if (type instanceof GraphQLNonNull) {
parent = (GraphQLObjectType) ((GraphQLNonNull) type).getWrappedType();
} else {
parent = (GraphQLObjectType) type;
}
return parent.getName() + "." + parameters.getExecutionStepInfo().getPath().getSegmentName();
}
}
На самом деле, Instrumentation довольно мощный функционал, который можно использовать не только для мониторинга. Например, уже реализованный MaxQueryDepthInstrumentation
из graphql-java мерит максимальную глубину запроса и при превышении отменяет запрос, a с помощью MaxQueryComplexityInstrumentation
можно поставить веса конкретным нодам и управлять сложностью запроса (но с ним есть нюансы, о них ещё поговорим в отдельной статье).
Этого достаточно, чтобы запустить наш сервис.
Сам запрос
{
candidates {
id,
firstName,
lastName,
phone,
email,
resumes {
id,
lastExperience,
salary {
currency,
amount
}
}
}
}
Ответ будет в стандартном для сервиса json-формате
{
"errors": [],
"data": {
"candidates": [
{
"id": 1,
"firstName": "Леонид",
"lastName": "Якубович",
"phone": "88005553535",
"email": "leonid@yakubovich.ru",
"resumes": [
{
"id": 501,
"lastExperience": "Ведущий шоу Поле чудес",
"salary": {
"currency": "RUB",
"amount": 40000
}
}
]
},
{
"id": 2,
"firstName": "Леонид",
"lastName": "Агутин",
"phone": "88005553030",
"email": "hophey@lalaley.com",
"resumes": [
{
"id": 502,
"lastExperience": "Ведущий шоу Голос",
"salary": {
"currency": "RUB",
"amount": 50000
}
}
]
}
]
},
"extensions": null,
"dataPresent": true
}
Заключение
Вот как обстоят graphql-дела в java-мире. Мы рассмотрели разные фреймворки, оценили их достоинства и недостатки, а потом реализовали простенький graphql сервис на java. Надеюсь, вам было полезно.
Конечно, развивая эту технологию, вы наверняка столкнетесь со множеством других кейсов. Их мы обязательно разберём в следующих статьях. Но по крайней мере, сейчас нам удалось создать работающий прототип, и это было относительно легко.