Рефакторинг Spring-сервиса работы с контрактами

6. Провести рефакторинг Spring-сервиса работы с контрактами

Условие задачи:
Дан Spring-сервис, который:

  • сохраняет контракты;

  • отправляет событие в Kafka после сохранения;

  • возвращает контракты постранично;

  • кэширует уже полученные страницы.

Необходимо провести code review, исправить ошибки и улучшить производительность и структуру кода.

Код:

@Service
public class ContractService {

    final public static String KT = "TOPIC";
    final public static int ONPAGE = 10;

    public ContractRepository repo;
    public KafkaTemplate<String, String> kafka;
    public HashMap<Long, List<Contract>> cache;

    void save(Long a, String b) {
        Contract x = new Contract(a, b);
        repo.save(x);
        kafka.send(KT, "contract was created");
    }

    public List<Contract> getPage(int number) {
        if (!cache.containsKey(number)) {
            List<Contract> cs = repo.findAll();
            List<Contract> r = new ArrayList<>();

            for (int i = 0; i < ONPAGE; i++) {
                Contract c = cs.get(i + number * ONPAGE);
                r.add(c);
            }

            cache.put(number, r);
        }

        return cache.get(number);
    }
}

Спойлеры к решению

Подсказки
💡 Обрати внимание на тип ключа HashMap<Long, ...> и тип номера страницы int.
💡 Поля repo, kafka и cache не должны быть публичными.
💡 Для получения одной страницы не нужно выполнять findAll().
💡 Пагинацию лучше выполнять непосредственно в БД через Pageable.
💡 После сохранения контракта закэшированные страницы могут устареть.
💡 В Spring для такого сценария удобно использовать @Cacheable и @CacheEvict.

Решение

В исходном коде есть несколько важных проблем.

Первая — тип ключа кэша:

HashMap<Long, List<Contract>> cache;

при этом номер страницы имеет тип:

int number

и используется так:

cache.put(number, r);

Для Map<Long, ...> такой вызов некорректен: номер страницы должен иметь тот же тип ключа. В данном случае естественный тип — Integer.

Кроме того, cache вообще не инициализируется, поэтому при обращении к нему возник бы NullPointerException.

Практичный Spring-вариант — не реализовывать кэш вручную, а использовать Spring Cache:

@Service
public class ContractService {

    private static final String TOPIC = "TOPIC";
    private static final int PAGE_SIZE = 10;

    private final ContractRepository repository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public ContractService(
            ContractRepository repository,
            KafkaTemplate<String, String> kafkaTemplate
    ) {
        this.repository = repository;
        this.kafkaTemplate = kafkaTemplate;
    }

    @CacheEvict(
            cacheNames = "contractPages",
            allEntries = true
    )
    public void save(Long id, String data) {
        Objects.requireNonNull(id, "id");
        Objects.requireNonNull(data, "data");

        Contract contract = new Contract(id, data);

        repository.save(contract);

        kafkaTemplate.send(
                TOPIC,
                "contract was created"
        );
    }

    @Cacheable(
            cacheNames = "contractPages",
            key = "#pageNumber",
            sync = true
    )
    public List<Contract> getPage(int pageNumber) {
        if (pageNumber < 0) {
            throw new IllegalArgumentException(
                    "Номер страницы не может быть отрицательным"
            );
        }

        Pageable pageable = PageRequest.of(
                pageNumber,
                PAGE_SIZE,
                Sort.by("id").ascending()
        );

        return List.copyOf(
                repository.findAll(pageable).getContent()
        );
    }
}

Репозиторий может выглядеть так:

public interface ContractRepository
        extends JpaRepository<Contract, Long> {
}

Для работы аннотаций кэширования в приложении должен быть включён Spring Cache, например:

@Configuration
@EnableCaching
public class CacheConfig {
}

Главная проблема с производительностью в исходном getPage():

List<Contract> cs = repo.findAll();

Допустим, в таблице миллион контрактов, а клиент запрашивает десять.

Исходная реализация делает:

БД → 1 000 000 записей → приложение → выбираем 10

Вместо этого пагинация должна выполняться на стороне БД:

PageRequest.of(
        pageNumber,
        PAGE_SIZE,
        Sort.by("id").ascending()
)

Тогда из БД загружаются только необходимые записи.

Сортировка важна для стабильной пагинации. Без явно заданного порядка БД не обязана возвращать строки каждый раз в одинаковой последовательности.

Кэширование выполняется через:

@Cacheable(
        cacheNames = "contractPages",
        key = "#pageNumber",
        sync = true
)

При первом:

getPage(2)

метод обращается к БД и сохраняет результат в кэш.

При следующем:

getPage(2)

результат берётся из кэша без выполнения метода.

sync = true также позволяет избежать нескольких одновременных загрузок одной и той же отсутствующей страницы при поддержке этой возможности используемым Cache-провайдером.

После сохранения нового контракта страницы потенциально меняются, поэтому:

@CacheEvict(
        cacheNames = "contractPages",
        allEntries = true
)

очищает кэш.

Очищать только одну страницу небезопасно. При сортировке новый контракт потенциально может сдвинуть элементы и изменить несколько последующих страниц.

Кроме того, исходный цикл:

for (int i = 0; i < ONPAGE; i++) {
    Contract c = cs.get(i + number * ONPAGE);
}

может выбросить IndexOutOfBoundsException, если последняя страница содержит меньше десяти элементов.

Spring Data pagination обрабатывает это автоматически:

repository.findAll(pageable).getContent()

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

Основные проблемы исходного кода:

  • неправильный тип ключа кэша;

  • кэш не инициализирован;

  • публичные изменяемые зависимости;

  • отсутствует constructor injection;

  • findAll() загружает всю таблицу ради одной страницы;

  • возможен IndexOutOfBoundsException;

  • нет проверки отрицательного номера страницы;

  • нет стабильной сортировки;

  • кэш не инвалидируется после save();

  • обычный HashMap не подходит для конкурентного доступа;

  • имена a, b, x, cs, r, KT, ONPAGE плохо передают назначение данных.

Отдельный архитектурный вопрос — последовательность:

repository.save(contract);
kafkaTemplate.send(...);

Она сама по себе не гарантирует атомарность между БД и Kafka. Если по требованиям событие обязательно должно соответствовать успешно зафиксированному изменению в БД, обычно рассматривают Transactional Outbox. Но это уже отдельное требование и для базового рефакторинга данного метода не обязательно.