JavaRush /Курсы /Spring Boot /Формат дат и времени в Spring MVC

Формат дат и времени в Spring MVC

Spring Boot
11 уровень , 2 лекция
Открыта

1. Даты в query-параметрах

Границу здесь полезно держать очень чётко: мы всё ещё на стороне web-binding. Нас не интересует application.yaml и старт приложения — только момент, когда строка из URL должна стать LocalDate в параметре контроллера.

Как только сервис становится чуть более «живым», чем “Hello, world”, у него появляются фильтры. И среди фильтров почти всегда где-то рядом стоит дата: «покажи курсы, которые запустились после…», «покажи события с…», «отдай список заказов за…». В этот момент начинающий разработчик часто попадает в классическую ловушку: в коде у нас нормальные Java-типы (LocalDate), а в запросе всё приходит строкой, и эта строка внезапно начинает жить по своим законам.

Давайте посмотрим на типичную картину на уровне пользователя API. Он открывает браузер и пишет:

/api/catalog/courses?launchedAfter=01.04.2026

Потому что «ну так же люди пишут дату, чего вы». А вы, как Java-разработчик, видите LocalDate и думаете: «Окей, сейчас как-нибудь распарсим». И тут начинается маленькая трагикомедия.

Можно, конечно, сделать вот так — вручную:

import java.time.LocalDate;

import org.springframework.web.bind.annotation.RequestParam;

public LocalDate parseLaunchedAfter(@RequestParam(required = false) String launchedAfter) {
    // Если параметр не пришёл — это нормальная ситуация для фильтра: возвращаем null
    // LocalDate.parse(...) по умолчанию ожидает ISO: 2026-04-01
    return launchedAfter == null ? null : LocalDate.parse(launchedAfter);
}

Формально это даже “работает”, пока клиент посылает ISO (2026-04-01). Но как только кто-то прислал 01.04.2026, вы ловите DateTimeParseException, а ваш контроллер превращается в место, где «почему-то» нужно писать обработку ошибок, объяснения форматов и ещё миллион мелких условий. Контроллер в этот момент начинает напоминать не web-слой, а охранника на входе в клуб: «в таком формате нельзя», «а в таком можно», «а справка где?».

В нормальном Boot-сервисе хочется другого ощущения: чтобы контроллер был коротким и типизированным, чтобы он принимал LocalDate, а не String, и чтобы формат даты был единым и предсказуемым для всех endpoint’ов, а не «как получится на этой машине сегодня».

2. Как работает spring.mvc.format.*

Когда мы говорим «форматирование дат в MVC», важно не улететь в абстракции. Нам не нужно знать все внутренности Spring MVC, но нужно чётко понимать, что происходит на практике: строка из URL должна превратиться в Java-тип параметра метода контроллера. И это преобразование делает MVC-механизм конвертации/форматирования, который мы уже называли MVC ConversionService.

Удобно представлять это как маленький конвейер:

flowchart TD
    A["HTTP запрос
... ?launchedAfter=2026-04-01"] --> B["Строковое значение из query-параметра"] B --> C["MVC ConversionService
(formatters/converters)"] C --> D["LocalDate launchedAfter"] D --> E["Ваш controller-метод"]

Свойства spring.mvc.format.* — это Boot-friendly способ сказать MVC: «Вот тебе глобальный формат для дат/времени, используй его при web-binding». То есть эти настройки влияют на:

- query-параметры (@RequestParam)
- path-переменные (@PathVariable)
- в целом на то, как MVC связывает строковые значения запроса с Java-типами

И тут очень важно понять, что они не влияют на другие «похожие» вещи. Например, они не являются настройкой для binding конфигурации приложения (это вообще другая подсистема). Они также не являются «универсальной настройкой всего мира дат» в приложении. Мы сегодня настраиваем конкретно поведение web-слоя, которое включается тогда, когда к нам прилетает HTTP-запрос.

Здесь достаточно удерживать одну границу: spring.mvc.format.* настраивает именно этот web-конвейер. Конфигурационный binding приложения живёт отдельно и к датам в query-параметрах отношения не имеет. Нас сейчас интересует только путь «строка из URL -> тип параметра контроллера», и для temporal-типов у Boot есть штатная ручка.

Значит, задача простая: договориться, как launchedAfter выглядит снаружи, и дать контроллеру честный LocalDate, а не заставлять его парсить строки вручную.

3. ISO-формат дат через application.yaml

Для стандартных temporal types самый спокойный путь — выбрать ISO-формат и один раз зафиксировать его в MVC-настройке. Он выглядит «по-компьютерному», зато стабилен, однозначен и почти не вызывает споров (споры он вызывает только у тех, кто считает, что 01.04.2026 — это единственная истина). Для API это обычно хороший выбор: 2026-04-01. А ещё он идеально ложится на LocalDate без всяких часов, минут и мыслей о временных зонах.

Добавляем настройку spring.mvc.format.date

Добавим в src/main/resources/application.yaml (или в тот YAML, который вы сейчас используете как базовый конфиг) такой блок:

spring:
  mvc:
    format:
      # Глобально задаём ISO-формат дат для MVC binding (query/path -> параметры контроллера)
      date: iso

Эта настройка читается Spring Boot’ом при сборке MVC-инфраструктуры и влияет на то, как будут парситься даты в web-layer. Самое приятное здесь — нам не нужно писать никакого Java-кода, никакого LocalDate.parse(...) в контроллерах и никакой «локальной» самодеятельности. Мы просто один раз договорились: «для дат — ISO», и сервис стал предсказуемым.

Делаем launchedAfter типизированным в контроллере

Теперь контроллер может честно попросить LocalDate, а MVC сам сделает преобразование из строки:

import java.time.LocalDate;
import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/catalog/courses")
public List<CourseCard> findCourses(
        // Фильтр опциональный: если параметр не передали, Spring положит сюда null
        @RequestParam(required = false) LocalDate launchedAfter) {

    // В контроллере не парсим строки — отдаём типизированное значение дальше в сервис
    return service.findCourses(launchedAfter);
}

Обратите внимание на важную «мелочь», которая спасает нервы: required = false. Параметр фильтра — вещь опциональная. Если его нет, Spring передаст null, и это нормально. Ненормально — если мы «по умолчанию» ожидаем дату всегда, а потом удивляемся, почему запрос без фильтра падает.

Фильтрация по дате в сервисе

В сервисе мы можем сделать максимально читаемую фильтрацию. Поскольку проект учебный и read-only, нам достаточно простой логики в памяти. Главное — не тащить это в controller.

import java.time.LocalDate;
import java.util.List;

public List<CourseCard> findCourses(LocalDate launchedAfter) {
    return repository.findAll().stream()
            // launchedAfter == null => фильтр не задан, пропускаем все элементы
            // isAfter(...) => курс должен стартовать строго позже указанной даты
            .filter(c -> launchedAfter == null || c.launchDate().isAfter(launchedAfter))
            .toList();
}

Здесь нет ничего «магического»: либо фильтр не задан (launchedAfter == null), либо курс должен быть строго позже указанной даты. Для LocalDate это читается естественно. А теперь самое важное: эта логика работает только потому, что controller получил уже готовый LocalDate, а не строку, которую мы парсим “как получится”.

Проверяем запрос

Теперь запрос выглядит так:

GET /api/catalog/courses?launchedAfter=2026-04-01

И это хороший формат для API: его легко сгенерировать программно, легко прочитать глазами, легко сравнить строками, и у него почти ноль зависимости от локали компьютера, на котором стоит клиент.

Если клиент случайно пришлёт что-то вроде launchedAfter=01.04.2026, MVC не сможет распарсить дату и вернёт ошибку. Это, на самом деле, здоровое поведение: сервис явно требует формат (контракт), и если формат нарушен — это ошибка клиента, а не повод для контроллера превращаться в “угадайку”.

4. Форматы времени в URL

Даже если сегодня вы используете только LocalDate, полезно понимать, что в Spring MVC есть ещё две соседние категории: время и дата-время. В реальных сервисах они появляются очень быстро, потому что «после даты» обычно хочется точнее: «после такого-то времени» или «после такого-то момента». И если вы заранее знаете, где это настраивается, вы не будете искать ответ по логам, как будто это квест.

Spring Boot даёт три ключа:

- spring.mvc.format.date — для дат (например, LocalDate)
- spring.mvc.format.time — для времени (например, LocalTime)
- spring.mvc.format.date-time — для даты-времени (например, LocalDateTime)

Можно задать их сразу единым ISO-правилом:

spring:
  mvc:
    format:
      # ISO для LocalDate
      date: iso
      # ISO для LocalTime
      time: iso
      # ISO для LocalDateTime
      date-time: iso

ISO-формат для date-time

ISO для date-time выглядит примерно так:

2026-04-01T10:15:30

И T здесь — не прихоть Spring и не тайный знак масонов. Это часть ISO 8601, которая разделяет дату и время. Для URL это очень удобно, потому что там нет пробела.

Почему пробел — проблема? Потому что пробел в URL может превращаться в + или %20. А если вы зададите формат yyyy-MM-dd HH:mm, то вам нужно будет помнить про URL-encoding, иначе MVC получит не то, что вы ожидаете, и начнётся «а почему оно не парсится, я же всё правильно написал».

Если уж вам действительно нужен кастомный формат, лучше выбирать тот, который дружит с URL, например с 'T':

spring:
  mvc:
    format:
      # Кастомный шаблон для LocalDateTime: используем 'T', чтобы в URL не было пробела
      date-time: "yyyy-MM-dd'T'HH:mm"

Тут две тонкости. Во-первых, кавычки в YAML полезны, потому что формат — это строка с символами, которые YAML может трактовать по-своему. Во-вторых, 'T' в шаблоне означает «буквально буква T», а не какая-то “магическая переменная времени”.

Параметр LocalDateTime

Представьте параметр, который приходит как “после такого-то момента”:

import java.time.LocalDateTime;

import org.springframework.web.bind.annotation.RequestParam;

public void example(@RequestParam LocalDateTime changedAfter) {
    // changedAfter уже типизированный: парсинг и валидация формата происходят до входа в метод
}

И опять же: главный смысл не в том, что мы прямо сейчас обязаны вводить LocalDateTime в проект. Смысл в том, что у вас есть единый и предсказуемый способ сделать web-binding “нормальным”, без ручных парсеров и разброда форматов.

5. Точечный формат через @DateTimeFormat

Глобальное правило удобно, пока вы контролируете контракт API и хотите, чтобы сервис был единым и понятным. Но иногда жизнь подкидывает “интересные” условия: например, вы интегрируетесь с клиентом, который уже исторически шлёт даты в формате dd.MM.yyyy, или вы делаете совместимость со старым UI. В таких случаях менять глобальное правило под один endpoint может быть слишком грубо — и тогда помогает точечная аннотация.

В Spring MVC для этого есть @DateTimeFormat. Она ставится прямо на параметр и говорит: «для этого конкретного параметра используй вот этот формат»:

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.RequestParam;

public void example(
        @RequestParam(required = false)
        // Точечно задаём формат только для этого параметра (глобальная настройка MVC не меняется)
        @DateTimeFormat(pattern = "dd.MM.yyyy")
        LocalDate launchedAfter) {
}

Практический плюс здесь в том, что преобразование всё равно делает MVC, а не ваш ручной код. Это значит, что поведение будет единым: ошибки будут формироваться стандартно, conversion будет происходить до входа в метод, и вы не превратите контроллер в коллекцию try/catch.

Но важно не злоупотреблять этим стилем. Если каждый endpoint начнёт принимать дату в своём формате, вы получите «зоопарк контрактов», и API станет сложно использовать и тестировать. Обычно либо вы держите глобальный ISO-формат, либо очень осознанно делаете исключение в одном месте, а не размазываете исключения по проекту.

6. Типичные ошибки при настройке spring.mvc.format.*

Даты и время в программировании — это как кот: если относиться к ним легкомысленно, они обязательно найдут способ вас поцарапать. В MVC-слое большинство проблем даже не “сложные”, а просто неочевидные: кажется, что вы договорились о формате, а на самом деле — договорились только в своей голове. Ниже — ошибки, которые встречаются чаще всего, и которые особенно легко сделать в начале пути.

Ошибка №1: ожидать, что spring.mvc.format.* влияет на всё подряд, включая любые данные в приложении.
Эти настройки относятся к MVC web-binding, то есть к тому, как значения из URL (query/path) превращаются в параметры контроллера. Они не являются универсальной настройкой «всего, где есть дата». Если вы пытаетесь лечить ими, например, поведение другого слоя, вы просто будете менять не ту ручку.

Ошибка №2: продолжать принимать даты как String и парсить вручную в контроллере.
Это кажется быстрым решением, но оно размазывает формат по коду, усложняет обработку ошибок и превращает контроллер в слой “ручного связывания”. В нормальной MVC-модели контроллер просит LocalDate, а формат задаётся централизованно через конфигурацию. Так проще читать, проще поддерживать и сложнее случайно сломать.

Ошибка №3: выбрать формат с пробелом для date-time и забыть про URL-encoding.
Формат вроде yyyy-MM-dd HH:mm выглядит «человечно», но для URL он коварен. Пробел может стать +, может стать %20, а может быть обработан по-разному разными клиентами. Если уж нужен кастомный формат, выбирайте URL-friendly вариант (... 'T' ...) или используйте ISO.

Ошибка №4: настроить только spring.mvc.format.date, а потом удивляться поведению LocalTime и LocalDateTime.
Это частая психологическая ловушка: «мы же настроили форматирование». На самом деле вы настроили только даты. Время и дата-время — отдельные ключи, и если вы начинаете принимать такие типы в параметрах контроллера, лучше явно зафиксировать и их формат тоже, чтобы не получить сюрпризы на уровне локали или дефолтов.

Ошибка №5: перепутать MVC binding и binding конфигурации приложения.
Мы уже разделяли эти миры: MVC связывает запрос в параметры контроллера, конфигурационный binding связывает настройки приложения в Java-объекты. Если ждать, что настройка MVC-формата каким-то образом “настроит формат” для конфигурации приложения — вы попадёте в режим «оно вроде похоже, но не работает». Это разные механики.

1
Задача
Spring Boot, 11 уровень, 2 лекция
Недоступна
Глобальный ISO-формат для `LocalDate`
Глобальный ISO-формат для `LocalDate`
1
Задача
Spring Boot, 11 уровень, 2 лекция
Недоступна
Глобальный формат для `LocalDateTime`
Глобальный формат для `LocalDateTime`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ