JavaRush /Курсы /Docker for Spring /Export directory и APP_EX...

Export directory и APP_EXPORT_DIR

Docker for Spring
13 уровень , 2 лекция
Открыта

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
app.export.dir: data/exports
Нормальный default для локальной разработки
Переопределение через env var
APP_EXPORT_DIR=/app/exports
Канонический контейнерный подход: 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 — контроллер или фасад.

1
Задача
Docker for Spring, 13 уровень, 2 лекция
Недоступна
Одна конфигурационная точка для export directory
Одна конфигурационная точка для export directory
1
Задача
Docker for Spring, 13 уровень, 2 лекция
Недоступна
Один image и переопределение пути через APP_EXPORT_DIR
Один image и переопределение пути через APP_EXPORT_DIR
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ