1. Дисциплина конфигурации
К этому месту у нас уже есть все детали: runtime-параметры вынесены из кода, базовый application.yml стал читаемым, app.attachments.* доходит до storage через AttachmentProperties, а local-override живёт поверх base-конфига. Теперь осталось собрать это в один воспроизводимый baseline проекта и быстро проверить три вещи: нет ли хардкода, не спорят ли друг с другом multipart-лимиты и не появился ли второй источник истины для storage-dir. Так что приступим.
Когда проект маленький, очень легко поверить в миф: “главное — чтобы запускалось, а конфиг потом подчистим”. Это примерно как сказать: “главное — чтобы лампочка горела, а проводку потом аккуратно уложим”. Технически свет будет, но однажды вы случайно заденете провод ногой — и внезапно узнаете, что такое “почему оно вчера работало”.
Сейчас важно не заново спорить, что вообще выносить в YAML. Этот вопрос мы уже закрыли. Здесь задача практичнее: сделать так, чтобы Task Tracker API запускался одинаково у всей команды, а конфиг не прятал в себе вторую копию логики. Framework-настройки живут под spring.*, настройки приложения — под app.*, runtime-параметры меняются через base-конфиг, профиль и overrides, а API-контракт и доменные правила остаются в коде.
2. Обязательные ключи запуска Task Tracker API
Если смотреть на конфигурацию как на маленький контракт между “кодом” и “окружением”, то у этого контракта должен быть минимальный обязательный набор пунктов. Иначе получится ситуация: приложение стартует “как-нибудь”, ошибки выглядят случайными, а вы отлаживаете не задачу, а собственное окружение. В учебном проекте нам нужна не универсальность, а предсказуемость, поэтому лучше иметь несколько обязательных ключей, но держать их в одном понятном месте.
В нашем проекте есть хорошее естественное разделение: всё, что относится к Spring MVC и servlet-инфраструктуре, лежит под spring.*, а всё, что относится к правилам запуска именно нашего приложения, лежит под app.*. Это не “правило ради правила”, а способ сделать файл читаемым: даже человек, который впервые открыл application.yml, быстро поймёт, где “настройки платформы”, а где “настройки проекта”.
Ниже — таблица минимально обязательных ключей, которые стоит держать в базовой конфигурации (не в профиле), чтобы Task Tracker API запускался одинаково у всех.
| Ключ | Пример значения | Зачем нужен | Уровень |
|---|---|---|---|
| spring.mvc.problemdetails.enabled | true | Включает Problem Details как базовый формат ошибок (application/problem+json) | framework-level (spring.*) |
| spring.servlet.multipart.max-file-size | 10MB | Ограничивает размер одного файла при upload | framework-level (spring.*) |
| spring.servlet.multipart.max-request-size | 12MB | Ограничивает общий размер multipart-запроса (файл + metadata + служебные части) | framework-level (spring.*) |
| app.attachments.storage-dir | ./data/attachments | Говорит storage-слою, где хранить содержимое файлов | application-level (app.*) |
Этого набора достаточно, чтобы runtime-baseline проекта был явным, а не распределённым по памяти разработчиков.
3. Согласованные multipart-лимиты
Multipart-лимиты — это тот случай, когда конфиг может быть “правильным по синтаксису”, но “неправильным по смыслу”. И это особенно неприятно, потому что ошибка проявляется не там, где вы ожидаете: запрос даже не дойдёт до вашего контроллера, а вы будете смотреть на лог и думать “почему мой endpoint не вызывается, я же его точно написал”.
В multipart у нас есть как минимум две величины: максимальный размер одного файла и максимальный размер всего запроса. Если упростить до бытовой аналогии, max-file-size — это ограничение “размер одного чемодана”, а max-request-size — ограничение “общий вес багажа”. Логично, что общий багаж должен быть не меньше одного чемодана, иначе вы разрешили чемодан на 20 кг, но багажник — на 10 кг. Формально вы ничего не нарушили, но поездка закончится на первом же посте контроля.
Плохой пример, который выглядит “как будто нормально”, но на практике создаёт непредсказуемость:
spring:
servlet:
multipart:
# Противоречие: один файл можно 20MB...
max-file-size: 20MB
# ...но весь запрос целиком — только 10MB
max-request-size: 10MB
Теперь вы сами себе противоречите: одному файлу можно 20MB, но весь запрос — только 10MB. В итоге для пользователя это будет выглядеть как “то ли 413 Payload Too Large, то ли какая-то странная ошибка загрузки”, а для вас — как “почему не работает, если я разрешил 20MB”.
Хорошая базовая стратегия для нашего проекта простая: раз мы загружаем один файл и небольшой JSON-метаданный блок, то max-request-size должен быть чуть больше max-file-size. Например, 10MB и 12MB — это понятная пара. Плюс она хорошо читается глазами: видно, что запрос включает не только файл.
spring:
servlet:
multipart:
# Один файл
max-file-size: 10MB
# Весь запрос (файл + метаданные)
max-request-size: 12MB
Ещё один нюанс: очень легко “на всякий случай” поставить огромные значения, чтобы “точно не мешало”. Но в учебном проекте это контрпродуктивно: вам важнее научиться контролировать границы, чем сделать “бесконечный upload”. Большие лимиты увеличивают риск, что вы начнёте хранить в памяти или логах то, что хранить не надо, и в целом усложняют отладку.
4. Явные defaults
Defaults в конфигурации — это место, где новички чаще всего попадают в ловушку: они либо не задают дефолт вообще (“пусть будет как получится”), либо задают дефолт так, что проект становится привязан к одной машине (“у меня на диске D всё красиво”). Истина тут простая: базовое значение должно быть переносимым и должно быть видно прямо в base-конфиге.
Для app.attachments.storage-dir таким значением у нас служит ./data/attachments. Это не “тайный fallback в Java-классе”, а явный default проекта. Его видно сразу после открытия application.yml, и поэтому любой разработчик понимает, куда по умолчанию пойдут файлы.
app:
attachments:
# Базовый путь для репозитория; локальные отличия перекрываются профилем или env var
storage-dir: ./data/attachments
Именно поэтому AttachmentProperties не должен придумывать второй fallback внутри кода. Если storage-dir — критичное свойство, base-конфиг задаёт его явно, а @Validated и @NotBlank страхуют от пустого значения при старте. Иначе у вас появляются две истины: YAML говорит одно, а Java-класс молча подставляет другое.
5. Убираем хардкод в storage
Конфигурация считается сделанной не тогда, когда вы добавили красивый YAML, а тогда, когда вы убрали из кода последние “магические строки”, которые противоречат идее внешних настроек. Очень типичная ситуация: вы добавили app.attachments.storage-dir, но где-то в storage всё ещё стоит Path.of("/tmp/..."), потому что “я просто тестил”. Через неделю вы уже не помните, что это было тестом, и начинаете искать “почему файлы не там”.
После AttachmentProperties у storage остаётся одна честная задача: взять typed config object и работать с ним. Он не должен сам выбирать путь, держать запасную константу или спорить с YAML.
package com.example.tasktracker.infrastructure.storage;
import com.example.tasktracker.config.AttachmentProperties;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class LocalAttachmentStorage {
private final Path root;
public LocalAttachmentStorage(AttachmentProperties props) {
// Единственный источник истины о директории — typed config object
this.root = Path.of(props.storageDir());
}
}
Да, тут можно добавить нормализацию пути (normalize()), перевод в абсолютный (toAbsolutePath()), создание директории (Files.createDirectories(root)). Но это уже детали file storage реализации. В контексте сегодняшней лекции важно зафиксировать дисциплину: storage не выбирает путь сам и не хранит его в исходниках.
6. Валидация конфигурации
Валидация конфигурации — это “страховка от человеческого фактора”. И она особенно полезна там, где без настройки подсистема физически не может работать. Для storage-dir это ровно наш случай: пустой или отсутствующий путь должен ломать запуск сразу, а не всплывать в первом upload как Path.of(null) или странный 500.
Этот узел у нас уже держится на @Validated и @NotBlank в AttachmentProperties. Для финального baseline важна одна мысль: storage-dir остаётся required application-level key, base application.yml даёт ему явное значение, а fail-fast на старте не даёт приложению перейти в полурабочее состояние.
Ещё одна полезная граница: не смешивайте в голове “валидацию конфига” и “валидацию запросов”. Когда пользователь прислал плохой JSON — это 400 Bad Request и наш error contract. Когда у приложения сломан конфиг — это проблема запуска, и правильное поведение чаще всего одно: приложение не должно стартовать.
7. Baseline application.yml
На финальном этапе хочется сделать две вещи одновременно: сохранить файл коротким и сделать его достаточно явным, чтобы не зависеть от “дефолтов Spring Boot, которые я не помню”. Это не противоречие, если держать дисциплину: вы фиксируете ровно те настройки, которые влияют на поведение вашего прикладного сценария, а не пытаетесь “сконфигурировать весь Spring”.
Ниже пример базового application.yml, который хорошо подходит для нашего текущего состояния проекта. Он задаёт Problem Details, multipart-лимиты и storage-dir. Всё остальное оставляется дефолтам, потому что либо не влияет на сценарий, либо требует отдельной дисциплины (которую мы в этом курсе сознательно не раздуваем).
spring:
mvc:
problemdetails:
# Единый формат ошибок для API
enabled: true
servlet:
multipart:
# Один загружаемый файл
max-file-size: 10MB
# Весь multipart-запрос целиком (файл + метаданные)
max-request-size: 12MB
app:
attachments:
# Базовая директория хранения; профиль и env vars могут её переопределить
storage-dir: ./data/attachments
В этом фрагменте важна не только конкретика значений, а то, что их теперь легко “прочитать как документ”. Прямо по YAML видно, что проект ожидает: ошибки в Problem Details, загрузку файлов до 10MB и хранение вложений в ./data/attachments. Это и есть цель дисциплины: чтобы конфигурация перестала быть “тайным знанием автора проекта” и стала явной частью репозитория.
8. Типичные ошибки в конфигурации
Ошибка №1: оставить часть хардкода “на всякий случай”.
Очень часто после внедрения @ConfigurationProperties в коде остаётся старая константа, например Path.of("/tmp/task-tracker/uploads"), или тихий fallback внутри AttachmentProperties, и она начинает спорить с YAML. В итоге конфиг есть, typed object есть, а файлы всё равно летят в старое место. Как только путь вынесен в конфигурацию, в storage-реализации и в config-class не должно оставаться второй скрытой истины про директорию.
Ошибка №2: сделать max-request-size меньше max-file-size.
Такая конфигурация формально корректна, но логически противоречива. Она приводит к тому, что загрузка начинает ломаться “раньше контроллера”, а вы пытаетесь чинить не то место. Хорошее правило: если multipart несёт один файл и небольшой metadata-part, то max-request-size должен быть немного больше max-file-size, чтобы не возникало ощущения “сегодня загрузилось, завтра — нет”.
Ошибка №3: вынести в YAML то, что является частью API-контракта.
Переносить в конфиг ограничения DTO, правила статусов, допустимые переходы, ограничения на title — это ошибка архитектуры. Такие вещи должны жить в коде, потому что они описывают контракт API и предметную область, а не окружение запуска. Иначе вы получите ситуацию, где один разработчик “подкрутил YAML”, и клиенты вдруг увидели другое поведение без изменения версии API.
Ошибка №4: не различать spring.* и app.* и устроить “плоскую свалку ключей”.
Когда свойства смешаны в корне файла без структуры, YAML перестаёт быть документом. Человек читает “мешок ключей” и не понимает, что управляет чем. Дисциплина здесь простая: всё, что относится к платформе (MVC, multipart), остаётся в spring.*, всё, что относится к вашему прикладному домену (attachments.storage-dir), живёт в app.*.
Ошибка №5: не валидировать критичные application-level свойства.
Если storage-dir обязателен, отсутствие валидации означает, что приложение может стартовать, а потом ломаться в момент загрузки файла. Это худший сценарий: проблема обнаруживается поздно, в непредсказуемом месте и обычно с менее понятной ошибкой. @Validated и @NotBlank на AttachmentProperties — простой способ сделать поведение “или запускаемся корректно, или не запускаемся вообще”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ