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. Но это уже отдельное требование и для базового рефакторинга данного метода не обязательно.