1. Чому перезапис CSV — це ризик, навіть коли ви впевнені в собі
Коли ми вперше робимо експорт, дуже хочеться написати щось на кшталт: «відкрив "tasks.csv", записав рядки, закрив файл — готово». Це здається логічним, аж поки одного дня не втрутиться реальність: закінчиться місце на диску, запис обірветься, процес уб’ють, ноутбук засне, антивірус вирішить «перевірити» файл у найцікавіший момент — і ваш CSV перетвориться на обрубок. Найгірше те, що такий обрубок часто виглядає «майже правильним», доки хтось не спробує імпортувати його назад.
Уявімо наївний експорт. Приклад спеціально короткий: він показує ідею, а не «правильний» підхід.
package main
import (
"os"
)
func main() {
f, _ := os.Create("tasks.csv") // помилки ми «героїчно» ігноруємо
_, _ = f.WriteString("id,title,done\n")
_, _ = f.WriteString("1,Buy milk,false\n")
_ = f.Close()
}
Проблема тут не лише в ігноруванні помилок, хоча це теж гріх рівня «я закомітив пароль». Головна проблема в тому, що ми пишемо просто в цільовий файл. Якщо запис обірветься посередині, старі дані вже буде втрачено, а нові ще не встигнуть з’явитися.
2. Протокол стійкого запису: спочатку готуємо, потім публікуємо
Якщо хочеться інженерної надійності, корисно мислити не так: «я записав файл», а так: «я провів транзакцію оновлення файла». Саме тут і з’являється протокол: ми пишемо результат в окремий тимчасовий файл, перевіряємо, що запис завершився без помилок, коректно закриваємо файл, а вже потім замінюємо старий файл новим — одним атомарним кроком, тобто перейменуванням.
Схематично це виглядає так — не як урок малювання, а як підказка для пам’яті:
flowchart TD
A["Є старий файл tasks.csv (або його немає)"] --> B["Створюємо temp-файл поруч: export-*.csv"]
B --> C["Пишемо CSV у temp через csv.Writer"]
C --> D["Flush() + перевірка w.Error()"]
D --> E["Close() temp-файла та перевірка помилки Close"]
E --> F["Публікуємо: rename(temp -> tasks.csv) / через backup"]
F --> G["Готово: або старий файл, або повністю новий"]
Ключова філософія тут проста: доки файл не «опубліковано», ми вважаємо його чернеткою, яку можна без жалю видалити. А «публікація» — це один крок, який або спрацював, або ні, без проміжного стану.
І ще один важливий нюанс: тимчасовий файл потрібно створювати в тій самій директорії, де лежить цільовий файл. Тоді os.Rename працює в межах однієї файлової системи, і ми маємо шанс на атомарність перейменування — у межах можливостей ОС.
3. Контрольні точки CSV: чому Write() ще не означає «записано»
У житті новачка настає момент, коли він дізнається: запис у файл — це не завжди «ось просто зараз байти на диску». CSV додає ще трохи веселощів: csv.Writer буферизує дані, і частина помилок може проявитися не в момент Write, а пізніше — на Flush().
Тому в CSV-експорті є особливий ритуал: після запису викликаємо Flush(), а потім обов’язково перевіряємо w.Error().
Це схоже на ситуацію, коли ви відправили лист («Write») і навіть закрили конверт, але на пошті вам кажуть: «ой, а марка ж фальшива» — і про це ви дізнаєтеся лише на касі («Flush/Error»).
Мінішаблон правильного завершення запису CSV:
package main
import (
"encoding/csv"
"fmt"
"os"
)
func main() {
f, err := os.Create("demo.csv")
if err != nil {
fmt.Println("create:", err)
return
}
w := csv.NewWriter(f)
_ = w.Write([]string{"id", "title"})
_ = w.Write([]string{"1", "Buy milk"})
w.Flush()
if err := w.Error(); err != nil {
fmt.Println("csv flush:", err)
}
}
Так, тут ще бракує коректного Close() і обробки помилок Write, але сама ідея контрольної точки вже добре видно: запис CSV не можна вважати успішним, доки не виконано Flush() та w.Error().
4. Завершення запису: Close() і backup
Close() — це не «формальність», а частина контракту
Коли ви закриваєте файл, ви ніби кажете операційній системі: «я завершив роботу, приберіть хвости». Іноді, хоч і нечасто, саме в момент Close() спливають помилки: наприклад, накопичені помилки запису, проблеми із синхронізацією буферів або особливості мережевих файлових систем. Тому «стійкий експорт» зазвичай передбачає перевірку помилки Close().
defer тут дуже допомагає, бо його призначення саме таке: закрити ресурс під час виходу. Це типовий Go-патерн.
Але є нюанс: якщо нам потрібно перевірити помилку Close(), то простий defer f.Close() перетворюється на «закрив без перевірки». Тому ми часто використовуємо акуратну обгортку.
Один із найпрактичніших патернів — з іменованим err — трапляється в реальному Go-коді:
package storage
import "os"
func closeCarefully(f *os.File, err *error) {
if cerr := f.Close(); cerr != nil && *err == nil {
*err = cerr
}
}
А викликаємо так. Зверніть увагу: ми не «обговорюємо вказівники», а просто передаємо &err як «куди записати» — це в нас уже було в курсі:
package storage
import (
"os"
)
func doSomething() (err error) {
f, err := os.Create("x.txt")
if err != nil {
return err
}
defer closeCarefully(f, &err)
// ... пишемо щось у f ...
return nil
}
Важливо не перетворювати цю техніку на магію: вона потрібна рівно тому, що Close() може повернути помилку, а ми хочемо її не втратити. І так, перевіряти помилки — нормально: у Go це буквально частина культури, бо «errors are values».
Backup .bak: додаткова підстраховка на етапі публікації
Backup — це не заміна temp → rename, а додатковий ремінь безпеки. Протокол temp → rename захищає нас від обрубка файла, але backup допомагає, якщо щось піде не так на етапі публікації або якщо нам потрібно вручну відновити попередню версію.
Правильна думка тут така: backup має сенс робити не з temp-файла, а з поточного цільового файла, безпосередньо перед публікацією. Тобто ми ніби кажемо: «зараз я заміню "tasks.csv", але стару версію притримаю як "tasks.csv.bak"».
Мініверсія функції «замінити файл із backup» — спрощено, але читабельно:
package storage
import (
"fmt"
"os"
)
func replaceWithBackup(dst, src string) error {
bak := dst + ".bak"
_ = os.Remove(bak) // якщо був старий .bak — прибираємо
_ = os.Rename(dst, bak) // якщо dst не було — окей, буде помилка, але нам не критично
if err := os.Rename(src, dst); err != nil {
_ = os.Rename(bak, dst) // спроба відкату
return fmt.Errorf("publish: %w", err)
}
return nil
}
Тут є свідома «зухвалість»: os.Rename(dst, bak) може не спрацювати, якщо dst не існує. У цій версії ми ставимося до цього спокійно, бо backup — це додаткова підстраховка. У суворішому варіанті можна розрізняти «файл не існує» та «реальну помилку», але зараз наша мета — саме протокол.
5. Приклад: стійкий експорт задач у CSV
Тепер зберемо все в цілісний рецепт для нашого навчального застосунку зі списком задач. Нехай у нас уже є тип:
package model
type Task struct {
ID int
Title string
Done bool
}
Спочатку зробимо маленьку функцію, яка пише CSV у будь-який io.Writer. Це важливо: так ми зможемо писати і у файл, і в буфер, і в тести — одним і тим самим кодом. Ідея io.Writer у нас уже була раніше.
Кодування задач у CSV та контрольна точка Flush()/Error
Сенс тут простий: ми хочемо один раз реалізувати формат CSV — заголовок, порядок колонок, порядок значень — а потім перевикористовувати його і для «експорту у файл», і для «експорту в пам’ять», і навіть для тестів. Тому ми не відкриваємо файл усередині цієї функції — ми просто пишемо туди, куди нам дали.
package csvio
import (
"encoding/csv"
"fmt"
"io"
"strconv"
"example.com/todo/internal/model"
)
func EncodeTasks(w io.Writer, tasks []model.Task) error {
cw := csv.NewWriter(w)
if err := cw.Write([]string{"id", "title", "done"}); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, t := range tasks {
rec := []string{strconv.Itoa(t.ID), t.Title, strconv.FormatBool(t.Done)}
if err := cw.Write(rec); err != nil {
return fmt.Errorf("write record: %w", err)
}
}
cw.Flush()
if err := cw.Error(); err != nil {
return fmt.Errorf("flush csv: %w", err)
}
return nil
}
Зверніть увагу на %w: ми не просто пишемо «помилка!», а загортаємо причину так, щоб код, який викликає, міг зрозуміти, що саме зламалося. У Go це стандартна практика, і %w — офіційний механізм wrapping.
Стійкий запис: temp поруч, запис, Close(), публікація
Тепер робимо функцію, яка застосовує файловий протокол. Тут уже з’являються os.CreateTemp, filepath.Dir, cleanup та публікація через rename/backup.
package storage
import (
"fmt"
"os"
"path/filepath"
)
func ExportCSVAtomically(path string, write func(f *os.File) error) (err error) {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, "export-*.csv")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close() // best-effort
if cleanup {
_ = os.Remove(tmpName)
}
}()
if err := write(tmp); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
if err := replaceWithBackup(path, tmpName); err != nil {
return err
}
cleanup = false
return nil
}
Тут є дві важливі думки.
Перша: ми спеціально приймаємо write func(f *os.File) error, щоб ця функція була універсальною. Сьогодні ми пишемо CSV, завтра — JSON, післязавтра — якийсь бінарний формат, а протокол надійного запису лишиться тим самим.
Друга: ми закриваємо файл рівно один раз. Ми не робимо «і defer tmp.Close(), і потім руками tmp.Close() без контролю», бо це типова пастка: можна отримати дивні ефекти, а головне — перестати розуміти, де саме ви перевіряєте помилку закриття.
Поєднуємо: експорт задач у "tasks.csv"
Тепер поєднуємо ці дві частини: як писати CSV і як безпечно записувати файл.
package storage
import (
"os"
"example.com/todo/internal/csvio"
"example.com/todo/internal/model"
)
func ExportTasksCSV(path string, tasks []model.Task) error {
return ExportCSVAtomically(path, func(f *os.File) error {
return csvio.EncodeTasks(f, tasks)
})
}
І все: ваш експорт став інженерним. Він або залишить старий файл без змін, або замінить його повністю записаною новою версією.
Пам’ятка: де саме в експорті можуть з’явитися помилки
Іноді новачкові здається, що «помилка запису» — це одна точка. Насправді вона може проявитися в кількох місцях, і протокол стійкості якраз змушує нас пройти всі контрольні кроки.
| Крок | Що робимо | Де часто помиляються | Як правильно завершити крок |
|---|---|---|---|
| 1 | Створюємо temp-файл | temp створюють не поруч із цільовим | |
| 2 | Пишемо записи CSV | ігнорують помилку запису | |
| 3 | Завершуємо CSV | забувають Flush() або w.Error() | |
| 4 | Закриваємо файл | вважають Close() «формальністю» | |
| 5 | Публікуємо | пишуть одразу в target | (часто через backup) |
| 6 | Прибираємо сміття | temp лишається при помилках | при |
6. Імпорт: спочатку перевіряємо дані, потім чіпаємо диск
З імпортом є одна моральна пастка: хочеться читати CSV і «по ходу» оновлювати файл бази чи сховища. Але імпорт тим і підступний, що він часто частково зіпсований: половина рядків нормальна, половина — ні. Якщо ви почнете змінювати сховище в процесі, отримаєте мутанта: дані наполовину нові, наполовину старі, а потім ще й користувач запитає: «а що сталося?»
Правильний підхід звучить нудно, зате працює: спочатку ми читаємо й перевіряємо все, формуємо результат у пам’яті (або хоча б до логічної контрольної точки), і лише якщо підсумок нас влаштовує — публікуємо його через той самий протокол.
Псевдоскелет — не повний імпорт, а каркас правильного керування побічними ефектами:
package app
import (
"fmt"
"example.com/todo/internal/storage"
)
func ImportAndSave(csvPath, storePath string) error {
tasks, err := ReadAndValidateCSV(csvPath) // тут можуть бути errors.Join(...)
if err != nil {
return fmt.Errorf("import: %w", err)
}
if err := storage.ExportTasksCSV(storePath, tasks); err != nil {
return fmt.Errorf("save: %w", err)
}
return nil
}
Сенс цього коду в тому, що «брудна частина» — поганий CSV — не торкається ваших цінних даних на диску. Спочатку ми отримуємо нормальний результат, а вже потім зберігаємо його стійко.
7. Типові помилки
Помилка №1: писати CSV прямо в цільовий файл і сподіватися, що «ну не впаде ж».
Це працює рівно до першого збою, після чого ви отримуєте обрізаний файл і втрачені старі дані. Лікується тільки протоколом: temp-файл поруч, успішна фіналізація, публікація через rename.
Помилка №2: забути про Flush() та w.Error() у csv.Writer.
CSV-писач буферизує вивід, тому помилка може проявитися «в кінці», а не в момент запису рядків. Якщо пропустити Flush/Error, ви можете радісно «успішно експортувати» файл, який насправді не був коректно дописаний.
Помилка №3: не перевіряти помилку Close() і думати, що закриття файла не може зламатися.
У більшості випадків Close() справді проходить тихо, але стійкий протокол якраз і потрібен на випадок, якщо ні. Якщо ви не перевіряєте Close, інколи втрачаєте єдиний сигнал про те, що запис завершився некоректно.
Помилка №4: влаштувати подвійний Close: і defer f.Close(), і потім f.Close() руками, а потім ще раз у defer.
Ця помилка не завжди призводить до падіння, але майже завжди створює хаос у голові: незрозуміло, де саме ви перевірили помилку закриття, а де просто «закрили для галочки». Краще обрати одну стратегію: або defer без перевірки, або явний Close() із перевіркою та акуратним cleanup.
Помилка №5: створювати temp-файл у випадковій директорії, а потім намагатися зробити os.Rename у робочу директорію.
На різних файлових системах Rename може не бути атомарним або взагалі не спрацювати. Тому temp потрібно створювати поруч із цільовим файлом — у тій самій директорії.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ