В этой статье применим 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<? 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); }; } }
Таким образом, взаимодействие между Controller и Service стало более понятным. Этот подход будет полезен если предполагается несколько возможных вариантов вовзврата из метода.
Что делать, если Java 21 в проекте нет
Наиболее близкий код можно получить в Java 17. В этой версии нам потребуется лишь поменять switch в Controller:
@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("Это исключение никогда не произойдет"); }; } }
Решение не самое красивое, однако сохраняет читаемость и можно быть уверенным, что ветка default никогда не будет исполнена.
Также в Java 17 можно включить Pattern Matching параметром JVM --enable-preview --source 17.
В Java 11 и ниже повторить подобное будет сложнее, т.к. ни Sealed Classes, ни обновленного switch там нет. Сам принцип с интерфейсом-маркером и ограниченным количеством имплементаций все еще будет работать, однако читаемость будет хуже.
В заключении попрошу вас поделиться своим мнением в комментариях: стали бы использовать такой подход в продакшене или нет? Если у вас есть решения лучше, чем в статье, также был бы рад их посмотреть.
