JavaRush /Курси /Kotlin SELF /Часткові результати та cleanup: temp‑файли, перейменуванн...

Часткові результати та cleanup: temp‑файли, перейменування, прибирання

Kotlin SELF
Рівень 56 , Лекція 4
Відкрита

1. Частковий файл і публікація результату

Якби файли були «чесними», у разі проблеми вони просто зникали б. Та реальність суворіша: під час помилки, вимкнення живлення, переповнення диска або скасування корутини у вас може лишитися файл, який існує, має розмір більший за нуль, інколи навіть відкривається… і водночас містить лише частину даних. Такий «півфайл» особливо небезпечний тим, що виглядає як нормальний результат роботи програми.

Уявіть два сценарії. У першому ви генеруєте звіт report.txt і записуєте його напряму. Користувач бачить файл, відкриває його — а там обрізаний текст, але без явної помилки. У другому ви копіюєте великий файл і перериваєте процес: якщо ви писали одразу в підсумковий шлях, у вас зʼявиться «готовий» файл, який насправді пошкоджений. І ось тут починаються веселі квести рівня «чому в мене інколи ламається архів».

Майже завжди краще мати правило: результат вважається опублікованим лише після успішного завершення операції, а до цього моменту він має жити в «чернетці» (temp/part). Саме такий механізм ми й будемо будувати.

Стратегія публікації: temp → rename → cleanup

У цій темі важливо навести лад у термінах — вони справді допомагають мислити. Є дія «зробити роботу» (записати байти/текст), і є дія «опублікувати результат» (зробити так, щоб користувач/інша програма побачили фінальний файл). Якщо змішати ці дії в одну, ви й отримаєте часткові результати, які виглядають «як справжні».

Стратегія, яку ми застосовуватимемо, проста на рівні ідеї:

  1. створюємо тимчасовий файл поруч із цільовим (.tmp/.part);
  2. записуємо/копіюємо лише в нього;
  3. якщо все успішно — виконуємо перейменування у цільове імʼя;
  4. якщо щось пішло не так — у 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 { ... } — не «гарний стиль», а практична необхідність: він закриває ресурс навіть у разі винятку, працюючи як гарантована дисципліна завершення.

1
Опитування
Асинхронний I/O, рівень 56, лекція 4
Недоступний
Асинхронний I/O
Асинхронний I/O
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ