Пагинация. Нестандартное использование Spring’овых Page и Pageable

39229e7a34df652f3e89141f3a21edf6.png

Привет, Хабр! На связи Николай Пискунов, ведущий разработчик в подразделении Big Data. В прошлый раз в блоге beeline cloud я рассказывал о Spring Data JPA и Hibernate — поднимал вопрос решения проблемы динамически изменяемого запроса к базам данных. В этой статье я покажу, как применить спринговую пагинацию на интерфейсе List<>. 

 Помнится, в одном из проектов на SpringBoot, фронт запрашивал данные у бэкенда и выдавал результат в постраничном формате. Для получения данных из БД использовался Spring Data JPA и PagingAndSortingRepository. В этом кейсе не обошлось без пагинации на List<>, но обо всём по порядку.

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

6d8c7fca22c926ce9f9e7b741bbc8dac.png

На этом шаге запрос из контроллера попадает в сервис:

@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. Но получил не тот результат, на который надеялся. Метаданные были рассчитаны неверно и на каждой странице возвращались все элементы:

894794e8ca82cf1914f6095a0c1b286f.png

Как видно из скрина выше, всего было 667 страниц и 10 000  элементов items. Результат меня удивил и я решил подать на вход страницу 668. Вот что получилось:

365be157992d6495455bffd0c611690d.png

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

А поскольку у нас уже готов метод, который получает саблист (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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

© Habrahabr.ru