JavaRush /Курсы /Spring Core /SpEL без магии в

SpEL без магии в ContextFlow

Spring Core
12 уровень , 4 лекция
Открыта

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-коде, где его можно тестировать, читать и менять без строковых фокусов.

1
Задача
Spring Core, 12 уровень, 4 лекция
Недоступна
Небольшие вычисления и systemProperties через SpEL
Небольшие вычисления и systemProperties через SpEL
1
Задача
Spring Core, 12 уровень, 4 лекция
Недоступна
Производный путь через SpEL и placeholder
Производный путь через SpEL и placeholder
1
Опрос
Конфигурация Spring, 12 уровень, 4 лекция
Недоступен
Конфигурация Spring
Работа со свойствами
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ