В этой статье вы узнаете, как использовать Spring for GraphQL в своем приложении Spring Boot.
Spring for GraphQL — относительно новый проект. Версия 1.0
была выпущена несколько месяцев назад. До этого релиза нам приходилось подключать сторонние библиотеки, чтобы упростить реализацию GraphQL в приложении Spring Boot. Я уже описал два альтернативных решения в своих предыдущих статьях. В следующей статье вы узнаете о проекте GraphQL Java Kickstart. В другой статье вы увидите, как создавать более сложные запросы GraphQL с помощью библиотеки Netflix DGS.
Мы будем использовать очень похожую схему и модель сущностей, как и в этих двух статьях о Spring Boot и GraphQL.
Исходный код
Если вы хотите попробовать сделать это самостоятельно, вы всегда можете посмотреть на мой исходный код. Для этого вам нужно клонировать мой репозиторий GitHub. Затем просто следуйте моим инструкциям.
Во-первых, вы должны перейти в каталог sample-app-spring-graphql
. Наш пример на Spring Boot предоставляет API на базе GraphQL и подключается к базе данных H2 в памяти. Он использует Spring Data JPA в качестве слоя для взаимодействия с базой данных. Есть три сущности Employee
, Department
и Organization
. Каждая из них хранится в отдельной таблице. Вот модель отношений.
Начало работы со Spring for GraphQL
В дополнение к стандартным модулям Spring Boot нам необходимо включить следующие две зависимости:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
spring-graph-test
предоставляет дополнительные возможности для построения модульных тестов. Стартер поставляется с необходимыми библиотеками и автоконфигурацией. Однако он не включает интерфейс GraphiQL. Чтобы включить его, мы должны установить следующее свойство в файле application.yml
:
spring:
graphql:
graphiql:
enabled: true
По умолчанию Spring for GraphQL пытается загрузить файлы схемы из каталога src/main/resources/graphql
. Он ищет там файлы с расширениями .graphqls
или .gqls
. Приведем схему GraphQL для сущности Department
. Тип Department
ссылается на два других типа: Organization
и Employee
(список сотрудников). Есть два запроса для поиска всех отделов и отдела по id и одна мутация для добавления нового отдела.
type Query {
departments: [Department]
department(id: ID!): Department!
}
type Mutation {
newDepartment(department: DepartmentInput!): Department
}
input DepartmentInput {
name: String!
organizationId: Int
}
type Department {
id: ID!
name: String!
organization: Organization
employees: [Employee]
}
Схема типа Organization
очень похожа. Из более сложных вещей нам нужно обрабатывать соединения с типами Employee
и Department
.
extend type Query {
organizations: [Organization]
organization(id: ID!): Organization!
}
extend type Mutation {
newOrganization(organization: OrganizationInput!): Organization
}
input OrganizationInput {
name: String!
}
type Organization {
id: ID!
name: String!
employees: [Employee]
departments: [Department]
}
И последняя схема — для типа Employee
. В отличие от предыдущих схем, она определяет тип, отвечающий за обработку фильтрации. EmployeeFilter может фильтровать по зарплате, должности или возрасту. Существует также метод запроса для обработки фильтрации — employeesWithFilter
.
extend type Query {
employees: [Employee]
employeesWithFilter(filter: EmployeeFilter): [Employee]
employee(id: ID!): Employee!
}
extend type Mutation {
newEmployee(employee: EmployeeInput!): Employee
}
input EmployeeInput {
firstName: String!
lastName: String!
position: String!
salary: Int
age: Int
organizationId: Int!
departmentId: Int!
}
type Employee {
id: ID!
firstName: String!
lastName: String!
position: String!
salary: Int
age: Int
department: Department
organization: Organization
}
input EmployeeFilter {
salary: FilterField
age: FilterField
position: FilterField
}
input FilterField {
operator: String!
value: String!
}
Создание сущностей
Не держите на меня зла, но я использую Lombok при реализации сущностей. Вот сущность Employee
, соответствующая типу Employee
, определенному в схеме GraphQL.
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Integer id;
private String firstName;
private String lastName;
private String position;
private int salary;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@ManyToOne(fetch = FetchType.LAZY)
private Organization organization;
}
Здесь создается сущность Department
.
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Integer id;
private String name;
@OneToMany(mappedBy = "department")
private Set<Employee> employees;
@ManyToOne(fetch = FetchType.LAZY)
private Organization organization;
}
Наконец, мы можем взглянуть на сущность Organization
.
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Organization {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Integer id;
private String name;
@OneToMany(mappedBy = "organization")
private Set<Department> departments;
@OneToMany(mappedBy = "organization")
private Set<Employee> employees;
}
Использование GraphQL for Spring со Spring Boot
Spring for GraphQL предоставляет модель программирования на основе аннотаций, используя известный паттерн @Controller
. Также можно адаптировать библиотеку Querydsl и использовать ее вместе со Spring Data JPA. Затем вы можете использовать ее в своих репозиториях Spring Data, аннотированных с помощью @GraphQLRepository
. В этой статье я буду использовать стандартный JPA Criteria API для генерации более сложных запросов с фильтрами и объединениями.
Начнем с нашего первого контроллера. По сравнению с обеими предыдущими статьями о Netflix DGS и GraphQL Java Kickstart, мы будем хранить запросы и мутации в одном классе. Нам нужно аннотировать методы запросов с помощью @QueryMapping
, а методы мутации с помощью @MutationMapping
. Последний метод запроса employeesWithFilter
выполняет расширенную фильтрацию на основе динамического списка полей, переданных во входном типе EmployeeFilter
. Чтобы передать входной параметр, мы должны аннотировать аргумент метода с помощью @Argument
.
@Controller
public class EmployeeController {
DepartmentRepository departmentRepository;
EmployeeRepository employeeRepository;
OrganizationRepository organizationRepository;
EmployeeController(DepartmentRepository departmentRepository,
EmployeeRepository employeeRepository,
OrganizationRepository organizationRepository) {
this.departmentRepository = departmentRepository;
this.employeeRepository = employeeRepository;
this.organizationRepository = organizationRepository;
}
@QueryMapping
public Iterable<Employee> employees() {
return employeeRepository.findAll();
}
@QueryMapping
public Employee employee(@Argument Integer id) {
return employeeRepository.findById(id).orElseThrow();
}
@MutationMapping
public Employee newEmployee(@Argument EmployeeInput employee) {
Department department = departmentRepository
.findById(employee.getDepartmentId()).get();
Organization organization = organizationRepository
.findById(employee.getOrganizationId()).get();
return employeeRepository.save(new Employee(null, employee.getFirstName(), employee.getLastName(),
employee.getPosition(), employee.getAge(), employee.getSalary(),
department, organization));
}
@QueryMapping
public Iterable<Employee> employeesWithFilter(
@Argument EmployeeFilter filter) {
Specification<Employee> spec = null;
if (filter.getSalary() != null)
spec = bySalary(filter.getSalary());
if (filter.getAge() != null)
spec = (spec == null ? byAge(filter.getAge()) : spec.and(byAge(filter.getAge())));
if (filter.getPosition() != null)
spec = (spec == null ? byPosition(filter.getPosition()) :
spec.and(byPosition(filter.getPosition())));
if (spec != null)
return employeeRepository.findAll(spec);
else
return employeeRepository.findAll();
}
private Specification<Employee> bySalary(FilterField filterField) {
return (root, query, builder) -> filterField
.generateCriteria(builder, root.get("salary"));
}
private Specification<Employee> byAge(FilterField filterField) {
return (root, query, builder) -> filterField
.generateCriteria(builder, root.get("age"));
}
private Specification<Employee> byPosition(FilterField filterField) {
return (root, query, builder) -> filterField
.generateCriteria(builder, root.get("position"));
}
}
Вот наша реализация репозитория JPA. Чтобы использовать JPA Criteria API, нам необходимо, чтобы он расширял интерфейс JpaSpecificationExecutor
. Это же правило применяется и к другим репозиториям: DepartmentRepository
и OrganizationRepository
.
public interface EmployeeRepository extends
CrudRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> {
}
Теперь давайте переключимся на другой контроллер. Вот реализация контроллера DepartmentController
. Здесь показан пример выборки отношений. Мы используем DataFetchingEnvironment
, чтобы определить, содержит ли входной запрос поле отношения. В нашем случае это могут быть employees
или organization
. Если любое из этих полей определено, мы добавляем конкретное отношение в оператор JOIN. Тот же подход применяется к методам department
и deparments.
@Controller
public class DepartmentController {
DepartmentRepository departmentRepository;
OrganizationRepository organizationRepository;
DepartmentController(DepartmentRepository departmentRepository, OrganizationRepository organizationRepository) {
this.departmentRepository = departmentRepository;
this.organizationRepository = organizationRepository;
}
@MutationMapping
public Department newDepartment(@Argument DepartmentInput department) {
Organization organization = organizationRepository
.findById(department.getOrganizationId()).get();
return departmentRepository.save(new Department(null, department.getName(), null, organization));
}
@QueryMapping
public Iterable<Department> departments(DataFetchingEnvironment environment) {
DataFetchingFieldSelectionSet s = environment.getSelectionSet();
List<Specification<Department>> specifications = new ArrayList<>();
if (s.contains("employees") && !s.contains("organization"))
return departmentRepository.findAll(fetchEmployees());
else if (!s.contains("employees") && s.contains("organization"))
return departmentRepository.findAll(fetchOrganization());
else if (s.contains("employees") && s.contains("organization"))
return departmentRepository.findAll(fetchEmployees().and(fetchOrganization()));
else
return departmentRepository.findAll();
}
@QueryMapping
public Department department(@Argument Integer id, DataFetchingEnvironment environment) {
Specification<Department> spec = byId(id);
DataFetchingFieldSelectionSet selectionSet = environment
.getSelectionSet();
if (selectionSet.contains("employees"))
spec = spec.and(fetchEmployees());
if (selectionSet.contains("organization"))
spec = spec.and(fetchOrganization());
return departmentRepository.findOne(spec).orElseThrow(NoSuchElementException::new);
}
private Specification<Department> fetchOrganization() {
return (root, query, builder) -> {
Fetch<Department, Organization> f = root
.fetch("organization", JoinType.LEFT);
Join<Department, Organization> join = (Join<Department, Organization>) f;
return join.getOn();
};
}
private Specification<Department> fetchEmployees() {
return (root, query, builder) -> {
Fetch<Department, Employee> f = root
.fetch("employees", JoinType.LEFT);
Join<Department, Employee> join = (Join<Department, Employee>) f;
return join.getOn();
};
}
private Specification<Department> byId(Integer id) {
return (root, query, builder) -> builder.equal(root.get("id"), id);
}
}
Вот реализация контроллера OrganizationController
.
@Controller
public class OrganizationController {
OrganizationRepository repository;
OrganizationController(OrganizationRepository repository) {
this.repository = repository;
}
@MutationMapping
public Organization newOrganization(@Argument OrganizationInput organization) {
return repository.save(new Organization(null, organization.getName(), null, null));
}
@QueryMapping
public Iterable<Organization> organizations() {
return repository.findAll();
}
@QueryMapping
public Organization organization(@Argument Integer id, DataFetchingEnvironment environment) {
Specification<Organization> spec = byId(id);
DataFetchingFieldSelectionSet selectionSet = environment
.getSelectionSet();
if (selectionSet.contains("employees"))
spec = spec.and(fetchEmployees());
if (selectionSet.contains("departments"))
spec = spec.and(fetchDepartments());
return repository.findOne(spec).orElseThrow();
}
private Specification<Organization> fetchDepartments() {
return (root, query, builder) -> {
Fetch<Organization, Department> f = root
.fetch("departments", JoinType.LEFT);
Join<Organization, Department> join = (Join<Organization, Department>) f;
return join.getOn();
};
}
private Specification<Organization> fetchEmployees() {
return (root, query, builder) -> {
Fetch<Organization, Employee> f = root
.fetch("employees", JoinType.LEFT);
Join<Organization, Employee> join = (Join<Organization, Employee>) f;
return join.getOn();
};
}
private Specification<Organization> byId(Integer id) {
return (root, query, builder) -> builder.equal(root.get("id"), id);
}
}
Создание модульных тестов
После того как мы создали всю логику, пришло время ее протестировать. В следующем разделе я покажу вам, как использовать для этого GraphiQL IDE.
Здесь мы сосредоточимся на модульных тестах. Самый простой способ начать тестировать Spring for GraphQL — использовать bean-компонент GraphQLTester
. Мы можем использовать его в мок веб-среде. Вы также можете создавать тесты для уровня HTTP с помощью другого компонента — HttpGraphQlTester
. Однако для этого требуется предоставить экземпляр WebTestClient
.
Вот тест для Employee
@Controller
. Каждый раз мы создаем встроенный запрос, используя нотацию GraphQL. Нам нужно аннотировать весь тестовый класс с помощью @AutoConfigureGraphQlTester
. Затем мы можем использовать DSL API, предоставляемый GraphQLTester
для получения и проверки данных из бэкенда. Помимо двух простых тестов, мы также проверяем, нормально ли работает EmployeeFilter
в методе findWithFilter
.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureGraphQlTester
public class EmployeeControllerTests {
@Autowired
private GraphQlTester tester;
@Test
void addEmployee() {
String query = "mutation { newEmployee(employee: { firstName: \"John\" lastName: \"Wick\" position: \"developer\" salary: 10000 age: 20 departmentId: 1 organizationId: 1}) { id } }";
Employee employee = tester.document(query)
.execute()
.path("data.newEmployee")
.entity(Employee.class)
.get();
Assertions.assertNotNull(employee);
Assertions.assertNotNull(employee.getId());
}
@Test
void findAll() {
String query = "{ employees { id firstName lastName salary } }";
List<Employee> employees = tester.document(query)
.execute()
.path("data.employees[*]")
.entityList(Employee.class)
.get();
Assertions.assertTrue(employees.size() > 0);
Assertions.assertNotNull(employees.get(0).getId());
Assertions.assertNotNull(employees.get(0).getFirstName());
}
@Test
void findById() {
String query = "{ employee(id: 1) { id firstName lastName salary } }";
Employee employee = tester.document(query)
.execute()
.path("data.employee")
.entity(Employee.class)
.get();
Assertions.assertNotNull(employee);
Assertions.assertNotNull(employee.getId());
Assertions.assertNotNull(employee.getFirstName());
}
@Test
void findWithFilter() {
String query = "{ employeesWithFilter(filter: { salary: { operator: \"gt\" value: \"12000\" } }) { id firstName lastName salary } }";
List<Employee> employees = tester.document(query)
.execute()
.path("data.employeesWithFilter[*]")
.entityList(Employee.class)
.get();
Assertions.assertTrue(employees.size() > 0);
Assertions.assertNotNull(employees.get(0).getId());
Assertions.assertNotNull(employees.get(0).getFirstName());
}
}
Тесты для типа Deparment
очень похожи. Кроме того, мы тестируем операторы соединения в тестовом методе findById
, объявляя поле organization
в запросе.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureGraphQlTester
public class DepartmentControllerTests {
@Autowired
private GraphQlTester tester;
@Test
void addDepartment() {
String query = "mutation { newDepartment(department: { name: \"Test10\" organizationId: 1}) { id } }";
Department department = tester.document(query)
.execute()
.path("data.newDepartment")
.entity(Department.class)
.get();
Assertions.assertNotNull(department);
Assertions.assertNotNull(department.getId());
}
@Test
void findAll() {
String query = "{ departments { id name } }";
List<Department> departments = tester.document(query)
.execute()
.path("data.departments[*]")
.entityList(Department.class)
.get();
Assertions.assertTrue(departments.size() > 0);
Assertions.assertNotNull(departments.get(0).getId());
Assertions.assertNotNull(departments.get(0).getName());
}
@Test
void findById() {
String query = "{ department(id: 1) { id name organization { id } } }";
Department department = tester.document(query)
.execute()
.path("data.department")
.entity(Department.class)
.get();
Assertions.assertNotNull(department);
Assertions.assertNotNull(department.getId());
Assertions.assertNotNull(department.getOrganization());
Assertions.assertNotNull(department.getOrganization().getId());
}
}
Каждый раз, клонируя мой репозиторий, вы можете быть уверены, что примеры работают нормально благодаря автоматизированным тестам. Вы всегда можете проверить статус сборки репозитория в моем конвейере CircleCI.
Тестирование с помощью GraphiQL
Мы можем легко запустить приложение с помощью следующей команды Maven:
$ mvn clean spring-boot:run
Как только вы это сделаете, вы сможете получить доступ к инструменту GraphiQL по адресу http://localhost:8080/graphiql
. При запуске приложение вставляет некоторые демонстрационные данные в базу данных H2. GraphiQL предоставляет контекстную помощь при построении запросов GraphQL. Вот пример запроса, протестированного там.
Заключительные мысли
Spring for GraphQL — очень интересный проект, и я буду внимательно следить за его развитием.
Помимо поддержки @Controller
, я попытался использовать интеграцию querydsl с репозиториями Spring Data JPA. Однако у меня возникли некоторые проблемы с этим, и поэтому я не стал помещать эту тему в статью.
На данный момент Spring for GraphQL является третьим надежным Java-фреймворком с высокоуровневой поддержкой GraphQL для Spring Boot. Моим выбором по-прежнему остается Netflix DGS, но Spring for GraphQL находится в стадии активной разработки. Так что, вероятно, в скором времени мы можем ожидать новых и полезных функций.
Комментарии (2)
ermadmi78
10.04.2023 15:33Спасибо за статью! Но в парадигме schema-first на Graphql Java Kickstart и на Kotlin'е проще ;)
Kwisatz
о, очень интересная штука. Но есть у меня вопросы:
Для какой цели тут spring boot? Вопрос не праздный, поскольку я не писал на яве давненько и Spring просто не знаю (почему не изучил это отдельная история)
В вашем примере сколько запросов к бд будет выполнено?
Какой цели служит схема и чем она создается? Обычно такой подход применяют когда нет бека, но у вас есть классы с аннотациями для Hibernate, соответственно схему можно генерить.