JavaRush /Курсы /Docker for Spring /Пути, имена файлов и права

Пути, имена файлов и права

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

1. Путь к файлу как контракт сервиса

С export directory мы уже навели порядок: сервис получает его извне и перестал гадать, куда писать. Но на этом файловый сценарий только перестаёт быть случайным — он ещё не становится надёжным. Когда разработчик впервые делает экспорт в файл, обычно хочется просто «быстро записать строку в catalog.csv и порадоваться жизни». И это нормально — пока мы живём в мире одной ОС, одной машины и одного запуска. Но как только появляется Docker и mount’ы, путь внезапно становится контрактом: где именно файл появится, кто его увидит, какие символы допустимы, и сможет ли процесс вообще туда писать.

Самая коварная часть в том, что ошибки путей и прав выглядят как «сломалась Java» или «сломался Spring». На деле часто ломается не код бизнес-логики, а окружающая среда: контейнерный путь не существует, bind mount накрыл другую директорию, имя файла содержит : (и привет Windows), каталог доступен только на чтение, а приложение честно падает, потому что оно не умеет писать в камень.

Давайте зафиксируем одну мысль, которая спасает нервы: в приложении есть “контейнерный путь” (target path), а у Docker есть “host путь” (source path). Это разные сущности, и смешивать их — как путать адрес доставки и адрес магазина.

Чтобы было визуально, вот простая схема:

flowchart TD
  Host["Host OS (Windows/macOS/Linux) каталог проекта: ./data/exports"] -->|"bind mount (source)"| Container["Container FS /app/exports (target)"]
  Container -->|"APP_EXPORT_DIR=/app/exports"| App["Spring Boot app пишет CSV файл"]
  App --> File["/app/exports/catalog-2026-03-21T10-15-00.csv"]

Приложение знает только target path внутри контейнера (например, /app/exports). Docker знает, куда это привязано на хосте (например, ./data/exports). Если вы пытаетесь дать приложению host-путь вроде C:\Users\me\project\data\exports, а оно запускается в Linux-контейнере — дальше начинается очень грустный стендап, только без смеха.

2. Path и resolve вместо строк

Склеивание путей строками — один из тех «грехов молодости», которые вроде бы не запрещены, но потом вас же и кусают. Типичная версия выглядит невинно: "dir" + "/" + "file". Проблема в том, что вы вручную берёте на себя ответственность за разделители (/ vs \), лишние слэши, .. в середине, и за читаемость кода. А ещё за то, что где-то обязательно появится dir + file без слэша и будет загадочный баг «почему путь неправильный».

В Java для путей есть нормальная модель: java.nio.file.Path. Она умеет соединять сегменты пути, понимает разделители текущей платформы, и главное — делает код читабельным. Это как перестать носить воду в решете: теоретически можно, но зачем.

Пример «как НЕ надо»

// Директория экспорта (как строка)
String exportDir = "/app/exports";
// Имя файла (как строка)
String fileName = "catalog.csv";

// Склейка руками: легко забыть слэш, получить двойной слэш или сломаться на другой ОС
String path = exportDir + "/" + fileName;
System.out.println(path); // /app/exports/catalog.csv

Это работает в Linux-подобном окружении, но уже здесь вы «вручную» управляете разделителем и не контролируете, что exportDir может прийти с хвостовым /.

Пример «как надо» (Path.of + resolve)

import java.nio.file.Path;

// Директория как Path (это уже модель пути, а не «просто строка»)
Path exportDir = Path.of("/app/exports");
// Добавляем имя файла как «сегмент» пути (без ручных слэшей)
Path file = exportDir.resolve("catalog.csv");

System.out.println(file); // /app/exports/catalog.csv

Обратите внимание: resolve буквально читается как «разреши файл относительно директории». Это очень человеческий язык — и в этом половина успеха.

Небольшая табличка, чтобы закрепить:

Задача Строки Path
Соединить директорию и имя файла легко сделать ошибку с / exportDir.resolve(fileName)
Кроссплатформенность надо помнить про \ Path сам разрулит
Нормализация (.., .) надо писать руками path.normalize()
Проверки (существует? директория?) через File/строки, боль Files.* +
Path

И да: можно использовать File.separator, но это скорее «пластырь», чем решение. Path — это нормальная архитектурная привычка.

3. Относительные и абсолютные пути

Когда вы пишете Path.of("data", "exports"), Java создаёт относительный путь. Относительный — значит «от текущей рабочей директории процесса». В локальном запуске из IDE это обычно корень проекта (но не всегда), а в контейнере это обычно значение WORKDIR из Dockerfile (например, /app). И вот здесь у начинающих происходит первый «файловый парадокс»: один и тот же код внезапно пишет в разные места, потому что рабочая директория разная.

Поэтому базовое правило очень простое: относительный путь — это нормально, если вы осознанно контролируете, откуда он считается. В учебном проекте мы часто хотим, чтобы при локальном запуске экспорт попадал в репозиторий (например, ./data/exports), а в контейнере — в контейнерный каталог (например, /app/exports) с возможностью примонтировать его на host.

Важный нюанс: APP_EXPORT_DIR для приложения — это путь внутри процесса, то есть внутри контейнера (когда мы в контейнере). Не пытайтесь превращать APP_EXPORT_DIR в «путь на вашем ноутбуке». Это Docker-уровень, не уровень Java-кода.

Посмотрите на пример, который хорошо показывает разницу:

import java.nio.file.Path;

// Относительный путь: считается от текущей рабочей директории процесса
Path local = Path.of("data", "exports");
System.out.println(local.toString()); // data/exports (или data\exports на Windows)

// Абсолютный путь (в стиле Linux): часто используется как стабильный target внутри контейнера
Path container = Path.of("/app/exports");
System.out.println(container.toString()); // /app/exports

Оба пути валидны, просто они про разные режимы запуска. В локальном режиме относительный путь удобен тем, что он не привязан к вашему C:\Users\... или /Users/.... В контейнерном режиме абсолютный путь внутри контейнера удобен тем, что он стабилен и не зависит от того, где вы запускаете docker run.

И ещё один маленький, но полезный приём: внутри кода почти всегда полезно приводить путь к нормализованной форме, чтобы вы хотя бы логами видели «что получилось».

import java.nio.file.Path;

// toAbsolutePath(): чтобы видеть полный путь в логах и не гадать «откуда считается»
// normalize(): чтобы убрать лишние "./" и "dir/.."
Path dir = Path.of("data/exports").toAbsolutePath().normalize();
System.out.println(dir); // /.../project/data/exports

Если вы хоть раз ловили «почему файл не там» — вы оцените, насколько это сокращает время на гадание.

4. Безопасные имена файлов

Имена файлов часто воспринимаются как «ну строка и строка». Но в реальной жизни это входные данные (иногда даже от пользователя), а файловые системы имеют характер. Причём характер у них разный: Linux обычно терпеливее к символам, Windows — гораздо более строгий, macOS любит свои особенности. Поэтому «работает у меня в контейнере» не гарантирует «работает у студента на Windows, который хочет увидеть экспорт на хосте».

Самый частый сюрприз: вы берёте LocalDateTime.now().toString() и получаете строку с двоеточиями в времени, например 2026-03-21T10:15:00. На Linux двоеточие в имени файла обычно допустимо, а на Windows — нет. Итог: экспорт «вдруг» падает, хотя код «абсолютно правильный».

Плохой (но очень популярный) вариант имени

import java.time.LocalDateTime;

// LocalDateTime.toString() содержит ":" в части времени — на Windows это запрещённый символ в имени файла
String fileName = "catalog-" + LocalDateTime.now() + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10:15:00.csv

С точки зрения Java это просто строка. С точки зрения Windows — это попытка вставить запрещённый символ.

Хороший вариант: DateTimeFormatter без запрещённых символов

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

// Формат специально без ":" (заменяем на "-"), чтобы имя было кроссплатформенным
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
String fileName = "catalog-" + LocalDateTime.now().format(fmt) + ".csv";

System.out.println(fileName); // catalog-2026-03-21T10-15-00.csv

Здесь мы заменили : на - именно в формате времени. Получилось читаемо, сортируемо, кроссплатформенно.

Санитарная обработка «имени откуда-то извне»

Даже если имя генерируем мы, полезно иметь маленькую функцию, которая убирает очевидные проблемы. Не нужно превращать это в «библиотеку имени файла на 600 строк», достаточно учебного, честного минимума.

public class FileNameSanitizer {

    public String sanitize(String raw) {
        // Минимальная «санитарная обработка»: убираем самые частые проблемные символы
        return raw
                .replace(":", "-")
                .replace("/", "_")
                .replace("\\", "_");
    }
}

Да, это простовато. И да, в реальном проде можно обсуждать Unicode-нормализацию и прочие радости. Но в учебном проекте задача другая: убрать самые частые сюрпризы, чтобы экспорты перестали падать «просто потому что символ».

И ещё один момент, который многие забывают: имя файла и директория — разные сущности. Сначала вы выбираете каталог, потом добавляете имя через resolve. Если вы держите это в одной строке вроде "data/exports/catalog.csv", у вас неизбежно появятся баги, где вы случайно дублируете часть пути или подменяете каталог.

5. normalize() и защита от ../

Сейчас будет часть, где вы почувствуете себя чуть-чуть взрослым инженером: даже если в курсе нет отдельного security-трека, файлы — это место, где безопасность и «просто аккуратность» встречаются без предупреждения.

Проблема выглядит так: вы думаете, что вам дали имя файла catalog.csv, а вам (случайно или специально) дали ../../oops.txt. Если вы тупо сделаете exportDir.resolve(fileName) — у вас получится путь за пределами export directory. Иногда это просто ошибка пользователя, иногда — неприятная попытка записать файл «куда-то ещё». В любом случае сервису лучше уметь это ловить.

Решение простое и очень показательное: resolve + normalize, а потом проверка startsWith(exportDir).

import java.nio.file.Path;

// Базовая директория экспорта (то, что считаем «разрешённой зоной»)
Path exportDir = Path.of("/app/exports").normalize();
// «Имя файла», которое внезапно оказалось путём с выходом наверх
String rawName = "../oops.txt";

// resolve() склеит путь, normalize() попытается схлопнуть ".." и "."
Path candidate = exportDir.resolve(rawName).normalize();

// Проверяем, что после нормализации путь всё ещё начинается с exportDir
if (!candidate.startsWith(exportDir)) {
    throw new IllegalArgumentException("Invalid export file name: " + rawName);
}

Тут важно понять механику: normalize() убирает .. и . на уровне пути (там, где это возможно). А startsWith(exportDir) проверяет, что итоговый путь всё ещё внутри нужной директории. Это хороший «пояс безопасности» даже для учебного проекта: он защищает от случайностей и делает поведение предсказуемым.

Можно красиво завернуть это в маленький класс, чтобы бизнес-код не расползался:

import java.nio.file.Path;

public class ExportPathResolver {
    private final Path exportDir;

    public ExportPathResolver(Path exportDir) {
        this.exportDir = exportDir.normalize();
    }

    public Path resolve(String fileName) {
        Path file = exportDir.resolve(fileName).normalize();
        if (!file.startsWith(exportDir)) {
            throw new IllegalArgumentException("File escapes export dir: " + fileName);
        }
        return file;
    }
}

Код короткий, логика в одном месте, и теперь CatalogExportService может думать об экспорте, а не о том, как не улететь в соседний каталог.

6. Права записи: как отличить “не могу писать” от “сломался экспорт”

Когда приложение не может записать файл, начинающий разработчик часто начинает подозревать всё: CSV-генерацию, кодировку, «не тот Spring профиль», «Docker опять магия». На практике это часто банальнее: директория не существует, директория существует, но это файл, директория есть, но она read-only, директория примонтирована, но контейнер не имеет прав на запись, или сама файловая система внутри окружения не разрешает запись.

Хорошая инженерная привычка здесь — валидировать директорию заранее и падать с понятным сообщением до того, как вы начали «делать экспорт». Тогда ошибка будет выглядеть как «export directory is not writable», а не как «что-то где-то не так в середине».

Вот минимальный валидатор директории (без фанатизма):

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ExportDirectoryValidator {

    public Path prepare(String configuredDir) throws IOException {
        // Приводим путь к абсолютному и нормализованному виду, чтобы он был понятен в логах
        Path dir = Path.of(configuredDir).toAbsolutePath().normalize();

        // Создаём директории при необходимости (операция идемпотентна)
        Files.createDirectories(dir);

        // Проверяем право на запись заранее, чтобы ошибка была «человеческой» и ранней
        if (!Files.isWritable(dir)) {
            throw new IllegalStateException("Export directory is not writable: " + dir);
        }
        return dir;
    }
}

Files.createDirectories(dir) удобен тем, что он идемпотентный: если директория уже есть, он не делает вид, что всё пропало. А Files.isWritable(dir) даёт быстрый сигнал «туда реально можно писать?».

Какие исключения чаще всего увидите

В Java при проблемах с файловой системой часто встречаются AccessDeniedException, NoSuchFileException, иногда FileSystemException. Для новичка полезно хотя бы один раз увидеть, что это разные классы ошибок: первая про права, вторая про отсутствие пути, третья — общий контейнер для «файловая система сказала “нет”».

Можно написать простую обработку, которая превращает «страшную ошибку» в понятный текст, не раскрывая лишних деталей:

import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;

public class ExportErrorMapper {

    public String toHumanMessage(Exception e) {
        if (e instanceof AccessDeniedException) {
            return "Нет прав на запись в директорию экспорта";
        }
        if (e instanceof NoSuchFileException) {
            return "Директория или файл не найдены (проверь путь и mount)";
        }
        return "Ошибка файловой системы: " + e.getClass().getSimpleName();
    }
}

Это не «великая архитектура», но как учебная привычка — отлично. Особенно когда приложение в контейнере, а вы пытаетесь понять, это ошибка кода или окружения.

7. Bind mount: кроссплатформенные нюансы

Файловые сценарии ломаются не только из-за Java. Bind mount может добавить своих сюрпризов, особенно если команда работает на разных ОС. Здесь важно знать ровно несколько вещей — достаточно, чтобы не тратить полдня на «почему не работает», но недостаточно, чтобы курс внезапно стал курсом по Linux.

Первый нюанс: Docker различает source и target. target — путь внутри контейнера (почти всегда Linux-стиль, например /app/exports). source — путь на host (может быть Windows-путь, macOS-путь, относительный путь проекта). И вот APP_EXPORT_DIR должен совпадать именно с target, иначе приложение будет писать не туда, куда вы смонтировали.

Второй нюанс: явный --mount type=bind обычно ожидает, что source-путь уже существует. Если вы укажете несуществующую директорию, Docker не всегда будет “вежливо создавать” её за вас. Это хорошо: ошибка ранняя и понятная, вместо того чтобы внезапно появилось что-то «не там и не так». (Короткий синтаксис -v в некоторых окружениях может вести себя иначе, но в учебном контексте полезнее привыкать к явности.)

Третий нюанс: Windows-путь с C: конфликтует с “двоеточием” в старом синтаксисе volume mapping. Поэтому на Windows чаще всего проще использовать либо --mount ..., либо аккуратно экранировать/кавычить строки. Мы не будем уходить в зоопарк оболочек, но полезно помнить: если видите ошибку, где Docker “не понял” путь, это не всегда ваша вина — иногда это просто синтаксис командной строки.

Чтобы не расписывать три отдельных курса «Bash / PowerShell / CMD», достаточно запомнить идею: source-путь подставляйте так, чтобы он был корректен на вашей оболочке, а target-путь внутри контейнера всегда делайте Linux-абсолютным.

Например, сама логика монтирования может выглядеть так (концептуально):

docker run --rm \
  --mount type=bind,source=./data/exports,target=/app/exports \
  -e APP_EXPORT_DIR=/app/exports \
  your-image:tag

Здесь нет ничего “про Windows” или “про Linux” в target — он стабилен. Вся «платформенность» живёт в source, и это нормально: host всегда разный.

8. Мини-рефакторинг экспортного сервиса

Safe filename, normalize() и writable-проверка полезны ровно в том месте, где сервис реально записывает файл. Export directory уже приходит из app.export.dir, так что новый способ конфигурации не нужен. Здесь нам важны две локальные вещи: безопасное имя файла и гарантия, что итоговый путь не вывалится за пределы export directory.

Имя файла по-прежнему удобно генерировать отдельно — например, через DateTimeFormatter без :. А внутри уже существующего CatalogExportService, который получает ExportProperties props, запись можно усилить так:

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public Path writeCsv(String fileName, String csv) throws IOException {
    Path exportDir = ExportDirectoryValidator.prepare(props.dir().toAbsolutePath().normalize());

    Path file = exportDir.resolve(fileName).normalize();
    if (!file.startsWith(exportDir)) {
        throw new IllegalArgumentException("Bad file name: " + fileName);
    }

    return Files.writeString(file, csv, StandardCharsets.UTF_8);
}

Получается важная вещь: директория по-прежнему приходит из одной точки правды, а сам сервис становится устойчивее к кривым именам, странным относительным путям и неожиданным проблемам записи.

Когда директория, имя файла и права перестают быть случайностью, остаётся проверить главное: переживает ли результат замену контейнера, или всё ещё живёт только до первого docker rm.

9. Типичные ошибки при работе с путями

Ошибка №1: склеивание путей строками и “слэши по настроению”.
Обычно это начинается с dir + "/" + name, а заканчивается тем, что в одном месте написали dir + name, в другом — два слэша подряд, а на Windows внезапно появляется \ и часть кода начинает жить своей жизнью. Path.of(...) и resolve(...) убирают этот класс проблем почти полностью, и заодно делают код проще для чтения.

Ошибка №2: путать host-путь (source) и контейнерный путь (target) и пытаться кормить приложение C:\....
В контейнере почти всегда Linux-файловая система, и строка C:\temp\exports внутри такого окружения не означает “диск C”. Это просто странное имя директории с двоеточием и обратными слэшами, которое вы сами же и создадите (если повезёт с правами). Приложение должно получать путь внутри контейнера, например /app/exports, а host-путь живёт только в Docker-команде как source.

Ошибка №3: генерировать имя файла через LocalDateTime.toString() и ловить запретные символы.
На Linux двоеточия обычно проходят, на Windows — нет. В результате экспорт «сломался только у части группы», и начинается охота на ведьм. Стабильный DateTimeFormatter с форматом HH-mm-ss снимает проблему, при этом имя остаётся сортируемым и читаемым.

Ошибка №4: не нормализовать путь и не проверять startsWith(exportDir).
Если в имя файла внезапно попали ../ (по ошибке или из внешнего ввода), resolve(fileName) может вывести вас за пределы export directory. Это плохая непредсказуемость даже для учебного сервиса. normalize() + startsWith() — маленькая защита, которая делает результат очевидным и управляемым.

Ошибка №5: думать, что “если директория существует, значит писать можно”.
Права — отдельный слой реальности. Директория может существовать, но быть read-only, может быть смонтирована без прав на запись, может быть файловой системой, которая запрещает изменения, или просто принадлежать пользователю, под которым процесс не имеет доступа. Быстрая валидация через Files.createDirectories(...) и Files.isWritable(...) даёт понятную ошибку раньше, чем вы закопаетесь в расследование.

1
Задача
Docker for Spring, 13 уровень, 3 лекция
Недоступна
Безопасное имя файла и корректный путь
Безопасное имя файла и корректный путь
1
Задача
Docker for Spring, 13 уровень, 3 лекция
Недоступна
Ошибка записи в read-only каталог
Ошибка записи в read-only каталог
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ