В этой статье мы будем использовать запечатанные классы для улучшения читаемости кода на примере из реальной разработки.
В этой статье используется Java 21, поскольку это первая выпущенная версия Java LTS с сопоставлением шаблонов. В примере также используется Spring Boot, но этот подход можно использовать в любой подобной ситуации.
Contents
Краткое описание запечатанных классов и сопоставления с образцом
Обе функции хорошо описаны в других статьях, поэтому здесь я приведу лишь краткое изложение.
Герметичные классы (JEP 409)
Класс или интерфейс с ограниченным количеством получателей, перечисленных в родительском классе. Компилятор применяет это правило и выдаст ошибку компиляции, если оно не будет соблюдаться.
Синтаксис:
public sealed interface Fruit permits Apple, Orange {
// Обратите внимание на ключевое слово permits:
// список разрешенных имплементаций определяется после него.
}
public class Apple implements Fruit {
// Имплементация определяется как обычно.
}
Сопоставление с образцом (JEP 441)
В этом JEP внесено несколько улучшений в операторы переключения, но в этой статье мы сосредоточимся на проверке типа переменной. Сопоставление с образцом работает не только с запечатанными классами, но только с ними и с перечислениями, ветку по умолчанию можно исключить.
Синтаксис:
switch (fruit) {
case Apple apple -> eat(apple);
case Orange orange -> give(orange);
// обратите внимание, что default ветка здесь не нужна
}
Давайте применим это на практике
Давайте представим себе простой бэкэнд, реализованный с помощью Spring Boot. Этот бэкэнд имеет API с конечной точкой POST/session, который создает сеанс для пользователя. Эта конечная точка имеет три возможных ответа:
-
200 ОК – если сессия создана успешно;
-
422 Необрабатываемый контент — если для создания сеанса требуется дополнительная информация;
-
500 Internal Server Error — если на серверной стороне произошла критическая ошибка.
Стандартная реализация Spring будет содержать классы Controller и Service (ненужные детали игнорируются):
@RestController
public class SessionApi {
// ...
public ResponseEntity<?> createSession(UserInfo userInfo) {
SessionInfo sessionInfo = sessionService.createSession(userInfo);
return new ResponseEntity<>(sessionInfo, HttpStatus.OK);
}
}
@Service
public class SessionService {
// ...
public SessionInfo createSession(UserInfo userInfo) {
// создаем сессию, а в случае критической ошибки выбрасываем исключение,
// которое будет обработано в @ControllerAdvice
return sessionInfo;
}
}
Приведенный выше код хорошо обрабатывает первый (200) и последний (500) варианты ответа. Однако эта реализация не учитывает ответ 422. Возможные подходы к решению этой проблемы:
-
Немедленно вернуть ResponseEntity из сервиса с нужным кодом — превращает Controller в бесполезный класс слоя;
-
Выброс исключения в Service и его обработка в ControllerAdvice распределяет бизнес-логику между классами, поскольку 422 — это не критическая ошибка, а стандартный ответ;
-
На мой взгляд, выбрасывать исключение в Сервисе и обрабатывать его в Контроллере не очень удобно.
Однако основная проблема вышеперечисленных подходов заключается в их низкой масштабируемости, ведь к варианту с ответом 422 можно добавить еще несколько. В этом случае эти подходы будет сложно читать.
Запечатанные классы могут сделать обработку нескольких вариантов намного проще и читабельнее. Для начала давайте создадим интерфейс маркера, который будет указывать результат операции создания сеанса:
public sealed interface CreateSessionResult permits SessionInfo, AdditionalInfoRequired {
}
Также потребуются реализации интерфейса, будь то DTO:
public record SessionInfo(/*поля пропущены*/) implements CreateSessionResult {
}
public record AdditionalInfoRequired(/*поля пропущены*/) implements CreateSessionResult {
}
Теперь давайте изменим тип возвращаемого значения в Service:
@Service
public class SessionService {
// ...
public CreateSessionResult createSession(UserInfo userInfo) {
// в зависимости от ситуации результата может быть двух разных типов
return someCondition
? sessionInfo
: additionalInfoRequired;
}
}
И, наконец, в контроллере мы сгенерируем соответствующий HTTP-ответ, используя сопоставление с образцом:
@RestController
public class SessionApi {
// ...
public ResponseEntity<? extends CreateSessionResult> createSession(UserInfo userInfo) {
CreateSessionResult createSessionResult = sessionService.createSession(userInfo);
return switch (createSessionResult) {
case SessionInfo sessionInfo -> new ResponseEntity<>(sessionInfo, HttpStatus.OK);
case AdditionalInfoRequired infoRequired -> new ResponseEntity<>(infoRequired, HttpStatus.UNPROCESSABLE_ENTITY);
};
}
}
Таким образом, взаимодействие контроллера и сервиса стало более понятным. Этот подход будет полезен, если существует несколько возможных вариантов возврата из метода.
Что делать, если Java 21 нет в проекте
Самый близкий код можно получить в Java 17. В этой версии достаточно поменять переключатель в Контроллере:
@RestController
public class SessionApi {
// ...
public ResponseEntity<? extends CreateSessionResult> createSession(UserInfo userInfo) {
CreateSessionResult createSessionResult = sessionService.createSession(userInfo);
return switch (createSessionResult.getClass().getSimpleName()) {
case "SessionInfo" -> new ResponseEntity<>(createSessionResult, HttpStatus.OK);
case "AdditionalInfoRequired" -> new ResponseEntity<>(createSessionResult, HttpStatus.UNPROCESSABLE_ENTITY);
default -> throw new RuntimeException("Это исключение никогда не произойдет");
};
}
}
Решение не самое красивое, но оно сохраняет читабельность, и вы можете быть уверены, что ветка по умолчанию никогда не будет выполнена.
Также в Java 17 вы можете включить сопоставление шаблонов с помощью настройки JVM. --enable-preview --source 17
.
В Java 11 и более ранних версиях повторить это будет сложнее, поскольку нет запечатанных классов и обновленных переключателей. Сам принцип с маркерным интерфейсом и ограниченным количеством реализаций всё равно будет работать, но читаемость будет хуже.
В заключение прошу поделиться в комментариях своим мнением: будете ли вы использовать такой подход в производстве или нет? Если у вас есть решения лучше, чем представленные в статье, я тоже буду рад их увидеть.