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 нужно создавать рядом с целевым файлом — в той же директории.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ