1. Экспорт как файловый сценарий
Bind mount и named volume уже дали нам внешний способ хранить файл. Теперь приложению нужно сделать следующий шаг: перестать гадать, куда вообще писать. Экспорт в файл кажется простым до смешного: «ну записали строку в catalog.csv — и всё». Но именно в этой простоте прячется ловушка: если вы не договорились о директории экспорта и не сделали её частью runtime-контракта, ваш сервис начнёт создавать файлы «где-то внутри контейнера», а потом вы будете искать их как носки после стирки — вроде были, но куда-то ушли.
В Container-Ready Catalog Service экспорт — это не просто техническая операция, а доменное действие. Пользователь вызывает endpoint экспорта, например POST /api/catalog/exports, сервис собирает данные каталога, формирует CSV и сохраняет его в файловую систему. После этого у нас появляется запись ExportJob, чтобы можно было сказать: «мы экспортировали вот столько элементов, в такой-то файл, в такое-то время». Даже если сейчас у вас часть этого хранится in-memory в standalone, смысл действия тот же: экспорт должен заканчиваться конкретным файлом и понятным результатом.
Важно зафиксировать мысль, которую часто пропускают новички: код экспорта не должен “угадывать”, где ему писать. Он должен получать «разрешённую» и «согласованную» директорию извне, так же как он получает порт или профиль. Если вы хардкодите путь в сервисе, вы фактически запекаете файловую модель внутрь приложения. А мы как раз тренируем противоположное мышление: image неизменяемый, поведение задаётся окружением.
Чтобы не превратить код в кашу, полезно заранее отделить три вещи: директорию, имя файла и содержимое CSV. Сначала нужно зафиксировать саму директорию экспорта и её конфигурирование через APP_EXPORT_DIR.
2. APP_EXPORT_DIR как runtime-конфиг
Когда говорят «вынести в конфиг», у многих в голове появляется картинка с километровым application.yml, где можно утонуть как в болоте. Но здесь всё проще: мы хотим одну конкретную настройку — куда сервис складывает экспортированные файлы. И эта настройка должна быть переопределяемой снаружи, особенно в контейнере.
Если смотреть на это с позиции чистой Java, самый прямолинейный вариант — прочитать переменную окружения вручную:
import java.nio.file.Path;
public class ExportDirDemo {
public static void main(String[] args) {
// Берём значение из env (если не задано — используем разумный default)
String dir = System.getenv().getOrDefault("APP_EXPORT_DIR", "data/exports");
// Превращаем строку в тип Path — дальше в коде меньше шансов ошибиться
Path exportDir = Path.of(dir);
// Для диагностики удобно сразу увидеть абсолютный путь, куда реально пишем
System.out.println(exportDir.toAbsolutePath()); // /.../data/exports
}
}
Это рабочий подход, но в Spring Boot мы почти всегда хотим идти более «бутовским» путём: через property app.export.dir, которую можно задавать в application.yml, а затем переопределять env var-ом APP_EXPORT_DIR благодаря relaxed binding.
В итоге идея такая: внутри приложения живёт свойство app.export.dir, а снаружи Docker или оператор может менять его, просто передав APP_EXPORT_DIR.
Небольшая схема полезна, чтобы один раз увидеть глазами, как это работает:
flowchart TD
Env["env: APP_EXPORT_DIR"] --> Boot["Spring Boot config (property sources)"]
Boot --> Props["app.export.dir"]
Props --> Service["CatalogExportService"]
Service --> FS["writes file to target dir"]
FS --> Mount["(optional) mount"]
Mount --> Host["host-visible folder (bind mount)"]
Обратите внимание на дисциплину: сервис не знает ничего про bind mount, named volume, пути на host-машине и про то, где Docker хранит volumes. Сервис знает только одно: «мне дали директорию, значит туда писать можно». Это и есть здоровый контракт.
Чтобы не путаться, удобно держать в голове короткое сравнение:
| Решение | Как выглядит | Почему это плохо или хорошо |
|---|---|---|
| Хардкод в коде | Path.of("/tmp/exports") | Быстро, но не переиспользуемо: в контейнере и на разных машинах станет больно |
| Значение в application.yml | |
Нормальный default для локальной разработки |
| Переопределение через env var | |
Канонический контейнерный подход: same image, different runtime config |
Теперь закрепим это в проекте.
3. Один export directory: app.export.dir
Если вы никогда не делали файловые сценарии в Spring Boot, легко скатиться к ситуации, когда в одном месте написано "/app/exports", в другом — "./exports", а потом вы удивляетесь, что файлы то появляются, то нет. Мы так не делаем. Нам нужна одна точка правды: app.export.dir.
Начнём с самого понятного места — application.yml. В учебном проекте удобно иметь default, который работает локально в репозитории. У нас уже есть папка data/exports/, значит default может быть таким:
app:
export:
# Default для локального запуска: экспорт падает прямо в папку проекта
dir: data/exports
Смысл этого default’а простой: если вы запускаете приложение локально без Docker, экспорт попадёт в папку проекта, которую легко открыть глазами, добавить в .gitignore при необходимости и проверить.
Когда же мы запускаем контейнер, мы можем переопределить это значение снаружи, например на /app/exports — типичный путь внутри image. И тут ключевой момент: код приложения не меняется. Меняется только runtime-параметр.
Чтобы использовать это значение в коде аккуратно и не размазывать @Value по всему проекту, заведём маленький конфигурационный объект. В Spring Boot для этого есть привычный инструмент: @ConfigurationProperties.
package com.example.catalog.export.config;
import java.nio.file.Path;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.export")
public record ExportProperties(Path dir) {
// dir — это "разрешённая" директория экспорта, приходящая из конфигурации/окружения
// Важно: бизнес-код не должен хардкодить пути — только использовать это значение
}
Здесь record — это просто компактный контейнер для данных. Spring Boot умеет биндить строку из конфигурации в Path, и это удобно: вы меньше делаете руками, и тип в коде сразу правильный.
Осталось включить эти свойства. Самый простой и понятный для новичка способ — явно зарегистрировать их в @SpringBootApplication:
package com.example.catalog;
import com.example.catalog.export.config.ExportProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(ExportProperties.class) // Регистрируем бин с настройками экспорта
public class CatalogApplication {
public static void main(String[] args) {
// Стартуем Spring Boot как обычно — все property sources применятся автоматически
SpringApplication.run(CatalogApplication.class, args);
}
}
Теперь ExportProperties можно внедрять как обычную зависимость в сервисы. И это уже большое улучшение архитектуры: путь живёт в одном месте, читается одинаково, и его легко переопределить без рефакторинга половины кода.
4. CatalogExportService: запись файла
Сейчас хочется броситься в контроллеры и API, но лучше сначала сделать одну маленькую, честную штуку: сервис, который умеет принять имя файла и CSV-строку, создать директорию и записать файл. Это и есть ядро файлового сценария. Всё остальное — endpoint, ExportJob, список items — обвязка вокруг него.
Минимальная версия CatalogExportService может выглядеть так:
package com.example.catalog.export.service;
import com.example.catalog.export.config.ExportProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class CatalogExportService {
private final ExportProperties props;
public CatalogExportService(ExportProperties props) {
// Внедряем конфигурацию: именно оттуда берём директорию экспорта
this.props = props;
}
public Path writeCsv(String fileName, String csv) throws IOException {
// Гарантируем, что директория существует (иначе в контейнере легко словить падение)
Files.createDirectories(props.dir());
// Собираем полный путь: exportDir + fileName
Path file = props.dir().resolve(fileName);
// Записываем CSV в файл и возвращаем Path результата (удобно для логов/диагностики)
return Files.writeString(file, csv);
}
}
Обратите внимание на важную мелочь, которая на практике спасает нервы: Files.createDirectories(...). Это не красивость, это страховка. Когда вы начнёте запускать контейнер в разных окружениях и с разными mount’ами, директория может быть пустой или отсутствовать. Сервис обязан либо подготовить её, либо упасть с понятной ошибкой. Мы выбираем подготовку.
Ещё одна важная деталь: сервис возвращает Path. Это делает экспорт осязаемым: не просто void, не просто «успешно», а конкретный результат — путь к файлу. Да, наружу в API мы, скорее всего, не будем возвращать абсолютные пути в проде, но внутри приложения это полезно для логов и диагностики.
Пока что мы не обсуждаем, как именно строится имя файла и что в CSV внутри — это намеренно. Хороший сервис экспорта не должен одновременно заниматься всем. Он должен быть как принтер: ему дали бумагу и сказали, куда печатать, дальше он не философствует.
5. Экспорт: файл + ExportJob + API-ответ
Если оставить экспорт на уровне «вызвали метод и где-то появился файл», студентам обычно неясно, как это связано с реальным backend API. Поэтому мы сделаем маленькую связку: endpoint экспорта вызывает сервис, создаёт ExportJob или хотя бы DTO-ответ и возвращает клиенту понятный результат.
Начнём с простого DTO-ответа. Пусть он сообщает имя файла и количество экспортированных элементов:
package com.example.catalog.export.web;
// DTO ответа: клиенту достаточно знать, какой файл создали, и сколько элементов экспортировали
public record ExportResponse(String fileName, int itemsCount) {
}
Дальше нам нужен фасад или сервис-координатор, который соберёт CSV, построит имя файла и вызовет CatalogExportService. Важно: мы не превращаем это в архитектуру ради архитектуры. Мы просто разделяем обязанности: один класс пишет файл, другой решает, что именно писать и как назвать.
package com.example.catalog.export.service;
import java.time.LocalDate;
import org.springframework.stereotype.Service;
@Service
public class ExportFileNameFactory {
public String todayCatalogCsv() {
// Делаем имя файла стабильным и читаемым: "catalog-YYYY-MM-DD.csv"
// LocalDate удобен тем, что не содержит двоеточий и лишних символов
return "catalog-" + LocalDate.now() + ".csv";
}
}
LocalDate — специально: без двоеточий и без сложных форматов. Нам здесь нужен простой, читаемый, повторяемый шаблон имени файла, без лишней кроссплатформенной экзотики.
Теперь добавим совсем небольшой контроллер, который запускает экспорт. В реальном проекте у вас, скорее всего, уже есть экспортный контроллер и сервис, но как учебная модель это выглядит так:
package com.example.catalog.export.web;
import com.example.catalog.export.service.CatalogExportService;
import com.example.catalog.export.service.ExportFileNameFactory;
import java.io.IOException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ExportController {
private final CatalogExportService exportService;
private final ExportFileNameFactory fileNameFactory;
public ExportController(CatalogExportService exportService, ExportFileNameFactory fileNameFactory) {
// Здесь контроллер только координирует: имя -> CSV -> запись -> ответ
this.exportService = exportService;
this.fileNameFactory = fileNameFactory;
}
@PostMapping("/api/catalog/exports")
public ExportResponse exportCatalog() throws IOException {
// 1) Генерируем имя файла
String fileName = fileNameFactory.todayCatalogCsv();
// 2) Собираем CSV (пока демонстрационно, в проекте будет реальная сборка)
String csv = """
id,sku,title
1,SKU-1,Book
""";
// 3) Пишем файл в директорию, заданную через app.export.dir / APP_EXPORT_DIR
exportService.writeCsv(fileName, csv);
// 4) Возвращаем клиенту минимально полезный результат
return new ExportResponse(fileName, 1);
}
}
Да, CSV здесь пока игрушечный — и это нормально для демонстрации файлового контура. В вашем реальном проекте CSV будет строиться из CatalogItem и репозитория, но Docker-курс не должен внезапно превратиться в курс «как красиво сериализовать CSV». Наша цель другая: показать, что экспорт — это реальная операция, у которой есть файл, имя, директория и воспроизводимый контракт через конфиг.
На этом базовый файловый контур уже собран: конфигурация даёт директорию, сервис пишет файл, API возвращает понятный результат.
6. Проверка APP_EXPORT_DIR: меняем только конфиг
Проверка APP_EXPORT_DIR — это отличный момент, чтобы почувствовать главный принцип курса на практике: один image или один jar, разные режимы через runtime config. И здесь даже не обязательно сразу монтировать директорию наружу. Можно начать проще: запустить сервис и убедиться, что файл создаётся в указанной директории.
Если вы запускаете приложение локально без Docker, достаточно default из application.yml. Вы делаете запрос на экспорт, например через Postman, .http или curl — что вам привычнее, — и после этого ищете файл в data/exports/. Это самый дешёвый smoke-check: он доказывает, что у нас есть единый export directory и что сервис умеет создавать директории и писать файл.
Дальше наступает контейнерная часть. Предположим, image вашего сервиса уже собран и называется docker-java-catalog-service. Тогда запуск с переопределением export directory выглядит примерно так:
docker run --rm \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
docker-java-catalog-service
Смысл команды не в том, чтобы угадать идеальный путь, а в том, чтобы проверить: переменная окружения действительно переопределяет app.export.dir. После запроса на экспорт файл будет создан внутри контейнера по пути /app/exports.
А вот чтобы этот результат был видим с host-машины, вам понадобится bind mount. Для самой идеи нам достаточно одного target path внутри контейнера и одного host-path снаружи; синтаксис host-пути подставьте под свою оболочку. Предположим, что каталог data/exports рядом с проектом уже существует:
docker run --rm \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
--mount type=bind,source="$(pwd)/data/exports",target=/app/exports \
docker-java-catalog-service
После вызова POST /api/catalog/exports вы увидите созданный CSV уже в папке ./data/exports на вашей машине. И это очень важный момент методически: вы меняете только APP_EXPORT_DIR и mount, а код остаётся тем же. То есть мы не допиливаем Docker-режим в коде, мы просто корректно описываем контракт: экспортный каталог задаётся извне.
Если вы хотите дополнительный самоконтроль, особенно когда начинаете путаться, какие значения реально применились, добавьте логирование export directory на старте приложения. Для новичков это иногда спасение: вы видите значение в логах и меньше гадаете.
На этом базовый файловый контракт собран: директория приходит из конфигурации, сервис пишет туда файл, а контейнерный запуск меняет только runtime-настройки. Дальше этот же контур остаётся лишь усилить: аккуратно работать с путями и именами файлов и проверить его на пересоздании контейнера.
7. Типичные ошибки при работе с APP_EXPORT_DIR
Ошибка №1: путь экспорта — это знание бизнес-кода.
Часто экспорт начинают с Path.of("/tmp/exports") прямо внутри сервиса. Потом появляется Docker, потом появляется другой пользователь, потом CI, и внезапно оказывается, что /tmp живёт своей жизнью. Правильнее считать путь экспорта параметром окружения и держать его в конфигурации app.export.dir с переопределением через APP_EXPORT_DIR.
Ошибка №2: несколько разных мест, где собирается путь.
Одна часть кода пишет в data/exports, другая — в /app/exports, третья вообще в ./out. В итоге вы не можете объяснить, где искать результат, и не можете стабильно монтировать каталог. Лечится это простым правилом: одна точка правды в ExportProperties и один сервис, который отвечает за запись.
Ошибка №3: сервис пишет файл, но не создаёт директорию.
На вашей машине папка уже существует — и всё работает. В контейнере папка может не существовать, и вы получаете исключение посреди экспорта. Files.createDirectories(...) выглядит скучно, но это тот самый скучный код, который экономит часы «а почему оно упало только в контейнере».
Ошибка №4: экспорт заканчивается “ok”, но непонятно, что именно случилось.
Если метод возвращает void, а endpoint отвечает просто «export started», студентам и вам через неделю тяжело проверить результат. Минимальный учебный baseline — возвращать хотя бы fileName и itemsCount, а внутри приложения иметь Path результата и логировать факт записи.
Ошибка №5: попытка сделать экспорт слишком умным в одном методе.
Новичок часто пытается в одном месте и CSV собрать, и имя придумать, и директорию выбрать, и job записать. Это почти гарантированно превращается в большой метод, который страшно менять. Гораздо спокойнее разделить: конфигурация ExportProperties, генерация имени ExportFileNameFactory, запись файла CatalogExportService, orchestration — контроллер или фасад.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ