1. Частковий файл і публікація результату
Якби файли були «чесними», у разі проблеми вони просто зникали б. Та реальність суворіша: під час помилки, вимкнення живлення, переповнення диска або скасування корутини у вас може лишитися файл, який існує, має розмір більший за нуль, інколи навіть відкривається… і водночас містить лише частину даних. Такий «півфайл» особливо небезпечний тим, що виглядає як нормальний результат роботи програми.
Уявіть два сценарії. У першому ви генеруєте звіт report.txt і записуєте його напряму. Користувач бачить файл, відкриває його — а там обрізаний текст, але без явної помилки. У другому ви копіюєте великий файл і перериваєте процес: якщо ви писали одразу в підсумковий шлях, у вас зʼявиться «готовий» файл, який насправді пошкоджений. І ось тут починаються веселі квести рівня «чому в мене інколи ламається архів».
Майже завжди краще мати правило: результат вважається опублікованим лише після успішного завершення операції, а до цього моменту він має жити в «чернетці» (temp/part). Саме такий механізм ми й будемо будувати.
Стратегія публікації: temp → rename → cleanup
У цій темі важливо навести лад у термінах — вони справді допомагають мислити. Є дія «зробити роботу» (записати байти/текст), і є дія «опублікувати результат» (зробити так, щоб користувач/інша програма побачили фінальний файл). Якщо змішати ці дії в одну, ви й отримаєте часткові результати, які виглядають «як справжні».
Стратегія, яку ми застосовуватимемо, проста на рівні ідеї:
- створюємо тимчасовий файл поруч із цільовим (.tmp/.part);
- записуємо/копіюємо лише в нього;
- якщо все успішно — виконуємо перейменування у цільове імʼя;
- якщо щось пішло не так — у finally намагаємося видалити тимчасовий файл (best‑effort cleanup).
Це зручно уявити як маленьку блок-схему:
flowchart TD
A[Потрібно отримати файл target] --> B[Створити тимчасовий файл поруч: target.tmp/target.part]
B --> C[Писати/копіювати дані лише в temp]
C --> D{Успіх?}
D -->|Так| E[Перейменувати temp -> target]
D -->|Ні / скасування| F[Не публікувати результат]
E --> G[Cleanup: видалити залишки temp, якщо лишилися]
F --> G
У Kotlin дисципліна «гарантованого прибирання» зазвичай будується на try/finally. А дисципліна «гарантованого закриття ресурсів» — на .use { ... }, яка автоматично закриває AutoCloseable навіть у разі винятків. Це саме той випадок, коли стандартні інструменти мови допомагають не виконувати «магічні ритуали» вручну.
Чому temp має лежати поруч із target
Дуже хочеться створити temp‑файл десь у /tmp, бо «там же тимчасове». Але нам потрібна передбачуваність, а не пригоди. Якщо розмістити temp поруч із target (у тій самій директорії), ви зменшите кількість несподіванок із правами доступу, різними дисками та перенесенням між файловими системами.
Ще один момент — іменування. .tmp або .part роблять дві корисні речі: по‑перше, ви самі очима бачите «це не фінал»; по‑друге, якщо вашу папку читає інший інструмент (або навіть ви за тиждень), шанс переплутати менший. Це той рідкісний випадок, коли «гарне імʼя» — частина надійності.
Зробімо маленьку таблицю, щоб мозок не сперечався сам із собою:
| Підхід запису | Що бачить користувач у процесі | Ризик «півфайла» | Зрозумілість після збою |
|---|---|---|---|
| Пишемо одразу в target.txt | target.txt зʼявляється і зростає | високий | «чому файл є, але він битий?» |
| Пишемо в target.txt.tmp, потім rename | target.txt відсутній до успіху | низький | лишається *.tmp, видно, що операція не завершилася |
І так, інколи temp теж може лишитися. Але «лишився report.txt.tmp» — це в тисячу разів зрозуміліше, ніж «report.txt є, але він пошкоджений».
2. Cleanup і best-effort
Чому cleanup роблять у finally
В ідеальному світі delete() завжди видаляє файл, а renameTo() завжди перейменовує. У реальному світі файл може бути заблокований системою, антивірусом, іншою програмою або вашим же кодом (коли ви забули закрити потік). Тому прибирання має бути best‑effort: ви чесно намагаєтеся, але не перетворюєте cleanup на окрему катастрофу, яка перекриває первинну помилку.
Ключовий момент: finally виконується і за звичайного завершення, і за винятків, і за скасування корутини (скасування зрештою теж «приходить» як виняток). Тому якщо ви хочете «прибрати в будь-якому разі», місце для цього майже завжди саме finally. У Kotlin допустимий try { ... } finally { ... } навіть без catch, коли ваша мета — гарантовано виконати дії з очищення.
deleteQuietly(...)
Нижче — маленький хелпер, який ми використовуватимемо в прикладах. Він спеціально «тихий»: не падає, якщо видалити не вдалося.
import java.io.File
fun deleteQuietly(file: File) {
try {
if (file.exists()) file.delete()
} catch (_: Exception) {
// Cleanup best-effort: не перетворюємо прибирання на нову помилку
}
}
Тут є легка самоіронія: ми робимо вигляд, що delete — це «просто», а потім додаємо try/catch, бо життя любить ламати наші ілюзії.
3. Практика: безпечний запис і копіювання
writeTextSafely(...)
Тепер зберемо першу корисну «цеглинку» для нашого навчального застосунку. Нехай у нас є консольна утиліта, яка обробляє файли й пише звіт (лог, summary, список помилок) у текстовий файл. Записувати звіт напряму в report.txt небезпечно: якщо вас скасували або програма впала на половині шляху, ви залишите «обрубок звіту», який виглядатиме як фінальний.
Зробімо suspend-функцію, яка пише в temp, а потім публікує результат через renameTo. Важливо, що файлові операції ми виконуємо в Dispatchers.IO, бо вони блокувальні.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun writeTextSafely(target: File, text: String) {
val tmp = File(target.parentFile, target.name + ".tmp")
try {
withContext(Dispatchers.IO) {
tmp.parentFile?.mkdirs()
tmp.writeText(text)
}
val renamed = withContext(Dispatchers.IO) {
if (target.exists()) target.delete()
tmp.renameTo(target)
}
if (!renamed) error("Не вдалося перейменувати ${tmp.name} на ${target.name}")
} finally {
deleteQuietly(tmp)
}
}
Зверніть увагу на зміст. Поки ви пишете, target може взагалі не існувати (або лишатися старим). Лише коли запис у temp успішно завершився, ви намагаєтеся виконати крок публікації (rename). І навіть якщо публікація не вдалася, у вас лишається temp, який ви спробуєте прибрати.
copySafely(...)
Із текстом усе було відносно мирно: writeText записує все одразу. Але з великими файлами так робити не можна — і через памʼять, і через скасування, і через контроль процесу. У минулій лекції ми вже робили chunk‑копіювання та додавали кооперативне скасування через isActive. Тепер додамо другий шар: не писати напряму в цільовий файл, а писати в .part і лише потім публікувати.
Припустімо, що функція copyCancellable(from, to) у нас уже є з минулої лекції й копіює файл блоками, періодично перевіряючи скасування. Тоді безпечна обгортка виглядатиме так:
import java.io.File
suspend fun copySafely(from: File, target: File) {
val part = File(target.parentFile, target.name + ".part")
try {
part.parentFile?.mkdirs()
copyCancellable(from, part) // копіюємо в чернетку
val ok = if (target.exists()) target.delete() else true
if (!ok) error("Не вдалося видалити старий файл: ${target.name}")
val renamed = part.renameTo(target)
if (!renamed) error("Не вдалося опублікувати результат: ${target.name}")
} finally {
deleteQuietly(part)
}
}
Тут є важлива «психологія» коду: ми не намагаємося зробити все ідеально на рівні файлової системи (це окрема наука), але формуємо зрозумілий, практичний і майже завжди достатній контракт для навчального проєкту: «або є новий коректний файл, або нового файла немає взагалі». А .part по дорозі буде або видалений, або лишиться сигналом, що операція не завершилася.
Вбудовуємо в консольний застосунок: звіт
Тепер поєднаємо дві ідеї: ви копіюєте кілька файлів, а потім пишете підсумковий звіт так, щоб він не зʼявлявся частково. У межах однієї лекції ми не будуємо величезний фреймворк — нам достатньо скелета, який показує стиль.
Для звіту заведемо просту модель результату. Ми вже знайомі з data class, тож використаємо його як «контейнер даних без зайвої філософії».
import java.io.File
data class CopyResult(
val from: File,
val to: File,
val ok: Boolean,
val message: String
)
Сформуємо рядковий звіт. Щоб не занурюватися в обʼємні теми форматування, зберемо звичайний текст із переносами рядків.
fun buildReport(results: List<CopyResult>): String {
val lines = results.joinToString(separator = "\n") { r ->
val status = if (r.ok) "OK" else "ERROR"
"${status}: ${r.from.name} -> ${r.to.name} (${r.message})"
}
return lines + "\n"
}
І тепер «міні-main», який показує сам принцип: отримали результати, зібрали текст, записали звіт через writeTextSafely. Тут ми не розгортаємо весь пайплайн паралельності заново, а лише показуємо, куди саме вставляється «безпечна публікація результату».
import kotlinx.coroutines.runBlocking
import java.io.File
fun main() = runBlocking {
val results = listOf(
CopyResult(File("a.bin"), File("out/a.bin"), ok = true, message = "скопійовано"),
CopyResult(File("b.bin"), File("out/b.bin"), ok = false, message = "не знайдено")
)
val reportText = buildReport(results)
writeTextSafely(File("out/report.txt"), reportText)
println("Звіт збережено") // Звіт збережено
}
Змістовна перемога: навіть якщо вас скасують під час запису звіту або щось упаде, ви не залишите користувачеві «половину звіту» під виглядом підсумкового документа.
4. Нюанси renameTo
У цьому місці новачки зазвичай питають: «А навіщо взагалі перевіряти renameTo, воно ж… перейменовує?» І тут хочеться відповісти максимально чесно: renameTo не обіцяє вам щастя — він повертає Boolean. Якщо повернув false, це не «дивно», це частина контракту. Тому ми й перетворюємо false на явну помилку: щоб не отримати «тихий провал» і загадковий результат.
Є кілька типових причин, чому перейменування може не спрацювати. Інколи цільовий файл уже існує, і перейменування не замінює його (тому вище ми інколи видаляємо target заздалегідь). Інколи файл усе ще відкритий потоком (тому .use {} такий важливий: він гарантує закриття ресурсу після завершення блоку). Kotlin якраз рекомендує ідіому .use() для закриття AutoCloseable-ресурсів, щоб не тримати потоки відкритими й не ламати операції з файлами.
Ще один практичний нюанс: якщо тимчасовий файл лежить «десь в іншому місці», перейменування може перетворитися на складнішу операцію на рівні ОС. Тому ми й тримаємо temp/part поруч із target: менше несподіванок, більше передбачуваності.
І насамкінець: навіть якщо ви зробили все правильно, усе одно може трапитися ситуація, коли cleanup не видалив .part (наприклад, файл заблокований). Це неприємно, але все одно краще, ніж лишити пошкоджений «фінальний» файл. У нашому контракті «зайвий .part» — це сміття, а «пошкоджений target» — це помилка даних.
5. Типові помилки під час роботи з temp/part і cleanup
Помилка №1: запис напряму в target і віра в «авось не перерветься».
Коли ви пишете одразу в підсумковий файл, ви фактично обіцяєте світові: «те, що лежить за цим шляхом, — готовий результат». Під час скасування або винятку обіцянка ламається, а далі починається найнеприємніша категорія багів: «дані виглядають валідними, але насправді ні».
Помилка №2: cleanup у catch, але не у finally.
Скасування корутини та багато неочікуваних збоїв можуть пройти повз ваш конкретний catch (або «прилетіти» не тим винятком, на який ви очікували). Якщо прибирання має відбуватися «в будь-якому разі», його місце — finally, бо він виконується за будь-якого виходу з блоку.
Помилка №3: ігнорувати результат renameTo і робити вигляд, що все вдалося.
Найпідступніший варіант: операція не опублікувалася, але ви не перевірили Boolean і продовжили програму, ніби нічого не сталося. Користувач бачить старий файл або взагалі відсутність файла, а ви впевнені, що «ми ж записали». Перевірка результату й перетворення false на явну помилку — це не параноя, а доросле життя.
Помилка №4: тимчасовий файл створюється не поруч із target.
Якщо temp лежить в іншому місці, ви збільшуєте шанс проблем із правами, різними пристроями/дисками й непередбачуваністю перейменування. «Поруч із target» — просте правило, яке різко зменшує кількість сюрпризів.
Помилка №5: забули закрити потік і дивуємося, чому не можна видалити або перейменувати файл.
Відкриті InputStream/OutputStream і Reader/Writer можуть утримувати файл зайнятим. Тому .use { ... } — не «гарний стиль», а практична необхідність: він закриває ресурс навіть у разі винятку, працюючи як гарантована дисципліна завершення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ