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(...)

Ниже — маленький helper, который мы будем использовать в примерах. Он специально «тихий»: не падает, если удалить не получилось.

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 прошла, мы пытаемся сделать publish‑шаг (rename). И даже если publish не удался, у нас остаётся 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 = "copied"),
        CopyResult(File("b.bin"), File("out/b.bin"), ok = false, message = "not found")
    )

    val reportText = buildReport(results)
    writeTextSafely(File("out/report.txt"), reportText)

    println("Report saved") // Report saved
}

Смысловая победа: даже если нас отменят во время записи отчёта или что-то упадёт, мы не оставим пользователю «половину отчёта» под видом итогового документа.

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
Задача
Kotlin SELF, 56 уровень, 4 лекция
Недоступна
Тихая уборка
Тихая уборка
1
Задача
Kotlin SELF, 56 уровень, 4 лекция
Недоступна
Публикация отчёта
Публикация отчёта
1
Задача
Kotlin SELF, 56 уровень, 4 лекция
Недоступна
Черновик копии
Черновик копии
1
Задача
Kotlin SELF, 56 уровень, 4 лекция
Недоступна
Обновление без потерь
Обновление без потерь
1
Опрос
Асинхронный I/O, 56 уровень, 4 лекция
Недоступен
Асинхронный I/O
Асинхронный I/O
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ