Пагинация. Нестандартное использование Spring’овых Page и Pageable
Привет, Хабр! На связи Николай Пискунов, ведущий разработчик в подразделении Big Data. В прошлый раз в блоге beeline cloud я рассказывал о Spring Data JPA и Hibernate — поднимал вопрос решения проблемы динамически изменяемого запроса к базам данных. В этой статье я покажу, как применить спринговую пагинацию на интерфейсе List<>.
Помнится, в одном из проектов на SpringBoot, фронт запрашивал данные у бэкенда и выдавал результат в постраничном формате. Для получения данных из БД использовался Spring Data JPA и PagingAndSortingRepository. В этом кейсе не обошлось без пагинации на List<>, но обо всём по порядку.
Структура проекта, если исключить остальные, не связанные с данным примером классы, имела примерно такую архитектуру:
На этом шаге запрос из контроллера попадает в сервис:
@Service
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class FooService {
FooMapper fooMapper;
FooEntityRepository fooEntityRepository;
public PagedFooDto getFooDto(int page, int pageSize) {
Page fooEntities = fooEntityRepository.findAll(PageRequest.of(page, pageSize));
return fooMapper.toPagedFooDto(fooEntities);
}
}
Сервис получает данные из БД. После этого маппер собирает для фронта:
@Mapper(componentModel = "spring",
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface FooMapper {
@Mapping(target = "totalPages", source = "totalPages")
@Mapping(target = "totalElements", source = "totalElements")
@Mapping(target = "pageSize", source = "size")
@Mapping(target = "pageElements", source = "numberOfElements")
@Mapping(target = "items", source = "content")
PagedFooDto toPagedFooDto(Page entities);
FooDto toFooDto(FooEntity entity);
}
и возвращает данные. На это уходит порядка 80—360 ms в зависимости от параметров и нагрузки.
Задача: ввиду сильной нагрузки и запроса «а давайте вот по этому эндпоинту отдавать данные за 20ms», было решено выделить логику, связанную с этим эндпоинтом в отдельный микросервис и «придерживать» большие массивы данных в кеше. Здесь я опущу историю с перебором множества различных решений кеширования, но в тайминги уложился инмемори кеш на основе caffeine.
Первое, что захотелось сделать, это написать свою реализацию, которая получает данные из кеша, разбивает на страницы и возвращает нужную. Довольно быстро получилось накидать простенькую пагинацию для листа:
@UtilityClass
public class PaginationListUtils {
public List returnPagedList(List data, int page, int pageSize) {
// Вычисляем индекс элемента
int startIndex = page * pageSize;
// Проверяем на null и что индекс не выходит за рамки data.size()
if(data == null || data.size() <= startIndex){
return Collections.emptyList();
}
// Вычисляем список элементов, которые соответствуют запросу
return data.subList(startIndex, Math.min(startIndex + pageSize, data.size()));
}
}
Но сразу встал вопрос рефакторинга. Либо придется вводить дополнительный класс-обертку, в котором помимо коллекции с данными будут храниться метаданные (которые ранее хранились в спринговом объекте Page<>), либо на уровне этого или соседнего метода (который тоже потребуется реализовать), производить вычисления, чтобы заполнить требуемые фронту метаданные, типа количества страниц, общего количества элементов и т.д. А значит нужно переписывать и маппер, и контроллер … и сервис, так как «зацепит всех».
Поскольку пагинация спрингдаты запускает фуллскан по целевой таблице, а после проводит вычисления, я сделал предположение, что где-то существует готовая реализация пагинации, и полез в глубину. Наткнулся на класс PageImpl<>, у которого есть прекрасный конструктор:
public PageImpl(List content, Pageable pageable, long total) {
super(content, pageable);
this.total = pageable.toOptional().filter(it -> !content.isEmpty())//
.filter(it -> it.getOffset() + it.getPageSize() > total)//
.map(it -> it.getOffset() + content.size())//
.orElse(total);
}
Но особо не разобравшись, я просто немного переписал сервис-класс:
@Service
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class FooService {
FooMapper fooMapper;
Cache cache;
public PagedFooDto getFooDtoFromCache(int page, int pageSize) {
List fooEntities = cache.asMap().values().stream().toList();
return fooMapper.toPagedFooDto(new PageImpl<>(fooEntities, PageRequest.of(page, pageSize), fooEntities.size()));
}
}
На тестах, предварительно сохранив в кеше 10 000 элементов, я подал на вход страницу 0, указав количество элементов — 15. Но получил не тот результат, на который надеялся. Метаданные были рассчитаны неверно и на каждой странице возвращались все элементы:
Как видно из скрина выше, всего было 667 страниц и 10 000 элементов items. Результат меня удивил и я решил подать на вход страницу 668. Вот что получилось:
Мало того, что вернулись все данные, вдобавок удвоились показатели элементов и страниц. И меня осенило, что вычисления, которые производятся под капотом спринга ориентируются на готовые данные, вырезанные из общего массива, который хранится в кэше.
А поскольку у нас уже готов метод, который получает саблист (PaginationListUtils→returnPagedList), то достаточно дополнить его, чтобы вместо List<> возвращать Page<>:
public static Page returnPagedList(Pageable pageable, List data) {
List result;
int pageSize = pageable.getPageSize();
int page = pageable.getPageNumber();
int startIndex = page * pageSize;
// Проверяем на null и что индекс не выходит за рамки data.size()
if (CollectionUtils.isEmpty(data) || data.size() <= startIndex) {
result = Collections.emptyList();
} else {
// Вычисляем список элементов которые соответствуют запросу
result = data.subList(startIndex, Math.min(startIndex + pageSize, data.size()));
}
return new PageImpl<>(result, pageable, data.size());
}
А дальше немного меняем сервис-класс:
public PagedFooDto getFooDtoFromCache(int page, int pageSize) {
List fooEntities = cache.asMap().values().stream().toList();
return fooMapper.toPagedFooDto(PaginationListUtils.returnPagedList(PageRequest.of(page, pageSize), fooEntities));
}
Таким образом, добавив новый класс и немного поменяв сервис, мы получим требуемый результат.
Больше дополнительных статей — в нашем проекте вАЙТИ. Все материалы основаны на реальных событиях и опыте:
История хакерского взлома и его устранения. Статья о том, как компания решала последствия взлома инфраструктуры и атаки вируса-шифровальщика. Пришлось восстанавливать контроллеры домена и сетевое хранилище, настраивать AD, DHCP и DNS, внедрять политики Active Directory. Материал в формате инструкции для тех, кто попадает в похожую ситуацию.
Shodan, Censys, SpiderFoot — краткий гайдлайн по оценке привлекательности организации для хакеров. Практическое руководство по оценке внешнего периметра компании на наличие полезных для хакера данных. Про определение векторов атак, а также сервисы, автоматизирующие сбор информации (грабберы, дорки). Материал будет полезен как сотрудникам по ИБ, так и топ-менеджменту.
Как защитить биометрические данные от кражи и взлома. В чем главная сила биометрических данных? В способах защиты от подделок. Это — компактный материал о лучших практиках и подходах к работе с отпечатками пальцев, голосом и аутентификацией по лицу.
Как привить сотрудникам культуру кибербезопасности. В 90% случаев виновником атаки на корпоративную инфраструктуру становится сотрудник. Статья о том, как развивать культуру кибергигиены внутри компании и чему обучать коллег.
Как мы ускоряем рабочие процессы с помощью ChatGPT. Тимлид команды разработки рассказывает, как чат-бот помогает экономить время на рутинных задачах и прототипировании.
beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.