Применяем Java Sealed Classes на практике
В этой статье применим Sealed Classes для улучшения читаемости кода, используя пример из реальной разработки.
В статье используется Java 21 т.к. это первая LTS версия Java с релизным Pattern Matching. Также в примере используется Spring Boot, но этот подход можно использовать в любой похожей ситуации.
Краткое описание Sealed Classes и Pattern Matching
Обе фичи хорошо описаны в других статьях, поэтому здесь дам только краткую сводку.
Sealed Classes (JEP 409)
Класс или интерфейс с ограниченным количеством приемников, которые перечисляются в родителе. Компилятор следит за исполнением этого правила и выдаст ошибку при компиляции, если оно будет нарушено.
Синтаксис:
public sealed interface Fruit permits Apple, Orange {
// Обратите внимание на ключевое слово permits:
// список разрешенных имплементаций определяется после него.
}
public class Apple implements Fruit {
// Имплементация определяется как обычно.
}
Pattern Matching (JEP 441)
Этот JEP предоставляет несколько улучшений для switch выражений, но в этой статье нас интересует проверка типа переменной. Pattern Matching работает не только с sealed классами, однако только с ними и с enum’ами можно упразднить default ветку.
Синтаксис:
switch (fruit) {
case Apple apple -> eat(apple);
case Orange orange -> give(orange);
// обратите внимание, что default ветка здесь не нужна
}
Применяем на практике
Представим простой бекенд, реализованный с помощью Spring Boot. В этом бекенде есть API с эндпоинтом POST /session, который создает сессию для юзера. У этого эндпоинта есть три возможных варианта ответа:
200 OK — если сесия была успешно создана;
422 Unprocessable Content — если требуется дополнительная информация для создания сессии;
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. Возможные подходы для решения этой проблемы:
Возвращать из Service сразу ResponseEntity с нужным кодом — превращает Controller в ненужный класс-прослойку;
Выбрасывать исключение в Service и обрабатывать в ControllerAdvice — размазывает бизнес-логику по классам, т.к. 422 это не критическая ошибка, а стандартный вариант ответа;
Выбрасывать исключение в Service и обрабатывать в Controller — на мой взгляд не очень читабельно.
Однако главная проблема с подходами выше — слабая расширяемость, ведь к варианту с ответом 422 может добавиться еще несколько. В таком случае эти подходы будут плохочитаемыми.
С помощью Sealed Classes можно сделать обработку множества вариантов значительно проще и читаемее. Для начала, создадим интерфейс-маркер, который будет обозначать результат выполнения операции создания сессии:
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;
}
}
И наконец в Controller сформируем подходящий HTTP ответ, используя Pattern Matching:
@RestController
public class SessionApi {
// ...
public ResponseEntity 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);
};
}
}
Таким образом, взаимодействие между Controller и Service стало более понятным. Этот подход будет полезен если предполагается несколько возможных вариантов вовзврата из метода.
Что делать, если Java 21 в проекте нет
Наиболее близкий код можно получить в Java 17. В этой версии нам потребуется лишь поменять switch в Controller:
@RestController
public class SessionApi {
// ...
public ResponseEntity 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("Это исключение никогда не произойдет");
};
}
}
Решение не самое красивое, однако сохраняет читаемость и можно быть уверенным, что ветка default никогда не будет исполнена.
Также в Java 17 можно включить Pattern Matching параметром JVM --enable-preview --source 17
.
В Java 11 и ниже повторить подобное будет сложнее, т.к. ни Sealed Classes, ни обновленного switch там нет. Сам принцип с интерфейсом-маркером и ограниченным количеством имплементаций все еще будет работать, однако читаемость будет хуже.
В заключении попрошу вас поделиться своим мнением в комментариях: стали бы использовать такой подход в продакшене или нет? Если у вас есть решения лучше, чем в статье, также был бы рад их посмотреть.