1. Карта источников конфигурации
Представьте ситуацию: вы честно меняете app.catalog.title в application.yaml, сохраняете файл, запускаете сервис… а заголовок в ответах всё равно старый. Первая мысль — «IDE не сохранила», вторая — «Spring опять магия», третья — «я точно программист, или просто удачливый печататель YAML?». На самом деле проблема почти всегда банальна: значение берётся не из того места, которое вы смотрите.
Spring Boot умеет собирать конфигурацию из нескольких источников одновременно, и если один и тот же ключ задан в нескольких местах, приложение всё равно увидит ровно одно итоговое значение. Чтобы не лечить симптомы, нам нужна нормальная mental model: какие бывают источники, как они складываются и почему одно значение выигрывает у другого.
PropertySource и источники свойств
Когда вы слышите «конфигурация Spring Boot», мозг обычно рисует один файл: application.yaml. Это нормально, мы все так начинали. Но в терминах Spring это слишком узко. PropertySource — это просто «место, откуда можно взять значение по ключу». Это может быть YAML-файл, переменная окружения ОС, параметр JVM, аргумент командной строки или даже «JSON-блок, спрятанный в переменной окружения». Файл — лишь один из вариантов.
Важно поймать простую мысль: Boot не читает «какой-то один правильный источник», он собирает слойный пирог. Каждый слой — это PropertySource. Слои накладываются друг на друга, и в итоге получается одна итоговая картина мира, из которой уже читает ваш код. Поэтому вопрос «в каком файле лежит значение» часто неправильный. Правильный вопрос звучит так: «какое итоговое значение видит приложение в runtime, и какой источник его победил?».
Чтобы ощутить это руками, полезно рассматривать конфигурацию как огромную Map<String, String>, но собранную из разных под-карт. Spring Boot выступает в роли аккуратного сборщика: он берёт эти под-карты, выстраивает их в определённом порядке и говорит: «если ключ встречается несколько раз — победит тот, кто стоит выше».
2. precedence и приоритеты источников
Слово precedence в этой теме звучит страшнее, чем оно есть на самом деле. Это не «тайная магическая настройка», а обычный порядок приоритетов: кто сильнее, тот и прав. Если один и тот же ключ присутствует в нескольких источниках, Boot не устраивает демократию, не проводит голосование и не спрашивает совета у вашего кота. Он берёт значение из более приоритетного источника — и всё.
Удобнее всего думать о precedence как о стопке прозрачных плёнок. Внизу лежит базовый слой — значения по умолчанию. Выше накладываются слои, которые уточняют поведение. Если наверху нарисовано новое значение для того же ключа, нижнее просто перекрывается. Это нормально и даже полезно: вы можете оставить хороший дефолт в приложении, внутри jar, а при запуске на другой машине перекрыть отдельные значения без пересборки.
Вот упрощённая схема. Мы пока специально не уходим в точный порядок всех редких источников — нам важна карта, а не справочник на 40 страниц.
flowchart TB
A["Packaged config (внутри приложения)"] --> E["Spring Environment (итоговые значения)"]
B["External config (рядом с запуском)"] --> E
C["Launch-time overrides (параметры запуска)"] --> E
E --> F["Ваш код читает env.getProperty(...)"]
Здесь ключевая идея не в том, что файлы плохие, а параметры запуска хорошие. Идея в том, что у конфигурации есть слои ответственности. Файл внутри приложения обычно задаёт базовый дефолт. Внешний файл — настройку для конкретного окружения. А параметры запуска — разовый override здесь и сейчас.
Если вы поймёте этот принцип, большинство конфигурационных мистических багов внезапно перестанут быть мистическими. Они станут скучными. А скучное — это прекрасно, потому что скучное чинится по инструкции.
3. Packaged и external конфигурация
Теперь про то, что чаще всего реально встречается в проектах: конфигурация внутри приложения и конфигурация снаружи. На уровне нашей mental model достаточно помнить два слова: packaged и external.
Packaged config — это то, что лежит в вашем проекте в src/main/resources и уезжает внутрь jar. Например, src/main/resources/application.yaml. Когда вы собираете приложение, этот файл становится частью артефакта. Это удобно, потому что у приложения появляется встроенный дефолт: оно может стартовать даже без внешних файлов.
External config — это то, что лежит рядом с запуском приложения, условно «файл рядом с jar» или в типовой папке конфигурации. Идея простая: вы не хотите пересобирать jar, чтобы поменять заголовок, порт или флаг режима обслуживания. Поэтому вы подкладываете внешний файл, и Boot использует его как более сильный слой по сравнению со встроенным.
Выглядит это максимально буднично: один и тот же ключ может встретиться в двух местах, и это не ошибка, если вы чётко понимаете, какой из них — дефолт, а какой — override.
# packaged: src/main/resources/application.yaml
app:
catalog:
title: "Catalog from jar"
# если ключ повторяется, победит более приоритетный слой (например, внешний файл)
# external: условно ./config/application.yaml рядом с запуском
app:
catalog:
title: "Catalog from external file"
Если приложение увидело "Catalog from external file", это не «оно проигнорировало ваш application.yaml». Оно его прочитало. Просто сверху лежал слой, который перекрыл значение.
И вот тут появляется важная дисциплина: один и тот же ключ в нескольких файлах допустим, но только если вы можете объяснить, зачем. Если вы не можете — это уже не layered config, а конфигурационная археология: через три дня вы будете раскопками заниматься, выясняя «а где же настоящее значение».
4. Environment: итоговая конфигурация
Слово Environment в Spring почти гарантированно вызывает путаницу у новичков, потому что в голове уже есть environment variables операционной системы. Да, ОС тоже даёт переменные окружения. Но Spring Environment — это другое. Это Spring-объект, который хранит итоговый результат сборки всех PropertySource с учётом precedence. То есть это «всё, что приложение знает о своих настройках», в одном месте.
Эта идея сильно упрощает жизнь: вашему коду не нужно знать, откуда пришло значение. Из файла? Из переменной окружения? Из аргумента запуска? Неважно. Код читает Environment — и получает финальный ответ. А когда что-то работает не так, вы не спорите о файлах на глаз, а идёте в Environment и проверяете, что реально видит runtime.
В catalog-service мы можем, в учебных целях, добавить маленький раннер, который выводит пару ключей и показывает, что победило. Сейчас мы используем System.out.println, потому что отдельная дисциплина логирования у нас будет позже, а сегодня нам важнее увидеть сам принцип.
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component
class PropertyProbeRunner implements ApplicationRunner {
private final Environment environment;
PropertyProbeRunner(Environment environment) {
// Spring сам внедрит Environment, который уже собрал все PropertySource с учётом precedence
this.environment = environment;
}
@Override
public void run(ApplicationArguments args) {
// Читаем итоговое значение: не важно, пришло оно из YAML, env vars или аргумента запуска
// "<missing>" — явный дефолт, чтобы отличать "ключ отсутствует" от "ключ есть, но пустой"
String title = environment.getProperty("app.catalog.title", "<missing>");
// Для демонстрации принципа используем System.out.println (логирование будет отдельной темой)
System.out.println("app.catalog.title = " + title); // app.catalog.title = Spring+ Catalog
}
}
Этого одного временного probe-runner для таких проверок вполне достаточно. Дальше, если захотите посмотреть другой ключ или сравнить другой канал запуска, проще менять содержимое run(...), чем держать в проекте коллекцию похожих стартовых помощников: иначе startup быстро превращается в шум.
Обратите внимание на две детали. Во‑первых, мы не парсим YAML руками и не читаем файлы через Files.readString(...). Во‑вторых, мы используем дефолт "<missing>", чтобы явно отличать ситуацию «ключ не найден» от ситуации «ключ найден, но пустой». Это маленькая привычка, но она очень спасает нервы.
Ещё одна полезная возможность Environment: он умеет простую конвертацию типов. Пока мы не привязываем конфигурацию к отдельным Java-объектам, но даже на базовом уровне можно попросить Spring вернуть значение как Integer или Boolean.
import org.springframework.core.env.Environment;
class SimpleReads {
private final Environment env;
SimpleReads(Environment env) {
// Environment здесь — тот же итоговый "слепок" конфигурации, собранный из всех источников
this.env = env;
}
void show() {
// Spring попробует сконвертировать строковое значение в нужный тип
// Третий аргумент — дефолт, который будет использован, если ключ не найден
Integer limit = env.getProperty("app.catalog.max-featured-count", Integer.class, 4);
Boolean maintenance = env.getProperty("app.catalog.maintenance-mode", Boolean.class, false);
System.out.println("limit = " + limit); // limit = 4
System.out.println("maintenance = " + maintenance); // maintenance = false
}
}
Это всё ещё сырой способ работы с конфигурацией, но для сегодняшней темы он идеален: он подчёркивает, что главный объект, который видит приложение, — Environment, а не отдельный файл.
5. Мини-кейс: один ключ в разных источниках
Давайте соберём в голове максимально типичный и максимально жизненный кейс: один ключ app.catalog.title задан в нескольких местах. Например, в src/main/resources/application.yaml вы оставили базовое значение, потому что проект должен стартовать «из коробки». Затем вы запускаете приложение на другой машине и хотите другой заголовок — подкладываете внешний конфиг. А потом, в момент диагностики, вы делаете разовый запуск с параметром, потому что «надо проверить вот прямо сейчас, не трогая файлы».
Мы сейчас не углубляемся в конкретный синтаксис и точный порядок всех каналов запуска — это отдельные темы следующих лекций дня. Здесь важен сам принцип: ключ один, источников несколько, итоговое значение одно.
Схематично это можно представить так:
| Где задано значение | Пример значения | Роль |
|---|---|---|
| Packaged config (внутри jar) | Catalog from jar | базовый дефолт, чтобы проект стартовал |
| External config (рядом с запуском) | Catalog from external file | настройка для конкретной среды/машины |
| Launch-time override (при запуске) | Catalog from CLI | разовая корректировка «на этот запуск» |
И именно поэтому при отладке всегда полезно задавать себе вопрос не «в каком файле у меня написано…», а «какой итоговый слой сейчас победил?». Потому что вы можете смотреть в packaged application.yaml до посинения, но если сверху есть override, ваш взгляд будет абсолютно честным, но бесполезным.
В реальном проекте это и есть причина, почему человек говорит: «Я же поменял!», а приложение отвечает: «Да, я видел. И всё равно сделал по‑своему». Boot не вредничает — он просто следует правилу precedence.
6. Читаемая конфигурация при множестве источников
Когда вы впервые узнаёте, что конфигурация может приходить отовсюду, есть соблазн начать пользоваться этим в стиле «ну раз можно, то давайте положим одно и то же в пять мест — на всякий случай». Это быстро приводит к состоянию, когда конфиг вроде бы есть, но управлять им невозможно: любое изменение превращается в угадайку.
Здоровая стратегия начинается с простого разделения ролей. Packaged config внутри проекта должен быть местом, где лежат разумные дефолты и то, что делает приложение самодостаточным. Внешняя конфигурация нужна, когда вы хотите менять поведение без пересборки и без правки исходников. Параметры запуска полезны для диагностики и разовых переключений, когда вы не хотите редактировать файлы вообще.
Если держать это разделение в голове, вы интуитивно перестаёте дублировать ключи «просто потому что можно». Вы начинаете задавать вопрос: «это значение — дефолт или override?». И конфигурация перестаёт быть мусорной кучей.
Ещё одна маленькая, но очень практичная мысль: когда вы отлаживаете странное поведение, не пытайтесь сразу найти «тот самый файл». Лучше найти ключ, а потом проверить, какое значение он реально принимает в Environment. Это даже психологически проще: спорить с реальностью тяжело, а Environment — это реальность в формате String.
Отсюда вырастает и следующий практический вопрос: каким каналом запуска вы вообще перебиваете этот базовый дефолт — внешним env var, -D или --key=value. Как только это начинаешь проверять не по файлам, а по итоговому значению в Environment, половина магии сразу исчезает.
7. Типичные ошибки: mental model property sources
В этой теме ошибки редко выглядят как «не компилируется». Чаще это ошибки мышления: приложение работает, но «не слушается», и вы начинаете думать, что оно игнорирует ваши настройки. На практике оно просто честно следует precedence и собирает значения из нескольких источников. Давайте зафиксируем самые частые грабли, чтобы вы наступали на них реже и более осознанно.
Ошибка №1: считать application.yaml единственным источником правды.
Новички часто мыслят так: «если я вижу значение в YAML, значит приложение обязано его использовать». Но Boot видит не один YAML, а набор источников. Поэтому правильная привычка — разделять «где значение объявлено» и «какое значение стало итоговым». Итог всегда живёт в Environment, а не в открытом в IDE файле.
Ошибка №2: путать OS environment variables и Spring Environment.
Переменные окружения операционной системы — это один из источников. Spring Environment — это объект, который содержит итог, уже собранный из разных источников. Если смешать эти понятия, вы начинаете ожидать от Environment поведения терминала, а от терминала — поведения Spring. Кончается это обычно тем, что вы начинаете «переименовывать переменные, пока не заработает», вместо того чтобы проверять итоговое значение.
Ошибка №3: диагностировать поведение по одному файлу, игнорируя слои.
Самый распространённый сценарий конфликта: ключ есть в packaged конфиге, но сверху его перекрыли. Вы смотрите в packaged файл и не понимаете, почему не меняется поведение. Лечится это просто: при проблеме с конфигурацией первым делом фиксируется точный ключ и проверяется его итоговое значение в runtime, а не проводится чемпионат по чтению YAML глазами.
Ошибка №4: дублировать ключи в нескольких местах без роли и договорённостей.
Иногда люди «на всякий случай» копируют один и тот же ключ в несколько файлов: в application.yaml, в какой-то внешний конфиг, в IDE Run Configuration. Сначала всё «как-то работает», а потом любое изменение начинает вести себя непредсказуемо. В layered конфигурации ключ может встречаться несколько раз, но у каждого появления должна быть понятная роль: дефолт, внешний override или разовый запуск.
Ошибка №5: думать, что раз значение видно после старта, значит оно всегда влияло на поведение старта.
Это тонкий момент. Пока достаточно запомнить принцип: некоторые настройки важны очень рано, и у них есть фактор времени. Поэтому фраза «я вывел значение через Environment после старта» не всегда равна «оно точно участвовало в старте». Это не баг, это устройство процесса запуска.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ