JavaRush /Курси /Go SELF /Чому небезпечне просте перезаписування файла

Чому небезпечне просте перезаписування файла

Go SELF
Рівень 42 , Лекція 0
Відкрита

1. Файл — не змінна: чому запис складніший, ніж здається

Коли ви вперше пишете os.WriteFile("data.txt", data, 0644), виникає приємне відчуття: «Усе, я переміг із збереженням даних». І це правда… рівно до першого випадку, коли програма падає посеред запису, раптово закінчується місце на диску або ви забуваєте Flush(). Тоді зʼясовується, що файл — не змінна в памʼяті, а обʼєкт, навколо якого працюють ОС, драйвери, буфери та інші неприємні сюрпризи.

Уявіть, що ви переписуєте важливий конспект ручкою. Якщо спочатку стерти старий текст, а потім писати новий, і в цей момент закінчаться чорнила, у вас не буде ні старого конспекту, ні нового. Залишиться половина нового тексту й половина порожнечі. Приблизно так само працює просте перезаписування файла: воно часто починається з обрізання (truncate) старого вмісту, а далі вже як пощастить.

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

2. Інваріанти надійного запису: що ми хочемо гарантувати

Надійність починається не з os.CreateTemp, не з Rename і навіть не з фрази «давайте додамо ще один defer». Надійність починається з чесної відповіді: який результат ми вважаємо правильним і які збої ми зобовʼязані пережити без катастрофи. Такі вимоги зручно формулювати як інваріанти — властивості, які мають виконуватися завжди (у межах нашого протоколу).

Сформулюємо базовий набір інваріантів для «записати нову версію даних у файл»:

Ситуація Що має бути істинним (інваріант) Чому це важливо
Операція запису завершилася успішно Цільовий файл містить нову версію даних Інакше «успіх» не має сенсу
Операція запису перервалася через збій (краш, kill, вимкнення, диск переповнився) Цільовий файл містить або стару версію, або нову, але не «напівверсію» Користувач і програма не повинні читати сміття
Операція завершилася (успіх/помилка) У робочому каталозі не лишається «службового сміття» (тимчасових файлів) Щоб каталог не перетворювався на звалище
У разі помилки Помилка зрозуміла: у ній видно, на якому кроці зламалося Інакше налагоджувати неможливо

Можна уявити це як просту діаграму станів. Нам важливо, щоб після збою ми не потрапляли в стан «пошкоджений файл».

stateDiagram-v2
    [*] --> Старий
    Старий: стара версія файла

    Старий --> Запис: почали оновлення
    Запис --> Новий: запис завершився успішно
    Запис --> Старий: збій, але стара версія збереглася
    Запис --> Новий: збій, але нова версія встигла повністю стати видимою

    Запис --> Пошкоджений: збій залишив напівверсію (НЕ МОЖНА)
    Пошкоджений --> [*]

Стан Corrupted — це якраз те, що дуже легко отримати за простого перезаписування. І далі в лекції ми розберемо, чому.

3. Де ламається просте перезаписування: truncate як точка катастрофи

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

Погляньте на такий код:

package main

import (
	"fmt"
	"os"
)

func main() {
	err := os.WriteFile("notes.txt", []byte("нова версія\n"), 0644)
	if err != nil {
		fmt.Println("write failed:", err)
	}
}

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

Більш «ручний» варіант підкреслює проблему ще наочніше:

package main

import (
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("notes.txt") // створює АБО обрізає наявний файл
	if err != nil {
		fmt.Println("create failed:", err)
		return
	}
	defer f.Close()

	_, _ = f.WriteString("нова версія\n")
}

Важлива думка: os.Create — це не «відкрити файл для запису». Це «створити файл, а якщо він є — зробити його довжину нульовою (truncate) і повернути дескриптор». Тобто точка ризику виникає на самому початку, ще до запису даних.

Іноді розробник думає: «Ну якщо я пишу швидко, шанс збою малий». Це саме той випадок, коли «малий шанс» перетворюється на «дуже великий» у продакшені: бо продакшен — це місце, де все рано чи пізно ламається, просто з принципу.

4. Протокол запису: Write, Flush і Close

Частковий запис: чому Write повертає (n, err)

Багато новачків дивляться на Write і бачать там «дивну пару» — кількість записаних байтів і помилку. Виникає бажання сказати: «Я ж усе одно перевіряю err, навіщо мені n?» А потім трапляється ситуація, коли err == nil, але записано менше байтів, ніж ви очікували. Це не найчастіший сценарій на звичайному диску, але сам контракт API каже: так буває, отже в критичних місцях це потрібно враховувати.

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

package main

import (
	"fmt"
	"os"
)

func writeAll(f *os.File, data []byte) error {
	n, err := f.Write(data)
	if err != nil {
		return fmt.Errorf("write: %w", err)
	}
	if n != len(data) {
		return fmt.Errorf("write: partial write %d/%d", n, len(data))
	}
	return nil
}

Зверніть увагу: тут немає магії. Ми просто ставимося до Write як до чесного контракту. Якщо функція каже «я міг записати не все» — значить, ми зобовʼязані це перевірити там, де «не все» ламає сенс операції.

І тепер уявіть наївний запис: ви робите Write, ігноруєте n, програма завершується «успішно», а потім завантаження даних падає, бо файл виявився обрізаним у випадковому місці. Найнеприємніше, що баг виглядає як «іноді файл битий», тобто як ідеальна пастка для налагодження.

Буферизація: «я записав» ≠ «дані у файлі»

Буферизація — це як доставка їжі: ви можете «оформити замовлення» (викликати WriteString), але це ще не означає, що страва вже на вашому столі, тобто дані на диску. bufio.Writer накопичує дані в памʼяті й віддає їх у файл порціями. Це пришвидшує роботу, але додає обовʼязковий крок — фіналізацію через Flush(), інакше дані можуть лишитися тільки в RAM.

Найтиповіший сценарій помилки виглядає так: код «пише», помилок немає, але файл порожній або неповний.

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("out.txt")
	if err != nil {
		fmt.Println("create:", err)
		return
	}
	defer f.Close()

	w := bufio.NewWriter(f)
	_, _ = w.WriteString("line 1\n")
	_, _ = w.WriteString("line 2\n")

	// Помилка: забули Flush()
}

Цей приклад спеціально «шкідливий»: він показує, що «помилок немає» ще не означає «результат правильний». Якщо ви хочете, щоб дані гарантовано пройшли через буфер у файл, Flush() має стати частиною протоколу:

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("out.txt")
	if err != nil {
		fmt.Println("create:", err)
		return
	}
	defer f.Close()

	w := bufio.NewWriter(f)
	_, _ = w.WriteString("ok\n")

	if err := w.Flush(); err != nil {
		fmt.Println("flush:", err)
	}
}

І ще один важливий момент: Flush() теж може повернути помилку. Це не формальність, а реальна точка, де може спливти проблема запису, наприклад якщо диск переповнений. Тому надійний запис — це завжди перевірка помилок на кожному кроці протоколу, а не лише на першому рядку.

Close() — не формальність: помилка може прийти під час закриття

Дуже хочеться думати, що Close() — це як «поставити табуретку на місце»: завжди працює і не може бути джерелом проблем. Але у сценаріях запису Close() інколи є моментом, коли система намагається «доробити хвіст роботи»: скинути внутрішні буфери, завершити операцію введення-виведення, зафіксувати стан. Тому у відповідальних місцях Close() — це частина протоколу, і цю помилку потрібно враховувати.

У Go часто пишуть так:

f, err := os.Create(path)
if err != nil {
	return err
}
defer f.Close()

Для читання це зазвичай нормально: якщо ми читаємо, а Close() раптом поверне помилку, найчастіше це не критично для результату, хоча й тут усе залежить від ситуації. Але для запису інколи роблять інакше: закривають файл явно і перевіряють помилку. Це виглядає трохи довше, зате чесніше.

package main

import (
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("data.txt")
	if err != nil {
		fmt.Println("create:", err)
		return
	}

	if _, err := f.WriteString("payload\n"); err != nil {
		_ = f.Close()
		fmt.Println("write:", err)
		return
	}

	if err := f.Close(); err != nil {
		fmt.Println("close:", err)
		return
	}
}

Так, це не найелегантніший код у світі. Зате він підкреслює думку лекції: надійність — це протокол, а не один виклик. У протоколу є кроки, і кожен крок може завершитися помилкою.

Тут корисно згадати загальну філософію Go: помилки потрібно перевіряти явно, бо вони — звичайні значення, і в цьому сила мови.

5. Контракт зберігання в застосунку: Save — це зобовʼязання

Коли ми робимо навчальний застосунок, нехай навіть найпростіший локальний менеджер завдань, зазвичай є функція на кшталт «зберегти список завдань у файл». І на ранніх етапах хочеться зробити це максимально просто: взяли дані, виконали os.WriteFile — готово. Але щойно файл стає «джерелом правди» (тобто без нього користувач втрачає дані), така простота починає коштувати надто дорого.

Зафіксуймо мінімальний API для шару зберігання: функції Load і Save, які нічого не друкують, а повертають помилки. Це важливий стиль: зберігання не повинно займатися UX; воно повинно займатися коректністю.

package storage

import (
	"fmt"
	"os"
)

func SaveText(path string, data string) error {
	if err := os.WriteFile(path, []byte(data), 0644); err != nil {
		return fmt.Errorf("save file %s: %w", path, err)
	}
	return nil
}

Код навмисно «наївний»: він показує стартову точку. З погляду зручності — ідеально. З погляду наших інваріантів — небезпечно, бо перезаписування не гарантує «або старе, або нове».

І ось тепер ми можемо сформулювати вимоги до наступної версії SaveText (не реалізовуючи її тут детально). Нам потрібно, щоб після збою файл не перетворювався на напівверсію. Нам потрібна точка «commit», після якої нова версія вважається опублікованою. Нам потрібно, щоб сміття не лишалося поруч. І нам потрібно, щоб помилка підказувала, на якому кроці все зламалося.

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

6. Типові помилки під час простого перезаписування файла

Помилка №1: вважати, що os.WriteFile — це завжди безпечно, бо «одна функція, отже атомарно».
У os.WriteFile справді зручний інтерфейс, але зручність не дорівнює надійності. Насправді перезаписування часто означає, що старий файл буде замінено або обрізано, і в разі збою ви можете залишитися з «напівверсією». Якщо файл важливий, спочатку сформулюйте інваріанти та перевірте, чи задовольняє їх обраний підхід.

Помилка №2: забувати, що запис — це кілька кроків, і помилка може прилетіти не там, де ви її очікуєте.
Новачки перевіряють помилку на Write, а Flush() і Close() сприймають як «завжди ок». Потім зʼявляється загадковий баг: «іноді файл порожній» або «іноді файл обрізаний». Правильна звичка — вважати фіналізацію частиною протоколу: якщо є буфер, потрібен Flush(); якщо є файл, Close() може бути значущим кроком.

Помилка №3: ігнорувати n із Write, бо «ну там же помилка окремо».
Контракт Write у Go чесно каже: можливий частковий запис. Якщо ви ігноруєте n, ви позбавляєте себе можливості виявити пошкодження даних одразу. Інколи краще повернути осмислену помилку «partial write 10/100», ніж потім розбиратися, чому парсер файла лається у випадковому місці.

Помилка №4: повертати помилку «як є», без контексту операції.
Помилка на кшталт «permission denied» без указання, що ви робили і з яким файлом, перетворює налагодження на вгадування. Обгортайте помилки через fmt.Errorf("save file %s: %w", path, err). У Go це базова практика: помилка має нести контекст, інакше вона майже марна.

Помилка №5: змішувати «збереження» та «виведення користувачеві» в одному місці.
Якщо ваш Save друкує в stdout/stderr, його важко тестувати й важко перевикористовувати. Нехай Save повертає error, а рішення «друкувати/логувати/падати» приймає зовнішній шар (CLI/сервер). Тоді й інваріанти перевіряти легше, і поведінка стає передбачуваною.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ