1. Введение
Когда утилита работает с файлами, она всегда немножко похожа на робота-пылесоса: идея классная, но иногда он находит носок, считает его мусором и уезжает с ним в неизвестность. Массовые операции copy/move — это особенно опасное место: вы запускаете обработку не одного файла, а сотен/тысяч, и любая ошибка превращается из «ой» в «ой-ой-ой, где мои фотки за 10 лет?».
В этой лекции мы добавим три вещи, которые сильно повышают шансы, что пользователь останется вашим другом (а не человеком, который пишет вам в 3 ночи «верни мои файлы»).
Мы научимся запускать утилиту в режиме dry-run (построить план и «как бы» выполнить, не меняя файловую систему), писать журнал операций простыми строками в файл (чтобы было понятно, что произошло), и формировать итоговый отчёт так, чтобы он записывался атомарно — то есть не оставлял «полу-файл» при сбое питания, ошибке диска или правах доступа.
2. Dry-run: репетиция без изменений файловой системы
Dry-run звучит как «сухой запуск», но по сути это режим «репетиция перед концертом». В нём утилита должна пройти все интеллектуальные шаги: прочитать конфигурацию, собрать кандидатов, построить план раскладки, проверить конфликты и… не делать реальных copyTo() и renameTo().
Важно уловить философию: dry-run — это не «мы часть сделаем, часть нет». Он должен быть полностью без изменений файловой системы. Максимум, что допустимо — записать лог и отчёт (и то, обычно лог/отчёт пишут в отдельную папку, чтобы не мешать целевому каталогу).
Чтобы dry-run выглядел в коде как «переключатель режима», удобно сделать отдельную функцию-обёртку, которая либо вызывает реальное выполнение, либо «симулирует» результаты на основе плана.
Вот минимальный вариант — мы будем возвращать список OpResult, но со статусом «не трогал, потому что dry-run»:
fun executeWithDryRun(plan: List<PlanItem>, cfg: OrganizerConfig): List<OpResult> {
if (!cfg.dryRun) return executePlan(plan, cfg)
return plan.map { item ->
OpResult(
source = item.source.path,
target = item.target.path,
status = Status.SKIPPED,
error = "dry-run"
)
}
}
Обратите внимание на маленькую деталь: мы не придумываем новую сущность «DryRunResult», а используем уже существующий OpResult. Да, статус SKIPPED тут не идеален философски, но он практичен: в отчёте будет видно, что операции не выполнялись, и почему.
Если вам хочется более «честной» модели, можно добавить новый статус PLANNED, но это уже изменение контракта результатов. В рамках текущего дня мы держим модель простой: статус + пояснение.
3. Журнал операций: лог, который спасает нервную систему
Журнал — это не то же самое, что отчёт. Отчёт — итог (сколько файлов обработали, какие ошибки). Журнал — это «что происходило по пути»: старт, параметры, сколько найдено кандидатов, сколько построено планов, где лежит отчёт, и почему мы остановились, если остановились.
Журнал особенно полезен в двух случаях: когда пользователь говорит «оно не работает», и когда вы сами через неделю открываете свой код и думаете «а что я имел в виду?». Программист без логов — как детектив без блокнота: вроде умный, но доказательства куда-то делись.
У нас не будет «настоящей» системы логирования с уровнями и ротацией (это отдельная тема). Мы сделаем самый простой протокол: каждая строка — одно событие. И добавим к строке простую метку времени, чтобы было понятно, в каком порядке всё происходило.
Для времени мы возьмём Java-класс LocalDateTime (он доступен на JVM, а мы сейчас именно там).
import java.io.File
import java.time.LocalDateTime
fun appendLogLine(logFile: File, message: String) {
logFile.parentFile?.mkdirs()
val ts = LocalDateTime.now()
logFile.appendText("[$ts] $message\n")
}
Заметьте: функция маленькая, но делает важное. Она сама создаёт директорию под лог (если нужно), добавляет перевод строки и гарантирует одинаковый формат.
Теперь в «главном сценарии» можно писать так (пока без полноценного main, просто идея):
import java.io.File
fun logStart(logFile: File, cfg: OrganizerConfig) {
appendLogLine(logFile, "Start File Organizer")
appendLogLine(logFile, "source=${cfg.sourceDir}, target=${cfg.targetDir}")
appendLogLine(logFile, "mode=${cfg.mode}, dryRun=${cfg.dryRun}")
}
Это выглядит банально, но на практике такой журнал очень быстро перестаёт быть «лишним» и начинает быть «спасибо, что он есть».
Небольшая таблица, чтобы не путать роли (она реально помогает мозгу):
| Артефакт | Для кого | Когда читают | Что внутри |
|---|---|---|---|
| Журнал (log) | для разработчика и диагностики | когда что-то пошло не так | события по шагам, параметры, подсказки |
| Отчёт (report) | для пользователя и результата | после выполнения | сводка и список операций |
| Печать в консоль | для «сейчас посмотрю» | во время запуска | короткие сообщения, минимум деталей |
4. Отчёт: сначала собираем текст, потом записываем
С отчётом есть две типичные ошибки новичка. Первая — формировать текст прямо в процессе копирования, перемешивая copyTo() и appendLine(...) в одну кашу. Вторая — писать отчёт напрямую в конечный файл, а потом удивляться «почему отчёт оборван на половине».
Мы сделаем правильно: сначала построим строку отчёта (чистая функция, без файловой системы), потом запишем её отдельно (I/O-часть).
Отчёт обычно состоит из двух частей: краткая сводка и список операций. Для сводки удобно посчитать, сколько результатов каждого статуса. Тут идеально ложится groupBy, потому что он превращает список в группы по ключу. Эта идея ровно та, что используется в стандартной библиотеке при группировках.
Минимальный рендер отчёта:
fun renderReport(cfg: OrganizerConfig, results: List<OpResult>): String {
val counts = results.groupBy { it.status }
.mapValues { (_, list) -> list.size }
val sb = StringBuilder()
sb.appendLine("File Organizer report")
sb.appendLine("mode=${cfg.mode}, dryRun=${cfg.dryRun}")
sb.appendLine("totals=$counts")
return sb.toString()
}
Уже неплохо, но хочется ещё и список операций. При этом важно не раздувать отчёт до размеров романа «Война и мир: директорий edition». Мы будем писать по одной строке на результат и добавлять ошибку только если она есть.
fun renderResultsList(results: List<OpResult>): String {
val sb = StringBuilder()
sb.appendLine()
sb.appendLine("Operations:")
for (r in results) {
val err = r.error?.let { " | error=$it" } ?: ""
sb.appendLine("${r.status}: ${r.source} -> ${r.target}$err")
}
return sb.toString()
}
Теперь можно собрать полный отчёт через «склейку строк» — это нормально, потому что мы делаем это в памяти, и это не массовая операция на тысячи мегабайт (а если будет — тогда вы уже знаете про StringBuilder и сможете сделать всё в одном sb):
fun renderFullReport(cfg: OrganizerConfig, results: List<OpResult>): String {
return renderReport(cfg, results) + renderResultsList(results)
}
5. Атомарная запись отчёта: temp-файл и renameTo
Атомарная запись — звучит как что-то из физики, но смысл бытовой: «или файл целиком новый, или файл целиком старый; а вот “наполовину записался” — не допускается».
Сломанный отчёт — это не так страшно, как сломанные файлы пользователя, но он ужасно неприятен. Представьте: утилита обработала 5000 файлов, на 4999 всё ок, а на последней секунде упала запись отчёта. Пользователь видит пустоту и думает, что вообще ничего не произошло. Поэтому отчёт — тоже часть надёжности.
Схема такая:
flowchart TD
A[Сформировали текст отчёта в памяти] --> B[Записали в report.tmp]
B --> C{Запись успешна?}
C -- нет --> D[Удалили tmp, вернули false]
C -- да --> E[Заменили старый report.txt]
E --> F[Переименовали tmp -> report.txt]
F --> G[Готово: отчёт целиком]
Реализация «в учебном стиле» (простая, но дисциплинированная):
import java.io.File
import java.io.IOException
fun writeTextAtomically(target: File, text: String): Boolean {
val parent = target.parentFile ?: return false
if (!parent.exists() && !parent.mkdirs()) return false
val tmp = File(parent, target.name + ".tmp")
return try {
tmp.writeText(text)
if (target.exists() && !target.delete()) return false
tmp.renameTo(target)
} catch (e: IOException) {
false
} finally {
if (tmp.exists()) tmp.delete()
}
}
Пара пояснений, потому что тут легко потеряться.
Мы создаём директорию под отчёт заранее. Это важнее, чем кажется, потому что отчёт обычно хотят положить, например, в targetDir/reports/report.txt, а папки reports ещё нет.
Мы пишем в .tmp. Даже если программа упадёт на середине writeText, она испортит только временный файл, а не «главный» отчёт.
Перед renameTo мы удаляем старый отчёт, если он был. Это «бытовой» способ, не самый идеальный для всех ОС, но в рамках курса и консольной утилиты — достаточно.
В finally мы стараемся подчистить tmp. Даже если всё прошло успешно, tmp уже переименован, exists() будет false, и мы ничего не удалим. Если же всё упало — мы хотя бы попробуем не оставлять мусор.
6. Единый сценарий: dry-run, лог и отчёт
Сейчас у нас есть все «кирпичики», и осталось собрать их в один понятный сценарий запуска. Тут важно сохранять стиль: «сначала планируем, потом исполняем, потом отчитываемся».
Сделаем функцию runOrganizerOnce, которая берёт конфиг, строит результаты, пишет лог и сохраняет отчёт:
import java.io.File
fun runOrganizerOnce(cfg: OrganizerConfig, logFile: File, reportFile: File): Boolean {
appendLogLine(logFile, "Run started")
validateConfig(cfg)
val plan = buildPlan(cfg) // считаем, что у вас уже есть эта функция из прошлых лекций
appendLogLine(logFile, "Plan size=${plan.size}")
val results = executeWithDryRun(plan, cfg)
appendLogLine(logFile, "Results size=${results.size}")
val reportText = renderFullReport(cfg, results)
val ok = writeTextAtomically(reportFile, reportText)
appendLogLine(logFile, "Report written=$ok path=${reportFile.path}")
return ok
}
Здесь я намеренно использую «как будто уже есть» buildPlan(cfg). В вашем коде это будет композиция предыдущих шагов: обход директорий, фильтрация, построение PlanItem. Смысл в том, что в этой лекции мы не лезем обратно в сбор кандидатов и раскладку: мы добавляем слой безопасности и отчётности поверх уже готового двигателя.
И вот тут проявляется приятная архитектурная идея: если dry-run — это всего лишь «другая ветка исполнения», то весь остальной код остаётся тем же. Мы не делаем второй File Organizer «для репетиции». У нас один и тот же организатор, просто в разных режимах.
Dry-run и конфликты имён
Есть тонкий момент, который в реальном проекте обязательно всплывёт: «а как dry-run должен вести себя при конфликтах имён, если в целевой папке уже есть файлы?»
Правильный ответ: dry-run должен уметь сообщить, что будет сделано. То есть если ConflictPolicy.SKIP, в dry-run логично показать, что файл будет пропущен из-за существования target. Если ConflictPolicy.BACKUP, логично показать, что планируется backup. Но при этом нельзя реально делать renameTo существующего файла, потому что это уже изменение ФС.
В рамках нашего упрощённого подхода (статус SKIPPED + "dry-run") мы не различаем причины. Это нормально для учебного шага, но если хочется чуть более честной симуляции — можно «внутри dry-run» сделать проверку target.exists() и написать в error более конкретный текст, не меняя файловую систему:
fun simulateDryRun(item: PlanItem): OpResult {
val reason = if (item.target.exists()) "dry-run (target exists)" else "dry-run"
return OpResult(item.source.path, item.target.path, Status.SKIPPED, reason)
}
Главное: любые проверки exists() — это безопасно. Любые copyTo, renameTo, delete — в dry-run запрещены.
7. Типичные ошибки
Ошибка №1: dry-run, который всё равно «чуть-чуть перемещает».
Иногда dry-run делают так: «ну мы же только папки создадим через mkdirs(), это же не страшно». И вот тут начинается скользкая дорожка: сегодня создали папки, завтра удалили старый отчёт, послезавтра «случайно» сделали backup. В итоге пользователь запускает dry-run и обнаруживает изменения. В хорошем дизайне dry-run не меняет файловую систему вообще, иначе доверие к режиму исчезает.
Ошибка №2: смешать выполнение и формирование отчёта в один огромный цикл.
Новичок часто пишет так: «копируем файл → сразу пишем строку в отчёт», и всё это в одном for. Это неудобно отлаживать и почти невозможно аккуратно перевести в dry-run. Гораздо проще держать дисциплину: выполнение возвращает List<OpResult>, отчёт строится отдельной функцией из результатов, а запись отчёта — третьей функцией.
Ошибка №3: писать отчёт напрямую в конечный файл без temp-файла.
Кажется, что «ну отчёт же маленький, что с ним будет». Но отчёт может писаться в сетевую папку, диск может быть заполнен, права могут быть ограничены, и в итоге вы получите обрубок. Temp + rename — это простой приём, который резко повышает качество утилиты: либо отчёт старый, либо новый, но не «наполовину».
Ошибка №4: забыть создать директорию под лог/отчёт.
Очень частая ситуация: вы красиво указали путь target/reports/report.txt, но папки reports нет. Тогда writeText падает, а вы думаете, что проблема в коде копирования. Маленькое parent.mkdirs() перед записью экономит тонны времени на «почему не записалось».
Ошибка №5: лог без времени и контекста.
Лог вида «Start», «Plan size=100», «Done» — это почти как не лог вообще. В нём нет времени, нет параметров запуска, нет путей, нет режима COPY/MOVE. В следующий раз вы увидите этот файл и не поймёте, что за запуск его создал. Даже простая метка LocalDateTime.now() и пара строк с конфигурацией превращают лог из «шум» в «диагностика».
Ошибка №6: прятать ошибки записи отчёта и всегда печатать «успех».
Иногда хочется сделать «пользовательский опыт» гладким и написать println("Done!"), даже если writeTextAtomically вернул false. Это плохая идея: отчёт — часть результата. Если он не записался, лучше честно сообщить пользователю в консоль и записать в лог, что отчёт не удалось сохранить (и куда пытались сохранить). Честность тут не моральная категория, а способ уменьшить количество загадочных багрепортов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ