1. Чтение Resource как часть надёжности приложения
Ресурсы уже разложены по папкам и вошли в wiring как нормальные зависимости. Теперь осталась самая приземлённая часть: читать их так, чтобы приложение либо получает нормальный текст, либо падает сразу и понятно.
Для обязательных шаблонов ContextFlow хороший сценарий один: ресурс найден, читается и даёт валидный текст. Вариант “не получилось — отправим пустую строку” здесь хуже любой громкой ошибки, потому что поломка уезжает глубоко в бизнес-сценарий.
Чтобы почувствовать контраст, давайте вспомним самый хрупкий вариант, который часто “случайно работает” в IDE:
import java.nio.file.Files;
import java.nio.file.Path;
// ВНИМАНИЕ: относительный путь завязан на текущую директорию запуска (working dir).
// "Работает" в IDE — и ломается после сборки в jar или при запуске из другого места.
String text = Files.readString(Path.of(
"src/main/resources/templates/notifications/order-created.txt"
)); // "работает", пока запуск из корня проекта
Проблема не в Files.readString() как таковом. Проблема в том, что такой код привязан к структуре исходников и working directory. Поэтому дальше мы строим другой контракт: обязательный шаблон либо успешно загружается на старте контейнера, либо приложение честно останавливается с понятной причиной.
2. Fail-fast при загрузке шаблонов
Раз ресурсы уже стали зависимостями, логично решить и момент их чтения на уровне контейнера. В инфраструктурных штуках почти всегда выгоднее подход “fail-fast”: если критичный ресурс отсутствует — мы не притворяемся, что всё нормально, а честно останавливаем сборку контекста. Это звучит строго, но на практике экономит часы времени. И да, это очень по-спринговски: контейнер умеет валиться на старте именно потому, что это полезнее, чем вылавливать проблемы позже в рандомном месте.
В нашем проекте это естественно связывается с темой lifecycle, которую мы уже проходили. @PostConstruct — идеальная точка, чтобы проверить существование шаблонов и загрузить их в память. Тогда ошибка проявится в момент старта ApplicationContext, а не когда пользователь уже “создал заказ” и ждёт уведомление.
Небольшая схема того, к чему мы стремимся:
flowchart TD
A[Spring создаёт bean NotificationTemplateCatalog] --> B["@PostConstruct loadTemplates()"]
B --> C{"Resource exists()?"}
C -- нет --> D["IllegalStateException с getDescription()"]
C -- да --> E["getInputStream + readAllBytes"]
E --> F[Шаблон сохранён в поле/кэш]
F --> G[Сервис использует готовую строку без I/O]
И вот минимальный “скелет” идеи в коде (пока без деталей чтения):
import jakarta.annotation.PostConstruct;
@PostConstruct
void loadTemplates() {
// Вызывается один раз при старте контекста — удобное место для fail-fast проверки.
// 1) проверить, что ресурсы существуют
// 2) прочитать в строки
// 3) сохранить в поля (кэш)
}
Важный методический момент: мы не делаем чтение шаблона при каждом отправлении уведомления. Шаблон — это часть конфигурации приложения, а не “данные пользователя”, поэтому его логично загрузить один раз и использовать много раз. Это и быстрее, и предсказуемее: вы не зависите от I/O в момент выполнения бизнес-сценария.
3. Методы Resource для диагностики
Когда вы только начинаете, легко утонуть в API: у Resource много методов, а у каждой реализации ещё больше нюансов. Нам сейчас нужен небольшой “диагностический набор”, который помогает принимать решения и писать полезные сообщения об ошибках. В учебном проекте лучше освоить 4–5 методов, но использовать их стабильно, чем знать 20 методов и каждый раз выбирать наугад.
Ниже — компактная таблица того, что чаще всего пригодится именно для нашей задачи (шаблоны и заготовки небольших текстов):
| Метод Resource | Зачем нужен | Как используем в ContextFlow |
|---|---|---|
| exists() | Проверить, найден ли ресурс | Перед чтением обязательного шаблона |
| isReadable() | Бывает, что ресурс есть, но читать нельзя | Дополнительная защита, особенно для file: |
| getInputStream() | Универсальный способ читать содержимое | Teaching default для чтения текста |
| getDescription() | Человекочитаемое описание ресурса | Всегда добавляем в текст ошибки |
| getFilename() | Имя файла (если оно есть) | Иногда полезно для логов/сообщений, но не опора логики |
Давайте сразу “почувствуем” пользу getDescription(). Это тот случай, когда Spring прямо помогает писать нормальные ошибки:
import org.springframework.core.io.Resource;
String describe(Resource r) {
// Хорошо подходит для исключений и логов: Spring сам формирует понятное описание.
return r.getDescription(); // например: "class path resource [templates/.../order-created.txt]"
}
Если вы потом увидите исключение с этой строкой, вы почти мгновенно понимаете, что именно искали и где. Это гораздо лучше, чем “File not found” без контекста, где вы потом 20 минут вспоминаете: “а какой именно файл-то?”.
4. getInputStream вместо getFile
На этом месте многие новички (и, честно, некоторые взрослые разработчики после тяжёлой недели) делают одну и ту же ошибку: пытаются превратить Resource в File и дальше жить как раньше. Мысленно это выглядит так: “ну раз это ресурс, значит у него должен быть файл”. Но classpath-ресурс — это не обязательно файл, особенно когда приложение упаковано в jar.
Вот пример кода, который выглядит правдоподобно, но может неожиданно сломаться:
import java.nio.file.Files;
import org.springframework.core.io.Resource;
String readViaFile(Resource resource) throws Exception {
// Опасно: для classpath-ресурса внутри jar физического файла может не быть.
return Files.readString(resource.getFile().toPath()); // может упасть для classpath внутри jar
}
Почему? Потому что у ресурса “внутри jar” физического файла в файловой системе может не существовать. Есть “кусочек архива”, а не путь на диске. Поэтому для универсального чтения Spring нам даёт гораздо более честный API: getInputStream().
Вот короткий и правильный вариант чтения текста (для маленьких шаблонов это отличный teaching default):
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.Resource;
String readUtf8(Resource resource) throws Exception {
// Универсально: работает и для classpath, и для file:, и для URL-ресурсов.
try (var in = resource.getInputStream()) { // try-with-resources гарантирует закрытие потока
// Для небольших шаблонов readAllBytes() читается просто и прозрачно.
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
Здесь сразу два важных бонуса. Во‑первых, мы не привязаны к файловой системе: поток можно открыть хоть из classpath, хоть из URL. Во‑вторых, try-with-resources гарантирует закрытие потока. Поток — как кран с водой: если вы его открыли и не закрыли, “утечки” могут быть не видны сразу, но со временем начнут кусаться (особенно когда таких чтений много).
5. Helper ResourceTextReader
Если в двух-трёх классах вы начнёте вручную писать exists(), try-with-resources, new String(...), catch(IOException e) и сообщения об ошибках — очень быстро появится копипаст. А копипаст в инфраструктуре неприятен тем, что он размножает “разные стандарты поведения”: один класс падает, другой молчит, третий возвращает пустую строку, четвёртый логирует в System.out.println (да, это тоже грех, просто помягче).
Поэтому разумный шаг — вынести чтение в маленький helper, который станет единым способом работать с текстовыми ресурсами в ContextFlow. Он не должен быть “универсальным движком ресурсов”, мы не строим космический корабль. Нам нужен аккуратный, понятный и повторно используемый кирпичик.
Начнём с fail-fast проверки:
import org.springframework.core.io.Resource;
public void requireExists(Resource resource) {
// Fail-fast: если обязательного ресурса нет — лучше упасть на старте с понятной причиной.
if (!resource.exists()) {
throw new IllegalStateException(
"Resource not found: " + resource.getDescription()
);
}
}
Теперь добавим чтение и нормальную обёртку IOException в runtime-исключение, чтобы не тащить checked-исключения по всем сервисам:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.Resource;
public String readRequiredUtf8(Resource resource) {
requireExists(resource);
try (var in = resource.getInputStream()) {
// Явно фиксируем кодировку, чтобы поведение не зависело от платформы.
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
// Важное место: не глотаем ошибку, а добавляем контекст и поднимаем как runtime-исключение.
throw new IllegalStateException(
"Cannot read resource: " + resource.getDescription(), e
);
}
}
Обратите внимание: мы не “глотаем” ошибку. Мы её поднимаем, добавляя контекст. Это и есть нормальная диагностика: ошибка не скрывается, но становится понятнее.
В реальном проекте такой helper удобно сделать Spring-bean’ом, чтобы его можно было внедрять в каталоги шаблонов и другие infrastructure-компоненты:
import org.springframework.stereotype.Component;
@Component
public class ResourceTextReader {
// методы requireExists(...) и readRequiredUtf8(...)
}
6. Шаблоны уведомлений в ContextFlow
Теперь соберём рабочий вариант ContextFlow, где каталог шаблонов делает сразу три вещи: держит ссылки на ресурсы, читает их fail-fast на старте и отдаёт бизнес-сервисам готовые методы render...().
Пусть в src/main/resources лежат такие файлы:
src/main/resources/templates/notifications
├─ order-created.txt
└─ order-cancelled.txt
Чтобы не вводить отдельный template-language, держим шаблоны максимально простыми: обычный текст плюс %s, который потом подставляем через String.formatted(...).
# order-created.txt
ORDER CREATED: id=%s, customer=%s
# order-cancelled.txt
ORDER CANCELLED: id=%s, reason=%s
Теперь сделаем каталог, который внедряет два Resource и читает их в @PostConstruct. Чтобы не усложнять wiring, можно внедрить ресурсы через @Value прямо с classpath:. Это не “плохой хардкод”, если вы воспринимаете их как часть поставки приложения.
Фрагмент конструктора:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
@Component
public class NotificationTemplateCatalog {
public NotificationTemplateCatalog(
// Явно говорим Spring, что это classpath-ресурс (а не "файл на диске").
@Value("classpath:templates/notifications/order-created.txt") Resource created,
@Value("classpath:templates/notifications/order-cancelled.txt") Resource cancelled) {
// ...
}
}
Теперь — ключевой кусок: @PostConstruct и чтение через наш helper. Разобьём на маленькие куски, чтобы код не выглядел как простыня.
Поля:
import org.springframework.core.io.Resource;
private final Resource createdTemplate;
private final Resource cancelledTemplate;
private final ResourceTextReader reader;
private String createdText;
private String cancelledText;
@PostConstruct загрузка:
import jakarta.annotation.PostConstruct;
@PostConstruct
void load() {
createdText = reader.readRequiredUtf8(createdTemplate);
cancelledText = reader.readRequiredUtf8(cancelledTemplate);
}
И методы, которые возвращают уже подготовленный текст. Мы можем сделать самый простой “template engine” на уровне String.formatted() — без библиотек и без магии:
public String renderCreated(String orderId, String customerName) {
return createdText.formatted(orderId, customerName);
}
public String renderCancelled(String orderId, String reason) {
return cancelledText.formatted(orderId, reason);
}
Именно такая форма каталога здесь полезна: класс честно зависит от ресурсов, читает их один раз и дальше отдаёт сервисам простой API “сформируй текст уведомления”. Бизнес-сервисы больше не знают, где лежат файлы и как они читаются. Это отличный пример здоровой изоляции.
Диагностика ошибок и “роль” ресурса
Сообщение об ошибке — это не место для творчества, но это место для уважения к будущему себе. “Future you” обычно устал, у него горит дедлайн, и он не хочет разгадывать квест “что именно сломалось”. Поэтому мы стремимся к ошибкам, которые отвечают на три вопроса: что искали, где искали, почему упали.
Сравним два сообщения:
Плохое (формально “правда”, но бесполезно):
IllegalStateException: Cannot read template
Хорошее (в нём уже есть конкретика):
IllegalStateException: Cannot read resource: class path resource [templates/notifications/order-created.txt]
Чтобы улучшить диагностику ещё на шаг, иногда полезно добавить “роль” ресурса. Не просто “не прочитали ресурс”, а “не прочитали шаблон создания заказа”. Тогда у вас в голове сразу связывается проблема с бизнес-сценарием.
Вот минимальный вариант метода, который принимает роль и добавляет её в исключение:
import org.springframework.core.io.Resource;
public String readRequiredUtf8(Resource resource, String role) {
if (!resource.exists()) {
throw new IllegalStateException(
"Missing resource (" + role + "): " + resource.getDescription()
);
}
return readRequiredUtf8(resource); // используем базовый метод
}
А вызов станет самодокументируемым:
createdText = reader.readRequiredUtf8(createdTemplate, "order-created template");
Этот приём очень “учебно полезный”: он показывает, что диагностика — это часть дизайна. Мы не просто читаем файл, мы строим поддержку приложения. И да, поддержка начинается с нормальных исключений, а не с героического дебага в пятницу вечером.
NotificationDispatchService использует каталог
Чтобы закрепить, что мы реально улучшили архитектуру, давайте посмотрим на то, как это выглядит в сервисе отправки уведомлений. Раньше там легко мог появиться соблазн “а давайте прямо тут прочитаем файл”. Теперь он просто просит каталог сгенерировать текст.
Мини-версия сервиса:
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationTemplateCatalog templates;
private final NotificationSender sender;
public NotificationDispatchService(NotificationTemplateCatalog templates,
NotificationSender sender) {
this.templates = templates;
this.sender = sender;
}
}
И метод, который отправляет уведомление о создании заказа:
public void notifyOrderCreated(String orderId, String customerName) {
String text = templates.renderCreated(orderId, customerName);
sender.send(text); // детали отправки — в реализации NotificationSender
}
Если вы где-то в сценарии (например, в ScenarioRunner) вызовете это, вы получите понятный результат, а чтение ресурсов произойдёт ровно один раз на старте. В хорошем варианте сценарный код вообще не должен иметь I/O, связанного с шаблонами: он занимается бизнес-действиями, а инфраструктура подготовлена заранее.
Если хочется увидеть глазами, можно временно использовать ConsoleNotificationSender и увидеть что-то вроде:
System.out.println(text); // ORDER CREATED: id=ORD-1, customer=Alice
И приятный факт: если вы случайно переименовали order-created.txt или положили его не в ту папку, вы узнаете об этом при старте контекста, а не “внутри” notifyOrderCreated().
Здесь мы работаем с текстом как с конкретным физическим ресурсом: нашли файл, прочитали его, подставили значения. Когда текстов становится много и их уже нужно искать не по пути, а по ключу, locale и fallback-правилам, поверх этой же ресурсной основы появляется MessageSource.
7. Типичные ошибки при чтении Resource
Ошибка №1: сразу вызывать getFile у classpath-ресурса.
Эта ошибка появляется из старой привычки: “если что-то похоже на файл, значит это файл”. Но classpath-ресурс может жить внутри jar, и физического файла может не существовать. Поэтому getFile иногда работает в IDE и внезапно падает после сборки. Teaching default для чтения — getInputStream().
Ошибка №2: забывать закрывать поток.
resource.getInputStream() отдаёт InputStream, а поток — это ресурс в буквальном смысле. Его надо закрывать. Самый простой и правильный способ — try-with-resources. Если читать ресурсы “вручную” и не закрывать, можно получить странные проблемы в долгоживущем приложении и очень неприятные симптомы, которые тяжело связываются с первопричиной.
Ошибка №3: глотать IOException и продолжать с пустым шаблоном.
Иногда хочется сделать “как в UI”: если что-то не загрузилось, покажем пустоту. Но шаблон уведомления или заголовок отчёта — это часть конфигурации приложения, а не необязательный элемент. Если его нет, лучше упасть сразу и объяснить причину. Иначе вы получите “тихую поломку”: сценарии продолжают выполняться, но уведомления становятся бессмысленными, а отладка превращается в охоту на привидений.
Ошибка №4: писать сообщение об ошибке без getDescription().
Сообщение “Resource not found” без указания, какой именно ресурс искали, почти не помогает. getDescription() в Spring обычно уже содержит информацию о типе ресурса и его “адресе” (classpath/file/url). Это бесплатная диагностика — грех не пользоваться.
Ошибка №5: разносить чтение ресурсов по бизнес-сервисам.
Если OrderPlacementService или NotificationDispatchService начинают собирать строки путей и заниматься I/O, у вас возникает “скрытая инфраструктура” внутри бизнес-кода. Такой код трудно тестировать, трудно менять и трудно читать. Гораздо лучше держать ресурсную логику в одном infrastructure/helper bean (например, NotificationTemplateCatalog + ResourceTextReader) и отдавать бизнес-слою готовый API “дай текст”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ