1. Роль SpEL рядом с ${...}
Если вы только-только привыкли к ${contextflow.app-name} и уже мысленно празднуете победу над хардкодом — поздравляю, вы нормальный человек. Но жизнь, как обычно, чуть богаче: иногда нам мало просто “взять строку по ключу”. Хочется собрать производное значение, слегка посчитать число, склеить путь, добавить суффикс или безопасно получить системный параметр. Вот тут и появляется SpEL.
Важно сразу зафиксировать правильный настрой: SpEL — не замена Java-коду и не «обязательная часть Spring-дзена». Это скорее как складной нож: удобно открыть коробку, но странно пытаться им построить дом. В нашей конфигурации ContextFlow SpEL должен появляться только там, где выражение короткое и очевидное, а смысл можно понять с первого взгляда без археологических раскопок.
2. Две скобки — два мира: ${...} и #{...}
Очень частая новичковая путаница выглядит так: “Ну раз и там скобки, то это одно и то же”. Нет. ${...} и #{...} — это два разных механизма, у которых разная роль и даже разный “тип результата”.
${...} — это placeholder. Он говорит Spring: «найди строковое значение по ключу в Environment и подставь его». Если ключа нет — можно указать дефолт через двоеточие, например ${contextflow.app-name:ContextFlow}. В этом режиме вы по сути работаете с конфигурацией как со словарём "ключ" -> "строка".
#{...} — это выражение (SpEL). Оно говорит Spring: «вычисли это выражение, используя доступный контекст (системные свойства, окружение, иногда бины)». Результат может быть строкой, числом, boolean — чем угодно в рамках разумного. То есть это не “подставь”, а именно “посчитай”.
Чтобы не путаться, удобно держать в голове такую мини-табличку:
| Механизм | Синтаксис | Что делает | Типичный результат |
|---|---|---|---|
| placeholder | ${key} | Берёт значение из Environment | обычно String (потом Spring может конвертировать в int, boolean и т.д.) |
| expression | #{...} | Вычисляет выражение (SpEL) | Object (число/строка/boolean) |
А вот ещё один практический маркер: ${...} почти всегда читается как конфигурация (“что написано в настройках”), а #{...} — как логика вычисления (“как из этого сделать итоговое значение”).
Чтобы увидеть процесс чуть более цельно, представьте такой упрощённый конвейер:
flowchart TD
A["Property sources (system props, env vars, contextflow.properties)"]
B["Environment ищет значение по ключу"]
C["Placeholder ${...} подставляет строку"]
D["SpEL #{...} вычисляет выражение"]
E["Injection значение попадает в конструктор/bean"]
A --> B --> C --> D --> E
Да, в реальности там больше шагов и инфраструктурных бинов, но для ментальной модели этого достаточно: сначала “находим значения”, потом “подставляем”, иногда “вычисляем”, а потом “внедряем”.
3. Источники SpEL: systemProperties и systemEnvironment
Обычно страх перед SpEL начинается с вопроса: “А откуда он вообще берёт переменные?” Хорошая новость: в рамках наших сегодняшних задач нам не нужно знать весь богатый словарь SpEL. Нам достаточно понимать два встроенных “словаря”, которые доступны почти всегда: systemProperties и systemEnvironment.
systemProperties — это Java System Properties, то есть то, что вы можете получить через System.getProperties(). Там живут, например, user.dir, user.name, java.version. Это кроссплатформенная штука и часто удобнее, чем environment variables.
systemEnvironment — это переменные окружения ОС, то есть System.getenv(). Тут уже начинается зоопарк: имена переменных отличаются между Windows/Linux/macOS, поэтому в учебных примерах лучше не завязываться на что-то вроде USER, иначе половина группы увидит null и решит, что Spring их не любит, а Spring просто не виноват.
Давайте в ContextFlow сделаем маленькую диагностическую вещь: выведем имя приложения из properties и пользователя, под которым запущен процесс, из system properties. Это хороший пример, потому что он демонстрирует оба мира: ${...} и #{...}.
package com.example.contextflow.application.scenario;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ScenarioRunner {
private final String appName;
private final String osUserName;
public ScenarioRunner(
// `${...}` — берём конфигурацию из Environment (с дефолтом)
@Value("${contextflow.app-name:ContextFlow}") String appName,
// `#{...}` — вычисляем SpEL-выражение из доступного контекста
@Value("#{systemProperties['user.name']}") String osUserName
) {
// Держим зависимости видимыми: всё приходит через конструктор
this.appName = appName;
this.osUserName = osUserName;
}
public void run() {
// Здесь просто демонстрация результата; вычисление уже произошло при создании бина
System.out.println("App = " + appName + ", user = " + osUserName);
// App = ContextFlow, user = alice
}
}
Обратите внимание на дисциплину: значение appName мы читаем как конфигурацию, а user.name — как часть окружения процесса. И ещё важнее: мы внедряем всё через конструктор, а не через поля, чтобы “контракт” бина оставался видимым.
4. Мини-вычисления в @Value: арифметика
Самый безопасный способ познакомиться с SpEL — начать с выражений, которые выглядят как нормальный калькулятор. Когда выражение простое, оно читается даже лучше, чем отдельный @Bean-метод, потому что не размазывает идею по нескольким строкам.
Представим, что в NotificationDispatchService мы хотим иметь техническое окно повторных попыток в секундах. Мы не строим здесь полноценную retry-механику, но как параметр конфигурации для демонстрации — идеально.
package com.example.contextflow.application.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final int retryWindowSeconds;
public NotificationDispatchService(
// Простая арифметика в SpEL: читается как «2 минуты»
@Value("#{2 * 60}") int retryWindowSeconds
) {
// Важно: это вычислится один раз при создании singleton-бина
this.retryWindowSeconds = retryWindowSeconds;
}
public void dispatch(String message) {
System.out.println("dispatch: " + message + " (window=" + retryWindowSeconds + "s)");
// dispatch: Hello! (window=120s)
}
}
Почему это нормальный пример? Потому что выражение 2 * 60 читается мгновенно, а вычисление происходит один раз при создании singleton-бина. Никаких “петель”, никаких условий, никакой попытки устроить в аннотации второй язык программирования.
Если вы сейчас подумали: “Но ведь можно просто написать 120”, — да, можно. И это нормально. Здесь цель не “обязательно использовать SpEL”, а увидеть, что SpEL уместен в очень маленьких вычислениях, где он не усложняет поддержку.
5. SpEL + properties: производные пути отчёта
Самый “вкусный” прикладной сценарий для сегодняшнего дня — собрать производное значение на основе property. В ContextFlow у нас есть директория вывода отчётов, и очень типично хотеть не просто директорию, а сразу конкретный файл.
Возьмём строки из того же src/main/resources/contextflow.properties, который уже подключён к AppConfig:
# Фрагмент того же contextflow.properties
contextflow.app-name=ContextFlow
contextflow.report.output-dir=build/reports
Теперь хотим получить путь до конкретного файла, например build/reports/daily-report.txt. Можно сделать это в Java-коде, но для демонстрации SpEL попробуем собрать строку прямо в @Value.
package com.example.contextflow.support.lifecycle;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputManager {
private final String dailyReportPath;
public ReportOutputManager(
// Важно: внутри SpEL мы намеренно пишем '${...}'
// Сначала `${...}` подставит строку из Environment, потом SpEL склеит итог
@Value("#{'${contextflow.report.output-dir:build/reports}' + '/daily-report.txt'}")
String dailyReportPath
) {
this.dailyReportPath = dailyReportPath;
}
public String dailyReportPath() {
return dailyReportPath;
}
}
Здесь есть важный нюанс, который стоит проговорить словами, потому что он выглядит как магия, если молчать. Внутри SpEL мы используем строку '${...}'. Это означает: сначала ${...} подставит значение из Environment (например, build/reports), а потом SpEL склеит строки. Мы не делаем здесь “двойное чтение конфигурации”, мы делаем “подставь базу — потом собери итог”.
Да, выглядит чуть странно из-за кавычек. Это нормальная реакция. SpEL вообще любит кавычки так же сильно, как программист любит кофе: без них он иногда не просыпается.
Теперь можем вывести это значение где-нибудь в сценарии, чтобы убедиться, что оно реально вычислилось:
package com.example.contextflow.application.scenario;
import com.example.contextflow.support.lifecycle.ReportOutputManager;
import org.springframework.stereotype.Component;
@Component
public class ReportingScenario {
private final ReportOutputManager outputManager;
public ReportingScenario(ReportOutputManager outputManager) {
// Внедряем зависимость через конструктор, без «магии» по полям
this.outputManager = outputManager;
}
public void printPlannedPath() {
// Проверяем, что значение действительно собрано на старте контейнера
System.out.println("Daily report will be written to: " + outputManager.dailyReportPath());
// Daily report will be written to: build/reports/daily-report.txt
}
}
Если вы видите, что строка собралась правильно — отлично. Если вдруг путь “сломался”, чаще всего проблема в кавычках или в том, что вы забыли подключить placeholder configurer, но это мы уже сделали в предыдущих лекциях дня.
6. Когда SpEL лучше заменить на Java-код
SpEL становится опасным не потому, что он “плохой”. Он становится опасным, когда вы начинаете использовать его как компромисс между ленью и архитектурой. То есть вместо того, чтобы сделать небольшой @Bean-метод и честно собрать значение в Java, вы пытаетесь написать “умную строку”, которая решит всё: и дефолты, и условия, и варианты окружения, и форматирование.
Как понять, что пора остановиться? Обычно это момент, когда вы открываете @Value, видите выражение, и у вас возникает желание… выдохнуть. Если нужно читать выражение по частям и шептать себе “так… тут кавычки… тут тернарник… тут ещё один тернарник…” — это уже сигнал.
Вот пример, который технически может работать, но методически лучше рассматривать как антипример:
// Антипример: выражение превращается в головоломку, которую трудно сопровождать
@Value("""
#{'${contextflow.report.output-dir:}' == ''
? systemProperties['user.dir'] + '/build/reports'
: '${contextflow.report.output-dir}'}
""")
String outputDir;
Если вы сейчас почувствовали лёгкую усталость — поздравляю, вы читаете код глазами, а не верой.
В таких случаях лучше сделать то, что Spring очень любит: вынести логику в конфигурационный код, где она остаётся Java-кодом, а не строковой головоломкой. Например, соберём ReportOutputManager через @Bean-метод и Environment.
package com.example.contextflow.config.reporting;
import com.example.contextflow.support.lifecycle.ReportOutputManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
public class ReportingConfig {
@Bean
public ReportOutputManager reportOutputManager(Environment environment) {
// Environment — нормальная точка, где можно читать properties и задавать дефолт
String dir = environment.getProperty(
"contextflow.report.output-dir",
"build/reports"
);
// Конструируем итоговый путь в Java-коде: брейкпоинты, отладка, всё как у людей
return new ReportOutputManager(dir + "/daily-report.txt");
}
}
И давайте сделаем конструктор ReportOutputManager соответствующим:
package com.example.contextflow.support.lifecycle;
public class ReportOutputManager {
private final String dailyReportPath;
public ReportOutputManager(String dailyReportPath) {
// Здесь «путь файла» уже готовый, без дополнительных вычислений внутри класса
this.dailyReportPath = dailyReportPath;
}
public String dailyReportPath() {
return dailyReportPath;
}
}
В этом варианте логика не “круче”, но она проще для жизни. Вы можете поставить breakpoint, вы можете отладить, вы можете расширить. И главное — конфигурация выглядит как конфигурация, а не как стихотворение в кавычках.
7. Типичные ошибки при использовании SpEL
SpEL очень быстро даёт ощущение суперсилы: “О, я могу вычислить что угодно прямо в аннотации!” И примерно в этот момент он начинает подкидывать грабли. Ниже — самые частые ошибки, которые я видел у новичков, и пару раз у себя, но тсс, никому.
Ошибка №1: использовать SpEL там, где достаточно ${...}.
Если значение просто берётся по ключу и больше ничего не делает, #{...} только усложняет чтение. @Value("${contextflow.app-name}") читается как “имя приложения”. @Value(#{ '${contextflow.app-name}' }) читается как “почему вы так со мной”.
Ошибка №2: превращать выражение в мини-программу.
Как только в выражении появляется несколько условий, тернарники, длинные конкатенации и вызовы статических методов, вы начинаете строить поддержку на зыбком фундаменте. Конфигурация должна быть скучной. Если она “умная и весёлая”, то обычно весело только до первого бага.
Ошибка №3: завязываться на нестабильные переменные окружения ОС.
systemEnvironment['USER'] может быть, а может не быть. На Windows часто USERNAME, на Linux — USER. Если уж очень хочется “внешнее имя пользователя”, чаще безопаснее взять systemProperties['user.name'], потому что это Java-уровень и он более предсказуемый.
Ошибка №4: копировать одно и то же выражение в нескольких местах.
Дублирование в SpEL неприятнее обычного дублирования: оно ещё и сложно читается. Если вы видите, что одно выражение нужно в двух местах — лучше вынести вычисление в @Bean и дать ему нормальное имя. Иначе у вас будет два одинаковых “заклинания” в разных классах, и одно из них однажды станет “почти таким же, но чуть-чуть другим” — и вы потратите вечер на поиски отличий.
Ошибка №5: пытаться спрятать бизнес-правило в SpEL.
SpEL — технический инструмент. Если вы внезапно начинаете вычислять в нём “скидку для лояльного клиента” или “правило выбора канала уведомлений” — вы уже смешали конфигурацию и бизнес-логику. Бизнес должен жить в Java-коде, где его можно тестировать, читать и менять без строковых фокусов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ