null

Частичное получение данных в Spring Boot REST API

Оригинал этой статьи - труд автора Bubu Tripathy под названием Partial Data Retrieval in Spring Boot REST API

Вступление

Во многих API получение ресурса обычно приводит либо к извлечению всего ресурса целиком, либо к выводу сообщения об ошибке, так сказать, имеем либо всё, либо ничего.

Хотя для многих это не является проблемой, однако это может создавать трудности, если речь идёт о работе с большими, сложными ресурсами, содержащими множество полей и подполей, ограниченными вычислительными ресурсами или медленным и дорогостоящим подключением.

В таких сценариях становится крайне важным контролировать объём информации, которую клиент запрашивает у API и объём информации возвращаемой ему от API.

Таким образом, частичное получение данных, позволяющее пользователям извлекать только необходимые части ресурса или списка ресурсов, становится бесценным.

Spring Boot REST API предлагает несколько подходов для частичного извлечения данных.

В этой статье мы рассмотрим некоторые из этих техник, с помощью которых можно оптимизировать производительность своего API и извлекать только релевантные части данных.

Разбивка на страницы или Пагинация

Пагинация - это процесс разделения большого объёма данных на более мелкие фрагменты, называемые страницами. Подразумевается, что клиент запрашивает только одну определенную страницу данных за раз. Такой подход часто применяется при работе с большими наборами данных, поскольку позволяет сократить объём данных, передаваемый между сервером и клиентом.

Для реализации пагинации в Spring Boot REST API используется интерфейс Pageable из Spring Data.

Приведем пример:

@GetMapping("/users")
public ResponseEntity<Page<User>> getUsers(@RequestParam(defaultValue = "") String searchCriteria,
                                            @RequestParam(defaultValue = "0") int page,
                                            @RequestParam(defaultValue = "10") int size) {

    Pageable pageable = searchCriteria.isEmpty()
                        ? PageRequest.of(page, size)
                        : PageRequest.of(page, size, Sort.by("lastName").ascending());

    Page<User> users = userRepository.findByLastNameContainingIgnoreCase(searchCriteria, pageable);

    return new ResponseEntity<>(users, HttpStatus.OK);
}

В этом примере endpoint принимает три параметра запроса: “SearchCriteria”, который определяет критерии поиска по фамилии пользователя, “page”, который указывает номер страницы для извлечения данных, и “size”, который определяет размер страницы.

Мы создаем объект Pageable с помощью класса PageRequest, передавая ему номер страницы и ее размер. Если параметр searchCriteria не указан, мы используем объект PageRequest по умолчанию. Если параметр searchCriteria указан, мы добавляем к объекту PageRequest критерий сортировки, чтобы отсортировать результаты по фамилии пользователя в порядке возрастания.

Затем мы используем метод findByLastNameContainingIgnoreCase из UserRepository (подразумевается, что мы сами реализовали этот метод), передаём в него критерии поиска и объект Pageable, чтобы получить конкретную страницу пользователей на основе критериев, определенных объектом Pageable.

Наконец, мы возвращаем объект Page в объекте ResponseEntity с кодом состояния HttpStatus.OK.

С помощью подобной реализации клиенты могут извлекать определенную страницу данных на основе критериев поиска и параметров разбивки на страницы, что позволяет им эффективно перемещаться по большим наборам данных.

Фильтры

Другим способом частичного получения данных является использование фильтров.

Фильтры позволяют получать подмножество данных на основе определенных критериев, таких как: диапазоны дат, ключевые слова или любые другие атрибуты.

Пример:

@GetMapping("/users")
public ResponseEntity<List<User>> getUsersByLastName(@RequestParam String lastName) {
    List<User> users = userRepository.findByLastNameContainingIgnoreCase(lastName);
    return new ResponseEntity<>(users, HttpStatus.OK);
}

В данном примере endpoint принимает параметр запроса "lastName", который определяет фамилию пользователей, которых необходимо получить.

Запросы диапазона

Запросы диапазона в REST API позволяют клиентам получить часть данных ресурса, указав необходимый диапазон байтов или строк. Обычно это используется для медиафайлов, логов или других ресурсов, которые слишком велики для передачи в рамках одного запроса.

Запросы диапазона реализуются в протоколе HTTP с использованием заголовков Range и Content-Range. Заголовок Range указывает диапазон байтов или строк для извлечения, и сервер выдает частичный ответ, содержащий запрошенный диапазон данных. Заголовок Content-Range определяет диапазон байтов или строк, возвращаемых в ответе, и используется клиентом для проверки того, что был возвращен правильный диапазон данных.

В Spring Boot можно реализовать запросы диапазона, используя класс ResourceRegion, который представляет область данных ресурса.

Вот пример того, как мы можем использовать запросы диапазона для извлечения части медиафайла:

@GetMapping("/media")
public ResponseEntity<ResourceRegion> getMedia(@RequestHeader HttpHeaders headers) {
    Resource media = new FileSystemResource("/path/to/media/file.mp4");
    ResourceRegion region = resourceRegion(media, headers);
    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
            .contentType(MediaTypeFactory.getMediaType(media)
                    .orElse(MediaType.APPLICATION_OCTET_STREAM))
            .body(region);
}

private ResourceRegion resourceRegion(Resource media, HttpHeaders headers) {
    long contentLength = 0;
    try {
        contentLength = media.contentLength();
    } catch (IOException e) {
        e.printStackTrace();
    }
    List<String> range = headers.getRange();
    if (!range.isEmpty()) {
        String[] ranges = range.get(0).split("-");
        long start = Long.parseLong(ranges[0]);
        long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : contentLength - 1;
        long rangeLength = Math.min(end - start + 1, contentLength);
        return new ResourceRegion(media, start, rangeLength);
    } else {
        long rangeLength = Math.min(1024 * 1024, contentLength);
        return new ResourceRegion(media, 0, rangeLength);
    }
}

В этом примере мы определяем endpoint для получения медиафайла. Endpoint принимает заголовки Range, которые определяют диапазон извлекаемых байтов. Мы создаем объект ResourceRegion, используя метод resourceRegion, который извлекает Range заголовки и возвращает частичный ответ.

Метод resourceRegion сначала извлекает длину содержимого медиафайла, используя метод contentLength, предоставляемый интерфейсом Resource. Затем проверяется наличие заголовка Range в заголовках запроса. Если он присутствует, то извлекаются начальная и конечная позиции запрашиваемого диапазона с помощью метода split и вычисляется длина диапазона. Затем создается объект ResourceRegion с использованием указанного диапазона. Если заголовок Range отсутствует, метод возвращает диапазон по умолчанию, равный 1 МБ, начиная с начала файла.

Наконец, мы возвращаем объект ResourceRegion в объекте ResponseEntity с кодом состояния HttpStatus.PARTIAL_CONTENT, указывающим на то, что был возвращен частичный ответ. Мы также устанавливаем в заголовке Content-Type медиатип ресурса, используя класс MediaTypeFactory, предоставляемый Spring.

Проекционные запросы

Проекционные запросы или проекции позволяют клиентам получать подмножество атрибутов сущности, а не всю сущность. Это удобно, когда клиенту нужны только определенные атрибуты и он не желает обрабатывать ненужные данные.

В Spring Boot проекционные запросы реализуются при помощи Spring Data.

Идея заключается в определении пользовательского интерфейса, в котором указываются необходимые атрибуты. Затем с помощью методов JPA-репозитория Spring Data получается проекция сущности, содержащая только указанные атрибуты.

Приведем пример использования проекционных запросов для получения подмножества атрибутов сущности:

@Entity
public class User {
    @Id
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    // getters, setters, и другие методы
}

public interface UserProjection {
    String getFirstName();
    String getLastName();
}

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserProjection> findByLastNameContainingIgnoreCase(String lastName);
}

В примере мы определяем сущность User с несколькими атрибутами, такими как: firstName, lastName и email.

Мы также определяем пользовательский интерфейс UserProjection, который определяет только атрибуты firstName и lastName с помощью методов getter.

Затем мы определяем интерфейс UserRepository и в нём метод findByLastNameContainingIgnoreCase, который принимает параметр lastName и возвращает список объектов UserProjection. Этот метод реализует одноименный метод из Spring Data, для получения подмножества пользователей на основе указанного lastName, и возвращает проекцию, состояющую только из атрибутов firstName и lastName.

Далее, используем этот метод в Spring Boot REST API:

@GetMapping("/users")
public ResponseEntity<List<UserProjection>> getUsersByLastName(@RequestParam String lastName) {
    List<UserProjection> users = userRepository.findByLastNameContainingIgnoreCase(lastName);
    return new ResponseEntity<>(users, HttpStatus.OK);
}

Подобный подход особенно полезен при работе с большими массивами данных или сущностями с большим количеством атрибутов, поскольку позволяет оптимизировать производительность и снизить потребление ресурсов.

Отложенная загрузка

Отложенная загрузка - это метод повышения производительности путем отсрочки загрузки определенных данных до тех пор, пока они действительно не понадобятся. Когда объект в приложении Spring Boot содержит в себе коллекцию других связанных с ним объектов, отложенная загрузка может использоваться для загрузки этих связанных объектов только при необходимости, нежели для загрузки всех их сразу.

Чтобы реализовать отложенную загрузку в Spring Boot, можно использовать параметр FetchType.LAZY в аннотации @OneToMany или @ManyToMany для поля коллекции в классе сущности. Этот параметр указывает Hibernate, базовому ORM-фреймворку, используемому Spring Data JPA, загружать связанные объекты только тогда, когда к ним обращается приложение.

Вот пример того, как мы можем использовать отложенную загрузку для извлечения пользователя и связанных с ним заказов в Spring Boot REST API:

@Entity
public class User {
    @Id
    private Long id;
    private String firstName;
    private String lastName;
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
    // другие атрибуты и методы
}

@Entity
public class Order {
    @Id
    private Long id;
    private String orderNumber;
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
    // другие атрибуты и методы
}

public interface UserRepository extends JpaRepository<User, Long> {}

@GetMapping("/users/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
    Optional<User> user = userRepository.findById(id);
    if (user.isPresent()) {
        return ResponseEntity.ok(user.get());
    } else {
        return ResponseEntity.notFound().build();
    }
}

В этом примере мы используем метод findById из UserRepository, для извлечения пользователя по его идентификатору из хранилища данных. При этом связанные с пользователем заказы не будут загружены до тех пор, пока к ним явно не будет получен доступ. Это может быть особенно полезно при работе с большими наборами данных или объектами со множеством ассоциаций.

Выборочная сериализация

Выборочная сериализация - это техника, которая позволяет пользователю API запрашивать только необходимые поля ресурса, что сокращает передаваемый объём данных и повышает производительность.

Чтобы реализовать выборочную сериализацию в Spring Boot REST API, разработчики могут использовать различные библиотеки и фреймворки, такие как Jackson или Gson, которые предоставляют аннотации для управления сериализацией объектов.

Используя эти аннотации, разработчики могут указать, какие поля следует включать или исключать из сериализованных выходных данных.

Выборочная сериализация не только уменьшает объем передаваемых данных, но и позволяет повысить уровень безопасности, предотвращая попадание конфиденциальных данных в руки неавторизованных пользователей.

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

Пример реализации выборочной сериализации:

public class User {
    private Long id;
    @JsonProperty("first_name")
    private String firstName;
    @JsonProperty("last_name")
    private String lastName;
    @JsonIgnore
    private String password;
    // getters and setters
}

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    User user = userRepository.findById(id).orElse(null);
    if (user != null) {
        user.setPassword(null); // исключаем поле password из сериализации
    }
    return user;
}

В этом примере мы определяем класс User с такими полями как: id, имя, фамилия и пароль. Мы используем аннотацию @JsonProperty, чтобы указать как будут отображаться имена полей firstName и lastName в сериализованном выводе. Мы также используем аннотацию @JsonIgnore, чтобы исключить поле пароля из сериализованного вывода.

В endpoint мы используем метод findById для извлечения пользователя из репозитория. Затем мы устанавливаем этому пользователю в поле пароля значение null, чтобы исключить это поле из сериализованного вывода. Наконец, мы возвращаем объект пользователя, сериализованный Jackson и содержащий только указанные поля.

Используя аннотации Jackson, разработчики могут легко указать, какие поля должны быть включены или исключены из выходных данных, обеспечивая более эффективный и безопасный API для своих пользователей.

Вы также можете указать пользовательский JsonSerializer для управления процессом сериализации:

public class UserSerializer extends JsonSerializer<User> {
    @Override
    public void serialize(User user, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeStartObject();
        jsonGenerator.writeStringField("firstName", user.getFirstName());
        jsonGenerator.writeStringField("lastName", user.getLastName());
        jsonGenerator.writeEndObject();
    }
}

@GetMapping("/users/{id}")
@JsonSerialize(using = UserSerializer.class)
public ResponseEntity<User> getUserById(@PathVariable Long id) {
    Optional<User> user = userRepository.findById(id);
    if (user.isPresent()) {
        return ResponseEntity.ok(user.get());
    } else {
        return ResponseEntity.notFound().build();
    }
}

В этом примере мы определяем пользовательский класс UserSerializer, который указывает, какие атрибуты должны быть включены в сериализованный вывод. Затем мы определяем endpoint для получения пользователя по его идентификатору. Мы используем аннотацию @JsonSerialize для класса UserSerializer, чтобы указать, что в сериализованный вывод должны быть включены только атрибуты firstName и lastName.

Кэш-заголовки

Кэш-заголовки используются для повышения производительности API за счёт управления поведением кэширования на стороне клиента и на стороне сервера. Они определяют, насколько долго какой-либо ресурс может быть закэширован, может ли клиент использовать кэшированную копию этого ресурса, а также должен ли сервер возвращать ответ из кэша или нет.

В Spring Boot кэш-заголовки реализуются с помощью заголовков Cache-Control и ETag.

Заголовок Cache-Control задает поведение кэширования клиента и сервера, а заголовок ETag определяет уникальный идентификатор ресурса, который можно использовать для проверки того, является ли закэшированный ответ все еще действительным или нет.

Пример:

@GetMapping("/users/{id}")
@Cacheable(value = "users", key = "#id")
public ResponseEntity<User> getUserById(@PathVariable Long id, HttpServletRequest request) {
    Optional<User> user = userRepository.findById(id);
    if (user.isPresent()) {
        String etag = Integer.toString(user.get().hashCode());
        if (request.getHeader("If-None-Match") != null
                && request.getHeader("If-None-Match").equals(etag)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
        } else {
            return ResponseEntity.ok().eTag(etag).body(user.get());
        }
    } else {
        return ResponseEntity.notFound().build();
    }
}

В примере мы используем аннотацию @Cacheable для кэширования ответов сервера и установки необходимых значений для заголовка Cache-Control, чтобы указать поведение кэширования.

Мы также используем заголовок ETag для определения уникального идентификатора ресурса, который генерируется на основе хэш-кода объекта user. Это позволяет клиенту проверить, является ли кэшированный ответ всё ещё действительным, отправляя заголовок If-None-Match со значением ETag в последующих запросах. Если ресурс не был изменён, сервер возвращает код состояния HttpStatus.NOT_MODIFIED, указывая на то, что клиент может использовать закэшированный ранее ответ.

С помощью заголовков кэша клиенты могут сократить количество запросов, отправляемых и получаемых с сервера, повысить производительность за счет повторного использования кэшированных ответов. Такой подход особенно полезен при работе с большими наборами данных или часто используемыми ресурсами, поскольку он помогает оптимизировать производительность и сократить использование ресурсов.

Матричные переменные

Матричная переменная - это тип параметра URL в REST API, позволяющий передавать несколько параметров в одном сегменте URL-пути, что хорошо подходит для сложных запросов или операций фильтрации данных.

Благодаря группировке связанных параметров, матричные переменные также помогают сохранить чистоту и организованность URL.

Матричные переменные отделяются друг от друга точкой с запятой (;), каждая матричная переменная имеет имя и значение, разделенные знаком равенства (=).

Вот пример использования матричных переменных:

http://localhost:8080/users;fields=firstName,lastName;sort=lastName,desc;age=30
@GetMapping("/users")
public List<User> getUsers(
        @MatrixVariable(value = "fields", pathVar = "id", required = false) List<String> fields,
        @MatrixVariable(value = "sort", pathVar = "id", required = false) List<String> sort,
        @RequestParam(value = "lastName", required = false) String lastName,
        @RequestParam(value = "age", required = false) Integer age) {
   
    // получаем из репозитория всех пользователей
    List<User> users = userRepository.findAll();
    
    // фильтруем набор данных по lastName и age
    if (lastName != null) {
        users = users.stream().filter(user -> user.getLastName().equals(lastName)).collect(Collectors.toList());
    }
    if (age != null) {
        users = users.stream().filter(user -> user.getAge() == age).collect(Collectors.toList());
    }
     
    // сортируем набор данных согласно sort
    if (sort != null) {
        users.sort((user1, user2) -> {
            for (String field : sort) {
                String[] split = field.split(",");
                String fieldName = split[0];
                String sortOrder = split.length > 1 ? split[1] : "asc";
                int compare = getFieldValue(user1, fieldName).compareTo(getFieldValue(user2, fieldName));
                if (compare != 0) {
                    return sortOrder.equals("asc") ? compare : -compare;
                }
            }
            return 0;
        });
    }

   // возвращаем объекты только с тем набором полей, который указан в fields
    if (fields != null) {
        users = users.stream().map(user -> filterFields(user, fields)).collect(Collectors.toList());
    }
    
    return users;
}

private String getFieldValue(User user, String fieldName) {
    try {
        Field field = user.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        return String.valueOf(field.get(user));
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

private User filterFields(User user, List<String> fields) {
    User filteredUser = new User();
    for (String field : fields) {
        try {
            Field f = user.getClass().getDeclaredField(field);
            f.setAccessible(true);
            f.set(filteredUser, f.get(user));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    return filteredUser;
}

В этом примере, мы определяем GET endpoint, который отдаёт список пользователей. Мы используем аннотацию @MatrixVariable, чтобы извлечь матричные переменные fields и sort из URL. По мимо этого, с помощью аннотации @RequestParam мы извлекаем обычные параметры запроса: lastName и age.

Внутри endpoint, мы вначале с помощью метода findAll из Spring Data JPA получаем всех пользователей, которые есть в хранилище данных. Затем мы фильтруем пользователей по lastName и age, если эти параметры присутствуют в запросе. Далее, мы сортируем пользователей, если в запросе присутствует параметр sort. Наконец, мы фильтруем поля у объектов User, если в запросе присутствует параметр fields.

Метод getFieldValue извлекает значение поля из объекта User, используя рефлексию. Метод filterFields при помощи рефлексии создает новый объект User, содержащий только необходимые поля.

Вложенные ресурсы

Матричные переменные можно использовать для выборочного получения данных вложенных (связанных) ресурсов, указывая поля, которые необходимо включить или исключить из ответа.

Например, предположим, что у нас есть endpoint, который возвращает список заказов. И каждый заказ из списка имеет связанного с ним клиента. Для того чтобы получить список заказов, содержащий в том числе данные связанных клиентов, но не все, а только определенные, можно для выборочного включения или исключения нужных полей использовать матричные переменные.

Пример:

http://localhost:8080/orders;fields=customer.firstName,customer.lastName
@GetMapping("/orders")
public List<Order> getOrders(@MatrixVariable(value = "fields", pathVar = "id", required = false) List<String> fields) {
    List<Order> orders = orderRepository.findAll();

    if (fields != null && !fields.isEmpty()) {
        for (Order order : orders) {
            Customer customer = order.getCustomer();
            if (customer != null) {
                filterFields(customer, fields);
            }
        }
    }
    return orders;
}

private void filterFields(Object object, List<String> fields) {
    try {
        Class<?> clazz = object.getClass();
        for (Field field : clazz.getDeclaredFields()) {
            field.setAccessible(true);
            if (field.isAnnotationPresent(JsonIgnore.class)) {
                continue;
            }
            if (field.getType().equals(String.class) || field.getType().isPrimitive()) {
                if (!fields.contains(field.getName())) {
                    field.set(object, null);
                }
            } else {
                filterFields(field.get(object), fields);
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

В этом примере, мы вначале получаем все заказы из хранилища с помощью метода findAll. Далее мы используем метод filterFields для выборочной фильтрации полей ассоциированного объекта customer для каждого заказа, при условии что в запросе присутствует матричная переменная fields. Метод filterFields использует рефлексию для рекурсивного обхода иерархии объектов и отсеивания полей, не указанных в матричной переменной fields. Мы включаем в ответ только поля firstName и lastName объекта customer.

Использование матричных переменных для выборочного получения данных вложенных ресурсов позволяет сократить объём данных, передаваемых между клиентом и сервером, и повысить производительность за счёт исключения ненужных данных.

Заключение

Существуют различные способы получения частичных данных в Spring Boot REST API, каждый из которых имеет свои преимущества и варианты использования. Используя эти методы, разработчики могут оптимизировать производительность Spring Boot REST API и обеспечить более высокий уровень user experience.