1. Вступ
Коли утиліта працює з файлами, вона завжди трохи схожа на робота-пилососа: ідея класна, але інколи він знаходить шкарпетку, вирішує, що це сміття, і їде з нею в невідомість. Масові операції copy/move — особливо небезпечна зона: ви запускаєте обробку не одного файла, а сотень або тисяч, і будь-яка помилка миттєво перетворюється з «ой» на «ой-ой-ой, де мої фотки за 10 років?».
У цій лекції ми додамо три речі, які різко підвищують шанси, що користувач залишиться вашим другом (а не людиною, яка пише вам о третій ночі: «поверни мої файли»).
Ми навчимося запускати утиліту в режимі 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()
}
Уже непогано, але хочеться ще й список операцій. При цьому важливо не роздути звіт до розмірів товстелезного роману «каталогове видання». Ми писатимемо по одному рядку на результат і додаватимемо помилку лише тоді, коли вона є.
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
Атомарний запис звучить як щось із фізики, але сенс дуже побутовий: «або файл повністю новий, або файл повністю старий; а от “наполовину записався” — неприпустимо».
Зіпсований звіт — не так страшно, як зіпсовані файли користувача, але це однаково дуже неприємно. Уявіть: утиліта обробила 5 000 файлів, 4 999 — без проблем, а в останню секунду «впав» запис звіту. Користувач бачить порожнечу й думає, що взагалі нічого не сталося. Тому звіт — це теж частина надійності.
Схема така:
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. Це погана ідея: звіт — частина результату. Якщо він не записався, краще чесно повідомити користувача в консоль і записати в лог, що звіт не вдалося зберегти (і куди саме намагалися зберегти). Чесність тут — не моральна категорія, а спосіб зменшити кількість загадкових багрепортів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ