JavaRush /Курсы /Kotlin SELF /Безопасность и отчётность

Безопасность и отчётность

Kotlin SELF
46 уровень , 4 лекция
Открыта

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. Это плохая идея: отчёт — часть результата. Если он не записался, лучше честно сообщить пользователю в консоль и записать в лог, что отчёт не удалось сохранить (и куда пытались сохранить). Честность тут не моральная категория, а способ уменьшить количество загадочных багрепортов.

1
Задача
Kotlin SELF, 46 уровень, 4 лекция
Недоступна
Репетиция плана
Репетиция плана
1
Задача
Kotlin SELF, 46 уровень, 4 лекция
Недоступна
Лог запуска
Лог запуска
1
Задача
Kotlin SELF, 46 уровень, 4 лекция
Недоступна
Большой отчёт
Большой отчёт
1
Опрос
Мини‑проект, 46 уровень, 4 лекция
Недоступен
Мини‑проект
Мини‑проект
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ