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

Без правильного 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 — это не только создание набора эндпоинтов, но и продумывание структуры, которая будет удобной и понятной для всех участников разработки. Грамотно спроектированный 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> findAllWithPagination(int page, int size);
    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

Ещё чуть-чуть и гнома раздавит тяжесть запрошенных мечей
Ещё чуть-чуть и гнома раздавит тяжесть запрошенных мечей
Реализация интерфейса сервиса (Incident Service Impl)
@Service
@Slf4j
@RequiredArgsConstructor
@FieldDefaults(level = PRIVATE, makeFinal = true)
public class IncidentServiceImpl implements IncidentService{

    IncidentRepository incidentRepository;
    IncidentMapper incidentMapper;

    @Override
    public Page<IncidentDto> findAllWithPagination(int page, int size) {
        log.info(IncidentLogMessages.INCIDENT_FETCH_PAGINATED.getFormatted(page, size));
        Page<Incident> incidents = incidentRepository.findAll(PageRequest.of(page, size));
        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. Минимизация логирования в контроллере:

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

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

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

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

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

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

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

Обращу ваше внимание на то, что здесь нет обработки исключений и формирования ответов 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
    public ResponseEntity<Page<IncidentDto>> getAllIncidents(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        Page<IncidentDto> incidents = incidentService.findAllWithPagination(page, size);
        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();
    }
}

Глобальная обработка исключений с использованием @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-разработки — это бесконечное поле для экспериментов, творчества и обучения. Надеюсь, вы нашли эту статью полезной и вдохновляющей. Если у вас есть вопросы, замечания или идеи — делитесь ими в комментариях, буду искренне рад конструктивной критике, поскольку вместе мы сделаем этот путь ещё более интересным и продуктивным.

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

Комментарии (78)


  1. wertingo
    02.01.2025 19:08

    Почему все crud-операции возвращают Dto, но create возвращает entity?


    1. br0mberg Автор
      02.01.2025 19:08

      Привет, клиенту может быть важно получить ресурс без обёрток, например, id для отслеживания состояния созданного инцидента в будущем или привязать его к другим сущностям


      1. Djaler
        02.01.2025 19:08

        Id может быть и в DTO, а выставлять наружу сущность базы - антипаттерн.


        1. br0mberg Автор
          02.01.2025 19:08

          На самом деле, вашему мнению имеет место быть и не только, я бы сказал, что вы правы. Простой пример: возвращать User без обёртки с хэшом пароля - так себе идея.
          В моём случае не было конфиденциальной информации, которую нужно сокрыть, но безусловно нужно принять ваше замечание во внимание. Спасибо, я доработаю этот момент!


          1. Djaler
            02.01.2025 19:08

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


            1. br0mberg Автор
              02.01.2025 19:08

              Спасибо, это определённо внесло ясности. Ради таких комментариев, я и пишу статьи, чтобы обратить на проблему внимание с других сторон


            1. Andrey_Solomatin
              02.01.2025 19:08

              а в том - что так внешний контракт зависит от внутренних нюансов реализации

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


        1. LightSouls
          02.01.2025 19:08

          С чего бы это антипаттерн ?


          1. Djaler
            02.01.2025 19:08

            1. ryanl
              02.01.2025 19:08

              Да это просто ошибка дизайна, слишком уж громкое название - антипаттерн - для обычной ошибки.


          1. AstarothAst
            02.01.2025 19:08

            Детали внутренней реализации протекают наружу, а значит, что внутренний рефакторинг или доработка на раз-два поломают внешнее апи.


  1. kokopay
    02.01.2025 19:08

    Всегда палка о двух концах - именовать IncidentDTO или IncidentDto. Второй вариант логичное продолжение, но первый более читаемый. По какой логике в голове Вы решаете процесс именования? И как используете именования в маппере, когда нужно свой какой-то добавить?

    Статья отличная, большую часть применяю уже, но в таком хорошо и явно описанном виде впервые встречаю. Хорошо кирпичики в голове сложены, делайте tg-канал - Ваши мысли многим будут интересны не только по Java разработке.


    1. Djaler
      02.01.2025 19:08

      IncidentResponse :)
      Чаще всего не нужно смешивать объекты запроса и ответа


      1. headlyboi
        02.01.2025 19:08

        Я обычно делю DTO на *Request, *Response, когда пишу код с эндпоинтами. Это хорошая практика?


    1. br0mberg Автор
      02.01.2025 19:08

      Привет, большое спасибо за положительный комментарий! Помню, когда писал свой самый первый pet-проект, колебался на выборе между Dto и DTO. Пришёл к тому, что IncidentDto больше подходит под единообразие стиля. Стараюсь придерживаться одного общепринятого, не считая специфичных вещей, таких как именования proto-файлов, например. Насчёт именования в мапперах — тот же стиль, в духе: toEntity, toDto, toDtoWithDetails.


    1. Andrey_Solomatin
      02.01.2025 19:08

      Всегда палка о двух концах - именовать IncidentDTO или IncidentDto

      Это договорённость о стиле. Просто решите с командой как оно будет и старайтесь следовать.


  1. headliner1985
    02.01.2025 19:08

    А не проще использовать генератор, типа jhipster? А дальше уже кастомизировать по запросу бизнес требований?


    1. br0mberg Автор
      02.01.2025 19:08

      Привет! С JHipster пока не знаком, но видел, как API генерируют с помощью Amplicode — выглядит довольно удобно. Думаю, использование таких инструментов действительно ускоряет разработку, особенно в руках опытного разработчика.

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

      Спасибо за идею! Возможно, стоит рассмотреть генераторы в будущих статьях для сравнения.


  1. medvedmike
    02.01.2025 19:08

    Я бы отметил такой момент: лучше не использовать классы LocalDateTime в сущностях и DTO. То, как они сериализуются зависит от таймзоны сервера или настроек jdbc драйвера + позволяет без явного указания таймзоны получать дату, что чревато ошибками в приложениях которые работают в нескольких таймзонах (как правило, вы сперва думаете что ваше приложение маленькое и всё ок, а потом возникает необходимость поддержать пользователей из разных таймзон и вылезают разные неприятные ошибки).

    Лучше всего использовать Instant или OffsetDateTime. Для передачи по REST и стерилизации использовать стандарт ISO-8068, а для хранения в БД тип TIMESTAMP WITH TIMEZONE, тогда драйвер тоже сам разберётся в как передавать дату и время и не будет ошибок.

    А если нужно получить только дату или только время - явно конвертировать в целевую таймзону которая имеет значение с точки зрения бизнес-логики.


    1. br0mberg Автор
      02.01.2025 19:08

      Отличные рекомендации, спасибо


    1. Djaler
      02.01.2025 19:08

      ISO-8601 только. А то у вас про турбинное масло)


    1. akalexus
      02.01.2025 19:08

      Никогда не сталкивался с описанными вами проблемами связанными с LocalDate/LocalDateTime, хотя часто работаю с ними, базы умеют работать с такими типами данных. Ещё есть ISO-8601, как уже указали. Важно понимать, что такое LocalDate и OffsetDate, для чего они предназначены и когда надо использовать один тип, а когда другой. В некоторых случаях категорически нельзя LocalDate заменить на OffsetDate или конвертировать в конкретную зону, как вы рекомендуете всегда делать.


      1. Andrey_Solomatin
        02.01.2025 19:08

        Никогда не сталкивался с описанными вами проблемами связанными с LocalDate/LocalDateTime, хотя часто работаю с ними, базы умеют работать с такими типами данных.

        А чего там работать то? Это же просто число. Главное чтобы таймзона серевера и приложения совпадала, а то объекты будет создаваться в прошлом или будущем.

        В некоторых случаях категорически нельзя LocalDate заменить на OffsetDate или конвертировать в конкретную зону, как вы рекомендуете всегда делать.

        В каких именно?


        1. akalexus
          02.01.2025 19:08

          А чего там работать то? Это же просто число.

          Почитайте подробнее или послушайте подкасты про то, как работать с типами дата/время, там можно в течении нескольких часов обсуждать нюансы и особенности. И на самом деле с точки зрения дат это не совсем просто число.

          В каких именно?

          Кто плотно работает с датами, тот сразу выдаст примеры. Дата выдачи паспорта, например. Если вы ее переведёте в offset, то в разных регионах может быть разная дата, а юридически дата должна быть одна и та же.


          1. Andrey_Solomatin
            02.01.2025 19:08

            Дата выдачи паспорта, например. Если вы ее переведёте в offset, то в разных регионах может быть разная дата, а юридически дата должна быть одна и та же.

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

            Тут и дата с таймзоной и дата без таймзоны, не подходящие типы.

            2. Перевод даты в offset это операция которую нельзя проводить если вы не знаете правильную зону. Не важно как она хранится внутри с таймзоной или без, это работа разработчика положить её туда правильно. Ну и когда вы достаёте, тоже нужно отдать в правильном формате.


            1. akalexus
              02.01.2025 19:08

              ...что-то среднее между датой и строкой...

              ...дата с таймзоной и дата без таймзоны, не подходящие типы...

              Что за бред? Дата выдачи это - ДАТА в григорианском исчислении. Без таймзоны. Для хранения обычно используется LocalDate. Вы начинаете юлить, вместо того, чтобы использовать реальные аргументы.

              ...явно конвертировать в целевую таймзону...

              ...Перевод даты в offset это операция которую нельзя проводить...

              Противоречие сами себе. Если нет таймзоны в дате, то и конвертировать нечего!

              Ещё раз повторюсь: используйте LocalDate там, где не нужна зона и OffsetDate там, где нужна. Требования зависят от доменной области.


              1. Andrey_Solomatin
                02.01.2025 19:08

                Что за бред? Дата выдачи это - ДАТА в григорианском исчислении. Без таймзоны. Для хранения обычно используется LocalDate. Вы начинаете юлить, вместо того, чтобы использовать реальные аргументы.

                С точки зрения операций которые с ней можно проводить, от даты понадобятся только форматирование.

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

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


                1. akalexus
                  02.01.2025 19:08

                  Не понимаю, что вы хотите сказать.

                  Для меня, моих коллег, которые тоже работают в моей доменной области (финтех) - это именно дата, и хранится как дата. Дата документа, контракта, ордера, соглашения и т.д. это LocalDate. И в базе он хранится соответствующим образом. По этим полям сортируется по времени, фильтруется по периодам, форматируется и т.д., все что обычно делается с датами. Все эти операции были бы невозможны, если делать, как вы предлагаете.

                  Есть ещё даты, таймстемпы с таймзоной - это логи, транзакции, события, которые могут происходить в разных регионах, и которые надо фиксировать соответствующим образом.

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

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


        1. akalexus
          02.01.2025 19:08

          Вот, например, в подлодке обсуждали "Дата и время" выпуск называется https://music.yandex.ru/album/7570122/track/123897310?activeTab=track-list&ysclid=m5i7akp8im824672124

          Там узнаете, что время может прыгать, ускоряться, замедляться, изменяться и т.д.


  1. nbog
    02.01.2025 19:08

    А версионирование API?


    1. br0mberg Автор
      02.01.2025 19:08

      Статья фокусируется на основах REST API, но и про версионирование можно добавить)


  1. Andrey_Solomatin
    02.01.2025 19:08

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


    Будьте острожнее с терминами.
    Например одно из ограничений RESTful это https://en.wikipedia.org/wiki/HATEOAS.

    То что вы сделали это не RESTful, это RPC похожий на REST. Иногда его называют RESTish.

    Такой подход часто используют, просто не надо его называть RESTful.


    1. dph
      02.01.2025 19:08

      Причем Филдинг говорил, что REST подходит для гипер-медиа систем, к которым работа с инцидентами никак не относится. И зачем использовать недоREST вместо RPC - не понятно.
      При том, что RPC нормально накладывается на OOP (в отличии от REST-style, который с ООП очень плохо соотносится).
      Так что в данном сценарии выбор REST-style - является явной ошибкой дизайна и категорически "неидеальным" API.


      1. br0mberg Автор
        02.01.2025 19:08

        HATEOAS часто называют “идеологическим требованием” REST, но его применение оправдано только в определённых случаях. Да и с чего вы решили, что гипер-медиа не сочетаемы с инцидентами? Например, в следующих итерациях микросервиса добавлена работа с вложениями и изображениями, которые являются важной их частью.

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

        Напомню, что статья называется идеальный REST API. В любом случае, спасибо за то, что заставляете глубже окунуться в тематику.


        1. dph
          02.01.2025 19:08

          Хм, гипермедиа системы - это про очень специфический набор систем, при чем тут инциденты, где есть просто задачи на базовые работы с сущностями? И наличие вложений или изображений не делает систему "гипермедиа", там нет про набор внутренних связей. Даже не всякий документооборот является гипермедиа.
          REST проектировался для очень узкого класса систем, к которым да, относится статический web на html, но к которому никак не относится отдельный сервис в распределенной системе.
          И да, поэтому в рамках данной статьи выбор REST - явная архитектурная ошибка (впрочем, их в этой статье вообще довольно много). И ошибочный выбор стиля API - не может быть про "идеальное",

          Если бы статья называлась "как быстро сделать хоть какой-нибудь API" - не было бы вопросов. Но вот "идеальным" результат назвать никак нельзя.


          1. keekkenen
            02.01.2025 19:08

            абсолютно бесполезный комментарий, который выглядит как, - вот вам мое, фи, тут все неправильно !

            собственно, где в нем вопросы и подсвеченные проблемы для того, чтобы улучшить ?


      1. Andrey_Solomatin
        02.01.2025 19:08

        И зачем использовать недоREST вместо RPC - не понятно.

        А чем он реально хуже в данном пример?

        При том, что RPC нормально накладывается на OOP (в отличии от REST-style, который с ООП очень плохо соотносится).

        А разве должно быть не наоборот? PRC это вызов функции на удалённом сервисе, а REST это работа с сущностью.

        Со стороны бэкенда код будет одинаковым, что для POST api/deleteIncedent/1 что для DELETE api/incidents/1


        1. dph
          02.01.2025 19:08

          Хуже сложностью развития и уходом от реальных бизнес-задач. В рамках REST ты не можешь сделать метод "закрыть инцидент", в лучшем случае сделать patch на какое-то поле. И так далее.
          Собственно, поэтому RPC проще позволяет реализовывать ООП (который про сокрытие стейта и явный набор имеющих бизнес-смысл сообщений к объекту). В RPC ты можешь добавить специфические create, убрать patch (которые, обычно, не соответствуют реальным бизнес-задачам), разделить close и archive (два разных действия с тем же инцедентом).

          Но тут вообще проблема подхода в статье. Хороший API идет от конкретных ФТ и НФТ, от конкретных бизнес-сценариев. А в статье даже user stories не описаны, не говоря уж о выделении реальных методов, о выделении пользовательских сценариях - т.е. про все то, что реально нужно для проектирования API.


          1. br0mberg Автор
            02.01.2025 19:08

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

            Здесь нет решения конкретной бизнес-задачи, нет пользовательских сценариев по описанной выше причине.

            Я бы обязательно рассказал о проектировании API в чистом виде, без демонстрации реализации, объяснения полезных аннотаций и подходов, будь у меня на то соответствующие компетенции.

            Предлагаю вам поделиться своими глубокими познаниями в этой области в рамках статьи. Будет очень интересно ознакомиться


            1. dph
              02.01.2025 19:08

              Хм, а зачем начинающим разработчикам сразу давать плохие советы, еще и называть статью "идеальный API"?
              Если бы статья называлась "как быстро набросать REST API на спринге не приходя в сознание" - не было бы вопросов. Но в статье нигде не говорится о том, что изначальная постановка - плохая, что если вас просят написать REST API как в статье - то нужно убегать из компании с таким низким уровнем качества и таким плохим проектированием.
              И, кстати, вот подобные советы были бы джуниорам гораздо полезнее, так как быстрее сделали бы их миддлами.


              1. Andrey_Solomatin
                02.01.2025 19:08

                что если вас просят написать REST API как в статье - то нужно убегать из компании с таким низким уровнем качества и таким плохим проектированием.

                Вы неплохо в прошлом посте задвинули про необходимость изучения требования перед тем как, что-то делать. А тут делаете общие утверждения без привязки к бизнес требованиям. Это неправильно.

                Если задачу дают джуну, мидлу или аутсорсеру, то возможно просто не хотят тратить время на его ознакомление с бизнес требованиями.


                1. dph
                  02.01.2025 19:08

                  Ну, если джуну дают задачу без контекста - это и есть повод убегать из компании. Так как роста не будет, процессы в компании довольно сомнительные, онбординга нет - и так далее.
                  При том, что разработка с известным контекстом гораздо эффективнее и экономичнее в большинстве кейсов (сложно придумать обратные).


            1. dph
              02.01.2025 19:08

              Ну, про проектирование микросервисов и про проблемы разных видов API у меня достаточно много докладов. Из более-менее последних:
              https://www.youtube.com/watch?v=Sidqt7IqMFk - про разные стили API
              https://www.youtube.com/watch?v=F-6e6sfLvSc - про версионирование API и связанные проблемы
              https://www.youtube.com/watch?v=hXuyT6T3fNU - про проектирование микросервисов вообще

              Про дизайн API у меня докладов нет, зато есть доклады от Ромы Елизарова, от Doug Lea и от других хороших специалистов. А про ООП неплохо написано и у Буча и у Эванса.
              Поэтому не совсем понятно, зачем транслировать довольно странные представления о REST в обучающих статьях (


          1. Andrey_Solomatin
            02.01.2025 19:08

            Хороший API идет от конкретных ФТ и НФТ, от конкретных бизнес-сценариев. А в статье даже user stories не описаны, не говоря уж о выделении реальных методов, о выделении пользовательских сценариях - т.е. про все то, что реально нужно для проектирования API.

            Абсолютно согласен.


        1. dph
          02.01.2025 19:08

          Ну и обычно для API микросервисов не нужно кэширование на уровне GET, скорее наоборот, с возможным кэшированием на промежочном слое нужно бороться. Увы, в статье про это вообще ни слова.


  1. Yury1989
    02.01.2025 19:08

    Отличная статья. Спасибо Вам!
    Хотел бы услышать ваше мнение по следующему вопросу: целесообразно ли всю логику про выбору статус кода ответа выносить в @ControllerAdvise или же лучше что то оставлять непосредственно в самом методе контроллера? В своей практике я чаще встречаю первый подход, однако самому больше нравится когда контроллер сам может выбрать статус код в простых ситуациях, например сервисный слой может возвращать Optional что даст нам возможность в самом контроллере выбрать между 200 и 404. Работать с таким кодом по моему мнению легче и приятнее. Что Вы думаете по этому поводу?


    1. br0mberg Автор
      02.01.2025 19:08

      Спасибо за положительный комментарий. Довольно часто встречаю как сторонников подобного использования advice, так и любителей обрабатывать такие вещи локально. Тут нет единого мнения, да и в большинстве проектов комбинируют оба подхода. Я предлагаю специфичные ошибки для прозрачности обрабатывать локально, а рядовые и повторяющиеся в глобальной вариации. Это обычно обговаривается в команде


  1. dreamzy
    02.01.2025 19:08

    Интересно, что использовали @RestController, но при этом обработчик ошибок обычный? Есть также @RestControllerAdvice, где не нужно оборачивать ответ в ResponeEntity и управлять статусами можно через аннотацию @ResponseStatus


    1. br0mberg Автор
      02.01.2025 19:08

      Благодарю, добавил этот момент в статью!


  1. endpoints
    02.01.2025 19:08

    Огромное вам спасибо, вы мне сильно облегчили понимание некоторых моментов


    1. br0mberg Автор
      02.01.2025 19:08

      Благодарю. Искренне рад, что смог вам помочь)


  1. ruslooob2
    02.01.2025 19:08

    Напомнило чем-то отчёт лабораторной работы в универе. Много воды.


    1. br0mberg Автор
      02.01.2025 19:08

      Мне действительно довольно часто приходится писать научные работы в универе, но не касающихся веб-разработки. Я искренне убеждён, что такой детализированный подход более «дружелюбен» начинающим в этой стезе специалистам. Спасибо за отзыв!


  1. endpoints
    02.01.2025 19:08

    А код случайно не залили в таком виде на гитхаб?


    1. br0mberg Автор
      02.01.2025 19:08

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