В этой статье вы узнаете, как использовать 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 в качестве слоя для взаимодействия с базой данных. Есть три сущности EmployeeDepartment и 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)


  1. Kwisatz
    10.04.2023 15:33

    о, очень интересная штука. Но есть у меня вопросы:

    1. Для какой цели тут spring boot? Вопрос не праздный, поскольку я не писал на яве давненько и Spring просто не знаю (почему не изучил это отдельная история)

    2. В вашем примере сколько запросов к бд будет выполнено?

    3. Какой цели служит схема и чем она создается? Обычно такой подход применяют когда нет бека, но у вас есть классы с аннотациями для Hibernate, соответственно схему можно генерить.


  1. ermadmi78
    10.04.2023 15:33

    Спасибо за статью! Но в парадигме schema-first на Graphql Java Kickstart и на Kotlin'е проще ;)