Применяем 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<? 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 там нет. Сам принцип с интерфейсом-маркером и ограниченным количеством имплементаций все еще будет работать, однако читаемость будет хуже.


В заключении попрошу вас поделиться своим мнением в комментариях: стали бы использовать такой подход в продакшене или нет? Если у вас есть решения лучше, чем в статье, также был бы рад их посмотреть.


ссылка на оригинал статьи https://habr.com/ru/articles/784864/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *