JavaRush /Курси /Go SELF /Стійкий імпорт і експорт CSV: Flush/Error, Close()

Стійкий імпорт і експорт CSV: Flush/Error, Close()

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

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 створюють не поруч із цільовим
os.CreateTemp(filepath.Dir(path), ...)
2 Пишемо записи CSV ігнорують помилку запису
if err := w.Write(...); err != nil { ... }
3 Завершуємо CSV забувають Flush() або w.Error()
w.Flush(); if err := w.Error(); err != nil { ... }
4 Закриваємо файл вважають Close() «формальністю»
if err := f.Close(); err != nil { ... }
5 Публікуємо пишуть одразу в target
os.Rename(temp, target)
(часто через backup)
6 Прибираємо сміття temp лишається при помилках
defer os.Remove(temp)
при
cleanup=true

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 потрібно створювати поруч із цільовим файлом — у тій самій директорії.

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