JavaRush /Курсы /Go SELF /encoding/csv — чтение и запись через Reader/Writer

encoding/csv — чтение и запись через Reader/Writer

Go SELF
47 уровень , 1 лекция
Открыта

1. Зачем encoding/csv, а не strings.Split

CSV выглядит настолько простым, что мозг новичка (и иногда даже опытного разработчика в пятницу вечером) предлагает: «да там же просто строки через \n, а поля через , — сейчас быстро сделаю strings.Split». Это прекрасная мысль… пока вы не встречаете запятую внутри кавычек, перевод строки внутри поля, или кавычку " в самом значении. CSV — формат с правилами, а не “текст с запятыми”.

В Go правильный путь почти всегда один: пакет encoding/csv. Он уже знает правила CSV, умеет корректно обрабатывать кавычки и экранирование, и самое главное — работает поверх io.Reader/io.Writer, а значит одинаково хорошо читает из файла, строки, сети, буфера (и даже из «потока, который вы ещё не придумали»).

Чтобы не быть голословными, вот мини-антипример. Он компилируется, выглядит логично, но ломается на реальном CSV:

package main

import (
	"fmt"
	"strings"
)

func main() {
	line := `1,"Buy milk, please",false`
	parts := strings.Split(line, ",")
	fmt.Println(parts) // [1 "Buy milk  please" false]  (уехало!)
}

Да, кавычки в результате ещё и «прилипнут», а поле развалится на две части. Короче говоря: CSV парсим CSV-парсером — не потому что «так модно», а потому что мы хотим спать спокойно.

2. csv.Reader: чтение CSV из io.Reader

Когда вы впервые видите csv.NewReader(r), возникает ощущение: «а почему не csv.ReadFile("x.csv")?». И вот тут Go довольно последователен: почти всё I/O строится вокруг потоков. Это даёт гибкость: один и тот же код работает с файлами, строками, HTTP-ответами и тестовыми буферами. Вы как будто пишете «функцию-пылесос»: ей всё равно, откуда пыль, главное — чтобы была труба.

csv.Reader читает CSV как последовательность записей (records), где каждая запись — это []string (поля). Главные методы:

  • Read() — прочитать одну запись.
  • ReadAll() — прочитать все записи сразу.

Самый короткий пример: ReadAll()

ReadAll() удобен, когда CSV небольшой и вы точно не боитесь памяти. Для учебных задач и «экспорт/импорт задач на 200 строк» это нормально. Для CSV на 4 ГБ — уже нет (и ваш ноутбук начнёт подозревать вас в предательстве).

package main

import (
	"encoding/csv"
	"fmt"
	"strings"
)

func main() {
	data := "id,title,done\n1,Buy milk,false\n2,Learn Go,true\n"

	r := csv.NewReader(strings.NewReader(data))
	records, err := r.ReadAll()
	if err != nil {
		fmt.Println("read all:", err)
		return
	}

	fmt.Println(records) // [[id title done] [1 Buy milk false] [2 Learn Go true]]
}

Обратите внимание: результат — это [][]string. То есть «таблица строк». Дальше ваша задача — превратить строки в ваши типы (например, int и bool). Но превращение и валидация — это отдельная логика; сегодня держим фокус именно на Reader/Writer.

Потоковый режим: Read() + цикл до io.EOF

Потоковое чтение — это когда вы читаете «по одной записи» в цикле. Это чуть более многословно, но зато устойчиво по памяти и хорошо подходит для больших файлов.

Ключевой момент: io.EOF — это нормальное завершение чтения, а не «ошибка формата». На EOF мы заканчиваем цикл.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strings"
)

func main() {
	data := "a,b\nc,d\n"
	r := csv.NewReader(strings.NewReader(data))

	for {
		rec, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read:", err)
			return
		}
		fmt.Println("record:", rec) // record: [a b] потом record: [c d]
	}
}

Этот шаблон «Read()EOFbreak» вы потом будете встречать много раз в Go. Он простой и предсказуемый, как табуретка.

Настройки csv.Reader: Comma, TrimLeadingSpace, FieldsPerRecord

Реальный CSV часто отличается от «идеального» из примеров. Поэтому у csv.Reader есть настройки. Их смысл важнее, чем запоминание названий.

Разделитель: Comma

Да, поле называется Comma, даже если вы используете ;. Логика тут простая: исторически CSV — “comma-separated values”, а потом люди решили «а давайте в некоторых странах запятая будет десятичным разделителем» — и понеслось.

package main

import (
	"encoding/csv"
	"fmt"
	"strings"
)

func main() {
	data := "id;title;done\n1;Buy milk;false\n"
	r := csv.NewReader(strings.NewReader(data))
	r.Comma = ';'

	rec, _ := r.Read()
	fmt.Println(rec) // [id title done]
}

Пробелы: TrimLeadingSpace

Иногда данные выглядят так: 1, Buy milk, false. И пробел после запятой не является частью значения, просто кто-то «делал красиво». TrimLeadingSpace = true может помочь (но это именно политика: иногда пробел значим).

Количество полей: FieldsPerRecord

Очень полезная штука: когда вы ожидаете, что в каждой строке будет, скажем, 3 поля, можно сделать так, чтобы парсер ругался, если вдруг приехало 2 или 4. Это ранняя сигнализация «CSV поехал».

Идея такая: после того как вы прочитали заголовок (header), вы можете выставить FieldsPerRecord = len(header) — тогда любая кривая строка всплывёт сразу, а не где-нибудь в глубине вашего кода.

3. csv.Writer: запись CSV в io.Writer

Записывать CSV «ручной склейкой строк» — примерно как чинить часы молотком: иногда показывает время, но лучше не смотреть слишком внимательно. У CSV есть правила кавычек и экранирования. csv.Writer делает это за вас, и это главный смысл его существования.

Минимальная запись CSV в буфер

В этом примере мы пишем CSV в bytes.Buffer, а потом печатаем результат. Это удобно для тестов и для понимания, что вообще уезжает «наружу».

package main

import (
	"bytes"
	"encoding/csv"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	w := csv.NewWriter(&buf)

	_ = w.Write([]string{"id", "title", "done"})
	_ = w.Write([]string{"1", "Buy milk", "false"})

	w.Flush()
	if err := w.Error(); err != nil {
		fmt.Println("csv error:", err)
		return
	}

	fmt.Print(buf.String())
	// id,title,done
	// 1,Buy milk,false
}

Тут есть важное правило (его лучше прямо мысленно выгравировать): после записи всегда делаем Flush() и проверяем w.Error().

Почему так? Потому что запись может буферизоваться и ошибка может проявиться не сразу. Это типичный паттерн для буферизированных writer’ов в Go: «пишем много раз → проверяем один раз на финализации». Похожую мысль вы можете встретить и в других местах стандартной библиотеки.

CSV сам экранирует кавычки и запятые

Проверим на поле, где есть кавычки и запятые. Вручную это собирать — гарантированно ошибиться хотя бы один раз в жизни. А csv.Writer спокойно справляется.

package main

import (
	"bytes"
	"encoding/csv"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	w := csv.NewWriter(&buf)

	_ = w.Write([]string{"note"})
	_ = w.Write([]string{`He said "hi", then left`})

	w.Flush()
	_ = w.Error()

	fmt.Print(buf.String())
	// note
	// "He said ""hi"", then left"
}

Обратите внимание: кавычка " внутри значения превращается в "" — это стандартное CSV-экранирование. А всё поле берётся в кавычки целиком, потому что внутри есть и ", и ,.

Настройки csv.Writer: Comma и UseCRLF

У writer’а тоже есть настройки.

Comma — тот же смысл: какой разделитель использовать при записи.

UseCRLF — писать строки с \r\n вместо \n. Иногда это нужно для совместимости с некоторыми системами, которые ожидают «виндовый» перенос. Чаще всего в современных пайплайнах хватает \n, но полезно знать, что такая ручка есть.

Про Flush() и почему он не «необязательная косметика»

Очень частая мысль новичка: «Ну я же уже делал Write, значит оно записалось». В реальности многие writer’ы буферизуют данные: копят их в памяти, чтобы писать порциями. Это быстрее и удобнее для системы. Цена — необходимость финализации.

csv.Writer буферизует вывод, поэтому правильный протокол выглядит так: «делаем много Write → вызываем Flush → проверяем ошибку». Этот стиль похож на то, как в стандартной библиотеке устроены буферизированные writer’ы: ошибка может проявиться при финальном сбросе буфера.

Если Flush() забыть, вы можете получить файл, в котором «как будто чего-то не хватает», и это один из самых раздражающих багов: программа вроде бы “успешно отработала”, а данные исчезли.

4. Импорт и экспорт задач через CSV

Сейчас мы соберём всё в небольшой кусок кода. Представим, что у нас уже есть простое приложение задач (условный todo), и мы хотим экспортировать задачи в CSV и импортировать обратно.

Мы не делаем здесь «идеальный промышленный импорт» с отчётами и кучей проверок — это отдельная история. Сейчас цель проще: научиться подключать encoding/csv как транспорт, а не как головоломку.

Для начала определим модель задачи:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Экспорт: WriteTasksCSV(w io.Writer, tasks []Task) error

Смысл функции: она не знает, куда пишет (в файл, в сеть, в буфер), ей дают io.Writer. Это очень по-гошному.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strconv"
)

func WriteTasksCSV(out io.Writer, tasks []Task) error {
	w := csv.NewWriter(out)

	if err := w.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 := w.Write(rec); err != nil {
			return fmt.Errorf("write task id=%d: %w", t.ID, err)
		}
	}

	w.Flush()
	if err := w.Error(); err != nil {
		return fmt.Errorf("flush csv: %w", err)
	}
	return nil
}

Здесь мы оборачиваем ошибки через %w, чтобы не терять причину и при этом добавлять контекст “где именно упало”.

И снова видим важный ритуал: Flush()w.Error().

Импорт: ReadTasksCSV(r io.Reader) ([]Task, error)

Теперь читаем CSV и получаем []Task. Мы сделаем версию, которая ожидает заголовок id,title,done и дальше читает строки.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strconv"
)

func ReadTasksCSV(in io.Reader) ([]Task, error) {
	r := csv.NewReader(in)

	header, err := r.Read()
	if err != nil {
		return nil, fmt.Errorf("read header: %w", err)
	}
	_ = header // заголовок мы пока не валидируем глубоко

	var tasks []Task
	for {
		rec, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("read record: %w", err)
		}

		id, err := strconv.Atoi(rec[0])
		if err != nil {
			return nil, fmt.Errorf("parse id %q: %w", rec[0], err)
		}
		done, err := strconv.ParseBool(rec[2])
		if err != nil {
			return nil, fmt.Errorf("parse done %q: %w", rec[2], err)
		}

		tasks = append(tasks, Task{ID: id, Title: rec[1], Done: done})
	}
	return tasks, nil
}

Да, здесь есть уязвимость: мы не проверяем длину rec, и при кривой строке получим панику по индексу. В этой лекции я оставляю это как «наглядный скелет», а не как «промышленный импорт, который переживёт апокалипсис». Но даже в таком виде видно главное: CSV-парсинг делает csv.Reader, а преобразование типов и базовые проверки делаем мы.

Быстрый “проводной” пример: записали → прочитали обратно

Чтобы почувствовать, что всё реально работает, можно прогнать в памяти через bytes.Buffer.

package main

import (
	"bytes"
	"fmt"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: `Buy milk, please`, Done: false},
		{ID: 2, Title: `Learn "Go"`, Done: true},
	}

	var buf bytes.Buffer
	_ = WriteTasksCSV(&buf, tasks)

	loaded, _ := ReadTasksCSV(&buf)
	fmt.Println(loaded)
	// [{1 Buy milk, please false} {2 Learn "Go" true}]
}

Здесь особенно приятно то, что кавычки и запятые внутри Title не ломают формат — потому что этим занимается csv.Writer и csv.Reader, а не ваша ручная строковая магия.

Поток данных: Reader/Writer и CSV

Иногда полезно остановиться и увидеть общую картинку. В Go часто проектируют код так, чтобы формат (CSV) был просто «насадкой» на поток чтения/записи. Вы меняете источник/приёмник, но не переписываете бизнес-логику.

flowchart TD
    A["Источник данных<br/>file / string / network"] --> B["io.Reader"]
    B --> C["csv.NewReader"]
    C --> D["[]string records"]
    D --> E["parse -> Task"]
    E --> F["[]Task"]

    F --> G["format -> []string"]
    G --> H["csv.NewWriter"]
    H --> I["io.Writer"]
    I --> J["Приёмник<br/>file / buffer / stdout"]

Если читать это как историю: «у нас есть io.Reader, сверху надеваем csv.Reader, получаем записи, превращаем в Task». А на экспорт идём обратным путём.

5. Типичные ошибки при работе с encoding/csv

Ошибка №1: парсить CSV через strings.Split или построчный Scanner и “делить по запятым”.
Такой код сначала выглядит героически, потом встречает кавычки/запятые внутри полей, и героизм превращается в археологию багов. CSV нужно читать csv.Reader-ом и писать csv.Writer-ом, потому что они реализуют правила формата, а не “примерно похожее”.

Ошибка №2: забыть Flush() и не проверить w.Error().
Это классика: вы честно вызвали Write, а в файле пусто или обрезано. Причина простая: часть данных сидела в буфере и не была сброшена, или ошибка записи проявилась только на финализации. Привычка “Flush() + Error() всегда” делает экспорт намного надёжнее.

Ошибка №3: не обрабатывать io.EOF отдельно и считать его «ошибкой чтения».
В потоковом чтении io.EOF — это нормальный сигнал, что данные закончились. Если не выделить его в отдельную ветку и не сделать break, вы либо будете печатать “error: EOF”, либо вообще сломаете цикл.

Ошибка №4: сразу лезть в rec[0], rec[1], rec[2] без проверки длины записи.
На учебных данных это прощается, но в реальном импорте любая “кривая” строка может привести к панике из-за выхода за границы. Даже если вы пока не строите сложную валидацию, минимальная проверка “сколько полей пришло” экономит часы отладки.

Ошибка №5: терять контекст ошибок и возвращать просто err.
Когда чтение падает на 736-й записи, сообщение “invalid syntax” не вдохновляет. Лучше оборачивать ошибки через fmt.Errorf("...: %w", err), чтобы было понятно, на каком шаге и с каким значением проблема. Такой стиль не делает код «красивее ради красоты» — он делает поддержку возможной.

1
Задача
Go SELF, 47 уровень, 1 лекция
Недоступна
Касса чек-листа
Касса чек-листа
1
Задача
Go SELF, 47 уровень, 1 лекция
Недоступна
Потоковый каталог
Потоковый каталог
1
Задача
Go SELF, 47 уровень, 1 лекция
Недоступна
Экспорт заметки
Экспорт заметки
1
Задача
Go SELF, 47 уровень, 1 лекция
Недоступна
Круговорот товаров
Круговорот товаров
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ