Часть 1: Как я создал идеальный REST API — микросервис инцидентов на Java и Spring

от автора

Привет! Меня зовут Бромбин Андрей, и сегодня я начинаю цикл статей о создании микросервисного приложения с нуля. Целью этого цикла является помощь начинающим разработчикам, а также обмен знаниями с более опытными коллегами для достижения наилучшего конечного результата.

Без правильного API гном заблудится в лесу данных

Без правильного API гном заблудится в лесу данных

О чем эта статья?

В этой статье я расскажу, как я спроектировал и реализовал микросервис для работы с инцидентами на Java с использованием Spring Framework. Проходя по этому пути, мы разберемся, в чем суть REST, какие лучшие практики стоит использовать и как реализовать CRUD-операции. Таким образом, мы ответим на ряд вопросов:

  1. Что такое REST API и зачем его проектировать?

  2. Какие лучшие практики используются в проектировании REST API?

  3. Как реализовать сопутствующую архитектуру микросервиса чисто и масштабируемо.

Я хочу не только поделиться знаниями в написании REST API сервиса на Java, но и рассказать, как сделать это максимально структурировано и понятно. Я также расскажу, какие паттерны проектирования помогут поддерживать чистоту кода и как правильно разделять логику приложения. Все это позволит избежать превращения типичной задачи Java-разработчика в сложный и неподдерживаемый хоррор.

Что такое REST API и зачем его проектировать?

Представим себе волшебный мир полный сказочных персонажей. Вы играете за гнома и им нужно как-то управлять. По сути, для этого и нужен: API (Application Programming Interface). Это как интерфейс между вами и игрой, который позволяет вашему гному ходить, бежать, размахивать молотом и даже поднимать сокровища.

Измотанный и расстроенный гном кричит: "Кнопку ВПЕРЁД найти сложнее, чем весло в пустыне!"

Измотанный и расстроенный гном кричит: «Кнопку ВПЕРЁД найти сложнее, чем весло в пустыне!»

Так вот хорошо спроектированный API назначит вам действия в стандартизированной и интуитивно понятной большинству игроков форме, где WASD — кнопки (эндпоинты) перемещения, Shift — бежать, а ПКМ — наносить удары

Но плохой API может раскидать действия по разным клавишам, заставляя искать их через настройки, получать неприятный опыт пользования и ломать игровой процесс.

REST (полное название: Representational State Transfer) — это стиль проектирования приложений для взаимодействия между клиентом и сервером. Вот основные принципы REST:

  • Client-Server (Клиент-Сервер): чёткое разделение функций между клиентом (пользовательский интерфейс) и сервером (хранение и обработка данных). То есть развитие клиента и сервера независят друг от друга.

  • Stateless (Без состояния): каждый запрос от клиента к серверу должен содержать всю необходимую информацию для его обработки, поскольку сервер не хранит состояние клиента.

  • Cache (Кэш): возможность записывать часть данных для ускорения повторных запросов.

  • Uniform Interface (Единообразный интерфейс): стандартные методы и взаимодействия, описанные концепцией REST.

  • Layered System (Слоевая система): возможность разделить систему на логические уровни.

  • Code-On-Demand (Код по запросу, опционально): возможность передавать исполняемый код клиенту по запросу.

RESTful Service — сервис на основе REST, который соблюдает ограничения REST.

HTTP-методы REST API

HTTP-методы REST API

Подробнее про код ответа и возвращаемые данные на тот или иной запрос будет в блоке ниже, посвящённом реализации API.

Зачем нужен REST API?

REST API стал стандартом в разработке веб-приложений благодаря следующим преимуществам:

  • Универсальность и кроссплатформенность: REST использует стандартные HTTP-методы и форматы.

  • Простота: Понятный интерфейс и легко читаемая структура URL делают работу с REST API удобной как для разработчиков, так и для конечных пользователей.

  • Масштабируемость: REST API хорошо подходит для распределенных систем и микросервисов.

Зачем его проектировать?

Проектирование REST API — это не только создание набора эндпоинтов, но и продумывание структуры, которая будет удобной и понятной для всех участников разработки.

Лучшие практики при написании REST API на Java

Одной из ключевых частей является правильная проработка сущностей. В этом разделе также не обойдётся без лаконичных решений типовых задач.

Создание сущности «Инцидент»

Для начала мы определим модель данных для микросервиса. Вот как может выглядеть сущность «Инцидент»:

Структура сущности Инцидента

Структура сущности Инцидента
Сущность Инцидент
@Entity @Getter @Setter @AllArgsConstructor @NoArgsConstructor @Table(name="incidents") @FieldDefaults(level = PRIVATE) public class Incident {     @Id     @GeneratedValue(strategy= GenerationType.IDENTITY)     Long id;      String name;      String description;      LocalDateTime dateCreate;      LocalDateTime dateClosed;      Long analystId;      Long initiatorId;      @Enumerated(EnumType.STRING)     IncidentStatus status;      @Enumerated(EnumType.STRING)     IncidentPriority priority;      @Enumerated(EnumType.STRING)     IncidentCategory category;      @Enumerated(EnumType.STRING)     ResponsibleService responsibleService; }

Что здесь важно:

  1. Использование аннотаций JPA: Позволяет связать модель с базой данных и упростить взаимодействие.

  2. Lombok аннотации: генерация геттеров и сеттеров, конструкторов

  3. Также стоит обратить внимание на аннотацию@FieldDefaults(/params/)
    Эта аннотация из библиотеки Lombok помогает сделать код более лаконичным, избавляя от необходимости многократно указывать модификаторы доступа и ограничения для каждого поля. У нее есть два основных атрибута:

    1. level: Устанавливает уровень доступа для всех полей класса (например, PRIVATE для стандартной инкапсуляции).

    2. makeFinal: Делает все поля неизменяемыми при значении true, добавляя модификатор final.

Создание DTO и зачем оно необходимо?

DTO (Data Transfer Object) — это объект, который используется для передачи данных между слоями приложения или через API. Основное назначение DTO — изолировать внутреннюю структуру сущности от внешнего мира, обеспечивая:

Структуры ДТО инцидента

Структуры ДТО инцидента

Инкапсуляция данных: DTO позволяет чётко отделить сущности базы данных от данных, которые отправляются клиенту. Это предотвращает случайное раскрытие лишней информации, например, внутренних идентификаторов или системных полей.

Упрощение и стандартизация: DTO предоставляет возможность форматировать данные в удобный для клиента вид, избавляя от необходимости повторной обработки данных на стороне клиента.

  • Изоляцию внешнего контракта от внутренней реализации: Возвращение сущности напрямую из хранилища приводит к тому, что API начинает зависеть от внутренней структуры базы данных. Любые изменения в сущности (например, добавление новых полей) могут сломать API. Использование DTO защищает внешний контракт от таких изменений, обеспечивая стабильность интерфейса.

DTO Инцидента
public record IncidentDto(         @NotBlank         @Size(max = 255, message = "Name should be between 2 and 255 characters")         String name,          @NotBlank(message = "Description should not be empty")         @Size(max = 500, message = "Description cannot exceed 500 characters")         String description,          LocalDateTime dateClosed,          @Positive(message = "Analyst ID must be positive")         Long analystId,          IncidentStatus incidentStatus,          IncidentPriority incidentPriority,          IncidentCategory category,          ResponsibleService responsibleService ) {}

Сервисный слой и его реализация

Сервисный слой — это ключевая часть архитектуры приложения, которая отвечает за обработку бизнес-логики. Он абстрагирует логику работы с данными от уровня контроллеров.

Сервисный слой (Интерфейс Incident Service)
public interface IncidentService {     IncidentDto findById(Long id);     Page<IncidentDto> getFilteredIncidents(IncidentFilterDto filterDto,                                             Pageable pageable);     IncidentDto save(Long initiatorId, IncidentDto incidentDto);     IncidentDto update(Long id, IncidentDto incidentDto);     IncidentDto updateStatus(Long id, IncidentStatus status);     IncidentDto updateAnalyst(Long id, Long analystId);     IncidentDto updatePriority(Long id, IncidentPriority priority);     IncidentDto updateResponsibleService(Long id, ResponsibleService service);     IncidentDto updateCategory(Long incidentId, IncidentCategory category);     void delete(Long id); }

Использование интерфейса для описания сервиса обусловлено реализацией паттерна проектирования «Интерфейс для реализации» (Interface to Implementation). Это позволяет:

  1. Облегчить тестирование: Интерфейс можно подменить на мок-объект в тестах.

  2. Поддерживать гибкость: Реализацию сервиса легко заменить, если изменятся требования.

  3. Обеспечить ясность: Интерфейс четко описывает, какие методы доступны, а реализация сосредотачивается на их логике.

Зачем нужна пагинация?

Пагинация — это процесс разделения больших объемов данных на отдельные страницы.

Представим себе совершенно обычную ситуацию, когда таблица данных разрослась до нескольких миллионов кортежей (строк), их все мы пытаемся выгрузить, что колоссально нагрузит систему. Таким образом, пагинация предотвращает ухудшение производительности системы в общем случае. В Spring Boot пагинация реализуется с помощью интерфейса Pageable. Параметры передаются в запросе в виде:

GET /api/incidents?page=0&size=10&sort=dateCreate,desc

В sort передаётся поле класса, по которому необходимо отсортировать данные. Через запятую порядок: asc — по возрастанию, desc — по убыванию.

Ещё чуть-чуть и гнома раздавит тяжесть запрошенных мечей

Ещё чуть-чуть и гнома раздавит тяжесть запрошенных мечей

Также хороший REST API поддерживает фильтрацию запрашиваемых данных, то есть помимо сортировки можно указать спецификацию. Например, клиенту хотелось бы отслеживать необработанные инциденты -> тогда нам необходимо предоставить возможность выполнять запрос на инциденты со статусом OPEN.
Параметры также передаются в запросе:

GET /api/incidents?page=0&size=10&sort=dateCreate,desc&status=OPEN

И таких параметров, по которым хотелось бы фильтровать данные может быть много.
И я предлагаю использовать специальный DTO класс, в который будут записываться все критерии фильтра от пользователя:

Класс с полями фильтрации IncidentFilterDto
public record IncidentFilterDto (         IncidentStatus status,         IncidentCategory category,         IncidentPriority priority,         ResponsibleService responsibleService,         LocalDateTime fromDate,         LocalDateTime toDate ) {}

Поля fromDate и toDate указывают временной диапазон

Specification — это паттерн проектирования, используемый для построения динамических условий фильтрации данных в запросах к базам данных.

В Spring Data JPA, Specification предоставляет гибкий способ фильтрации данных, где можно задавать условия в виде сложных логических операций, таких как AND, OR, NOT, а также сравнения и фильтрации по различным полям сущности. Для этого достаточно наследоваться в интерфейсе Repository от JpaSpecificationExecutor:

@Repository public interface IncidentRepository extends JpaRepository<Incident, Long>,   JpaSpecificationExecutor<Incident> {}

Реализуем класс IncidentSpecificationBuilder одноимённого паттерна Builder.

Паттерн Builder — это структурный паттерн проектирования, который позволяет создавать сложные объекты пошагово, а затем получать готовый результат.

Основной его задачей является создание динамической спецификации на основе переданных критериев фильтрации. Этот процесс включает создание предикатов (Predicate), которые задают условия для фильтрации сущностей.

Класс строитель для спецификации
@Component public class IncidentSpecificationBuilder {      public Specification<Incident> build(IncidentFilterDto filterDto) {         return (root, query, cb) -> {             List<Predicate> criteriaPredicates = new ArrayList<>();              addPredicateIfNotNull(criteriaPredicates, cb, root.get(Incident_.status), filterDto.status());             addPredicateIfNotNull(criteriaPredicates, cb, root.get(Incident_.category), filterDto.category());             addPredicateIfNotNull(criteriaPredicates, cb, root.get(Incident_.priority), filterDto.priority());             addPredicateIfNotNull(criteriaPredicates, cb, root.get(Incident_.responsibleService), filterDto.responsibleService());              if (filterDto.fromDate() != null && filterDto.toDate() != null) {                 criteriaPredicates.add(cb.between(root.get(Incident_.dateCreate), filterDto.fromDate(), filterDto.toDate()));             }              return cb.and(criteriaPredicates.toArray(new Predicate[0]));         };     }      private <T> void addPredicateIfNotNull(List<Predicate> predicates,                                            CriteriaBuilder cb, Path<T> field, T value) {         if (value != null) {             predicates.add(cb.equal(field, value));         }     } }

Метод build этого класса используется в сервисном методе getFilteredIncidents.

Реализация интерфейса сервиса (Incident Service Impl)
@Service @Slf4j @RequiredArgsConstructor @FieldDefaults(level = PRIVATE, makeFinal = true) public class IncidentServiceImpl implements IncidentService{      IncidentRepository incidentRepository;     IncidentMapper incidentMapper;      @Override     public Page<IncidentDto> getFilteredIncidents(IncidentFilterDto filterDto, Pageable pageable) {         Specification<Incident> specification = specificationBuilder.build(filterDto);         Page<Incident> incidents = incidentRepository.findAll(specification, pageable);         return incidents.map(incidentMapper::toDto);     }      @Override     public IncidentDto findById(Long id) {         Incident incident = incidentRepository.findById(id)             .orElseThrow(() -> new NotFoundException(IncidentLogMessages.INCIDENT_NOT_FOUND.getFormatted(id)));         return incidentMapper.toDto(incident);     }      @Override     public IncidentDto save(Long initiatorId, IncidentDto incidentDto) {         Incident incident = incidentMapper.toEntity(incidentDto);         incident.setInitiatorId(initiatorId);          Incident savedIncident = incidentRepository.save(incident);         log.info(IncidentLogMessages.INCIDENT_CREATED.getFormatted(savedIncident.getId()));         return incidentMapper.toDto(savedIncident);     }      // Остальные методы реализуются аналогично } 

Ключевые моменты:

  1. Логирование: Фиксируем ключевые моменты полезные для отладки (Lombok аннотация Slf4j скрыто инжектит класс Logger, улучшая читаемость)

  2. Исключения: Ошибки, такие как «объект не найден», выносятся в отдельные классы, опять же для повышения читаемости и масштабируемости проекта

  3. Маппер: IncidentMapper используется для преобразования между DTO и сущностью. Это пример паттерна «Mapper», который упрощает преобразование данных.

Почему здесь нет аннотации @Transactional?

В данном коде нет аннотации @Transactional, так как методы репозитория Spring Data JPA уже транзакционные по умолчанию. Здесь не требуется дублировать транзакционность. Однако, если методы включают сложную бизнес-логику с несколькими вызовами репозиториев, @Transactional может быть добавлена для обеспечения атомарности операции. Мы сможем это наблюдать в фасадном классе одноимённого паттерна в дальнейшем.

Ключевой момент: Описание REST API в классе Контроллера

Требования к идеальному API

HTTP-методы и их предназначение

  • GET: Для получения данных. Например, запрос GET /api/incidents возвращает список инцидентов, а GET /api/incidents/{id} — детальную информацию об инциденте.

    • Возвращаем список из DTO.

  • POST: Для создания нового ресурса. Пример: POST /api/incidents создаёт новый инцидент и возвращает его идентификатор и данные.

    • Возвращаем DTO ресурса.

  • PUT: Для полного обновления ресурса. Пример: PUT /api/incidents/{id} заменяет весь объект указанным в запросе.

    • Возвращаем DTO ресурса.

  • PATCH: Для частичного обновления ресурса. Пример: PATCH /api/incidents/{id}/status обновляет только статус инцидента.

    • Возвращаем DTO ресурса.

  • DELETE: Для удаления ресурса. Пример: DELETE /api/incidents/{id} удаляет указанный инцидент.

    • Пустое тело ответа.

Важные детали:

  • Методы GET, PUT, PATCH и DELETE должны быть идемпотентными, то есть повторные запросы с теми же параметрами не изменяют состояние сервера.

  • POST не является идемпотентным, так как повторный вызов может создать дублирующий ресурс, избегать этого нужно путём проверки на UNIQUE KEY.

Коды ответа

API должен возвращать стандартные HTTP-коды, чтобы клиенты могли корректно интерпретировать результаты запросов.

200 ОК и в жизни всё ОК

200 ОК и в жизни всё ОК
  • 2xx (успех):

    • 200 OK: Запрос выполнен успешно (например, при GET-запросе).

    • 201 Created: Ресурс создан (например, при POST-запросе).

    • 204 No Content: Успешная операция, но тело ответа отсутствует (например, при DELETE-запросе).

  • 4xx (ошибки клиента):

    • 400 Bad Request: Некорректный запрос клиента (например, невалидные данные).

    • 403 Forbidden: Клиенту запрещено выполнение операции.

    • 404 Not Found: Ресурс не найден.

    • 422 Unprocessable Entity: Данные запроса корректны, но содержат ошибки валидации.

  • 5xx (ошибки сервера):

    • 500 Internal Server Error: Общая ошибка сервера.

    • 501 Not Implemented: Метод API не поддерживается сервером.

Структура URI

Эндпоинты должны быть понятными и следовать REST-конвенциям. Примеры:

  • /api/incidents — для работы со списком инцидентов.

  • /api/incidents/{id} — для работы с конкретным инцидентом.

  • /api/incidents/{id}/status — для изменения статуса инцидента.

Архитектура REST API

Архитектура REST API

Правила именования:

  1. Используйте существительные во множественном числе для ресурсов (incidents, users).

  2. Не используйте глаголы в URI: GET /api/incidents вместо /getIncidents.

  3. В случае фильтрации или пагинации, параметры передаются в запросеGET /api/incidents?page=1&size=10.

Формат возвращаемых данных

Для всех запросов сервер должен использовать формат JSON. Это делает API стандартизированным и удобным для интеграции.

В ответах необходимо передавать Content-Type: application/json; charset=UTF-8. Некоторые браузеры игнорируют заголовок charset, что может привести к неверной интерпретации данных. Использование UTF-8 как стандарта кодировки гарантирует совместимость и правильное отображение.

Пример успешного ответа GET запроса на /api/incidents/1:
{     "status": "success",     "data": {         "id": 1,         "name": "Sample Incident",         "description": "Incident description",         "status": "OPEN",         "priority": "HIGH",         "createdAt": "2024-12-30T10:15:30",         "closedAt": null     } }

Пример ошибки:
{     "status": "error",     "message": "Incident with ID 999 not found",     "code": 404 }

Кроме того при написании Java Контроллера непосредственно реализующего структуру API следует:

  1. Разделять логику:

    • Контроллеры должны быть ответственны только за прием данных и формирование ответа. Это принцип единственной ответственности или Single Responsibility principle (SOLID).

  2. Минимизация логирования в контроллере:

    • Логирование — это хорошо, а вот избыточное логирование уже плохо. Всё что можем выносим в обработчик исключений (о нём далее) и в сервисный слой.

Пример реализации контроллера

Аннотация @RestController объединяет в себе функционал аннотаций @Controller и@ResponseBody. Автоматически добавляет JSON сериализацию и за нас добавляет @ResponceBody к каждому методу.

Аннотация @RequestMapping используется для задания URL-адреса, по которому будет доступен метод или контроллер.

Аннотация @Validated над классом контроллера избавляет от необходимости добавлять её перед каждым методом.

@Validated @RestController @RequestMapping("/api/incidents") public class IncidentController {}

С остальными аннотациями мы уже знакомы и разобрали их раннее.

Обращаю ваше внимание на то, что здесь нет обработки исключений и формирования ответов 4xx и 5xx. Это мы рассмотрим в следующем блоке: глобальной обработке исключений. Уже сейчас можно наблюдать чёткость и чистоту структуры класса контроллера и для наглядности сравню как могло бы быть в плохом API.

Сравним наглядно плохую и хорошую реализации
    @PostMapping     public ResponseEntity<Incident> createIncident(@RequestBody Incident incident) {         log.info("Creating incident: {}", incident);         if (incident.getName() == null || incident.getDescription() == null) {             log.warn("Validation failed for incident: {}", incident);             return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();         }         try {             Incident createdIncident = incidentService.save(incident);             log.info("Incident created: {}", createdIncident);             return ResponseEntity.status(HttpStatus.CREATED).body(createdIncident);         } catch (Exception e) {             log.error("Error while creating incident", e);             return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();         }     }
    @PostMapping     public ResponseEntity<IncidentDto> createIncident(@RequestBody @Valid IncidentDto incidentDto) {         IncidentDto createdIncident = incidentService.save(incidentDto);         return ResponseEntity.status(HttpStatus.CREATED).body(createdIncident);     }

Финальная итерация, касающейся реализации.

Реализация контроллера
@Slf4j @Validated @RestController @RequestMapping("/api/incidents") @RequiredArgsConstructor @FieldDefaults(level= AccessLevel.PRIVATE, makeFinal=true) public class IncidentController {     IncidentService incidentService;      @GetMapping     @PreAuthorize("hasAnyRole('ADMIN', 'ANALYST')")     public ResponseEntity<Page<IncidentDto>> getAllIncidents(             @ModelAttribute IncidentFilterDto incidentFilterDto,             Pageable pageable) {          Page<IncidentDto> incidents = incidentService.getFilteredIncidents(                 incidentFilterDto, pageable);         return ResponseEntity.ok(incidents);     }        @PostMapping     public ResponseEntity<IncidentDto> createIncident(@RequestBody @Valid IncidentDto incidentDto) {         IncidentDto createdIncident = incidentService.save(incidentDto);         return ResponseEntity.status(HttpStatus.CREATED).body(createdIncident);     }      @PutMapping("/{id}")     public ResponseEntity<IncidentDto> updateIncident(@PathVariable Long id, @RequestBody @Valid IncidentDto incidentDto) {         IncidentDto updatedIncident = incidentService.update(id, incidentDto);         return ResponseEntity.ok(updatedIncident);     }      @PatchMapping("/{id}/status")     public ResponseEntity<IncidentDto> updateIncidentStatus(@PathVariable Long id, @RequestBody IncidentStatus status) {         IncidentDto updatedIncident = incidentService.updateStatus(id, status);         return ResponseEntity.ok(updatedIncident);     }      @DeleteMapping("/{id}")     public ResponseEntity<Void> deleteIncident(@PathVariable Long id) {         incidentService.delete(id);         return ResponseEntity.noContent().build();     } } 

@ModelAttribute указывает автоматическое сопоставление полей указанных в запросе полям сущности IncidentFilterDto.

Глобальная обработка исключений с использованием @ControllerAdvice

Что такое @ControllerAdvice?

Это часть концепции аспектно-ориентированного программирования (AOP), где Advice — это «совет» или «инструкция», которая выполняется до, после или вокруг основного метода.

Аннотация @ControllerAdvice — это мощный инструмент в Spring Framework, который позволяет, например, централизованно обрабатывать исключения, возникающие в приложении. Благодаря этому механизму мы:

  1. Избегаем дублирования кода обработки исключений в каждом контроллере.

  2. Обеспечиваем единообразие формата ответов на ошибки.

  3. Упрощаем сопровождение и отладку приложения.

Конечно, в современном мире комбинируют подходы глобальной обработки и локальной. Если ошибка не специфична, как например, NotFoundException, которую можно в проекте локально обработать в 20 местах или глобально в 1 обработчике, то выбор вполне очевиден.

Аннотация @RestControllerAdvice — вариация @ControllerAdvice , предназначенная специально под REST API. Позволяет нам не указывать в сигнатуре метода ResponseBody<>, сериализуя ответ в JSON автоматически.

Также стоит обратить внимание на аннотацию над методами @ResponseStatus, используется для указания HTTP-статуса, который должен возвращаться при обработке исключения клиенту.

Структура глобального обработчика ошибок

Структура глобального обработчика ошибок
Реализация Глобального обработчика исключений
@Slf4j @RestControllerAdvice public class GlobalExceptionHandler {      @ResponseStatus(HttpStatus.NOT_FOUND)     @ExceptionHandler(NotFoundException.class)     public ErrorResponse handleNotFoundException(NotFoundException e, WebRequest request) {         log.warn(GeneralLogMessages.NOT_FOUND.getFormatted(e.getMessage()), request.getDescription(false));         return new ErrorResponse(e.getMessage(), System.currentTimeMillis());     }      @ResponseStatus(HttpStatus.BAD_REQUEST)     @ExceptionHandler(MethodArgumentNotValidException.class)     public ErrorResponse handleValidationExceptions(MethodArgumentNotValidException e, WebRequest request) {         log.warn(GeneralLogMessages.VALIDATION_FAILED.getFormatted(e.getMessage()));         StringBuilder errors = new StringBuilder("Validation errors: ");         e.getBindingResult().getFieldErrors().forEach(error ->                 errors.append(error.getField()).append(": ").append(error.getDefaultMessage()).append("; "));          return new ErrorResponse(errors.toString(), System.currentTimeMillis());     }      @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)     @ExceptionHandler(Exception.class)     public ErrorResponse handleGlobalException(Exception e, WebRequest request) {         log.error(GeneralLogMessages.UNEXPECTED_ERROR.getFormatted(e.getMessage()), request.getDescription(false), e);         return new ErrorResponse(GeneralLogMessages.UNEXPECTED_ERROR.getFormatted(), System.currentTimeMillis());     } }  

Класс стандартизирующий сообщения об ошибках
public enum GeneralLogMessages {     NOT_FOUND("Resource not found: %s - Path: %s"),     VALIDATION_FAILED("Validation failed: %s"),     UNEXPECTED_ERROR("An unexpected error occurred: %s - Path: %s");      private final String message;      GeneralLogMessages(String message) {         this.message = message;     }      public String getFormatted(String... args) {         return String.format(message, args);     } }

Это простейшая реализация класса стандартизирующего сообщения об ошибках, поскольку логирование — это обширная тема, требующая отдельного внимания.

Тонкости реализации

  1. Кастомные исключения:

    • Использование собственных классов исключений, например NotFoundException, упрощает понимание ошибок и делает их более специфичными.

  2. Понятные сообщения об ошибках:

    • Каждое исключение логируется с подробным описанием.

    • Использование GeneralLogMessages помогает стандартизировать формат сообщений, упрощает поддержку кода и делает его более чистым.

  3. Коды статуса:

    • Применяются правильные HTTP-статусы:

      • 404 Not Found для отсутствующих ресурсов.

      • 400 Bad Request для ошибок валидации.

      • 500 Internal Server Error для неожиданных ошибок.

  4. Использование ErrorResponse стандартизирует формат ответа.

  5. Сокрытие деталей реализации:

    • Для непредусмотренных ошибок сервера клиенту возвращается общее сообщение (An unexpected error occurred), чтобы избежать утечек внутренней информации.

  6. Логирование:

    • Использование log.warn и log.error позволяет различать уровни важности событий при отладке.

Гном проделал большой путь и уже думает, какие новые свершения его ждут

Гном проделал большой путь и уже думает, какие новые свершения его ждут

Заключение

В этой статье мы рассмотрели основные этапы проектирования и реализации REST API для микросервиса управления инцидентами. Мы разобрались с принципами REST, изучили лучшие практики и подходы к созданию чистого, структурированного и масштабируемого API. Эти знания помогут вам не только создавать устойчивые и удобные сервисы, но и уверенно справляться с реальными задачами в разработке.

Но это только начало пути. В следующих статьях я расскажу о других важных аспектах микросервисной архитектуры, таких как:

  • Использование Liquibase для миграций данных.

  • Документирование API с использованием Swagger UI.

  • асинхронное Kafka взаимодействие с другими микросервисами.

  • синхронное gRpc взаимодействие с другими микросервисами.

  • Тестирование, мониторинг и логирование микросервисов.

  • Докер контейнеризации микросервисов.

Мир Java-разработки — это бесконечное поле для экспериментов, творчества и обучения. Надеюсь, вы нашли эту статью полезной и вдохновляющей. Если у вас есть вопросы, замечания или идеи — делитесь ими в комментариях, буду искренне рад конструктивной критике, поскольку вместе мы сделаем этот путь ещё более интересным и продуктивным.

До встречи в следующей статье! 🚀


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


Комментарии

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

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