JavaRush /Курси /Go SELF /Multi-error на практиці — errors.Join для імпорту

Multi-error на практиці — errors.Join для імпорту

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

1. Навіщо взагалі потрібен multi-error під час імпорту

Коли ви пишете імпорт CSV, дуже легко за звичкою зробити «fail fast»: знайшли проблему в рядку № 2 — одразу return err. Підхід чесний, але не завжди вдалий. Користувач виправить рядок № 2, запустить імпорт знову, побачить помилку в рядку № 17, потім — у рядку № 23… і почне підозрювати, що ви з нього знущаєтеся. Іноді — не без підстав.

Імпорт — майже завжди пакетна операція. Користувач приніс файл, а ваше завдання — дати звіт, що з ним не так. І що більше проблем ви покажете за один запуск у розумних межах, то коротшим буде цикл «виправив → знову запустив → знову виправив».

До того ж це просто економить час: помилки в даних часто йдуть «пачками». А ми, як розробники, хочемо бути не лише суворими, а й корисними.

Давайте спершу подивимося на «наївний» код, який псує UX.

// Поганий UX: зупиняємося на першій же помилці.
if err := validateRecord(rec, recNo); err != nil {
    return nil, err
}

Він коректний, але для імпорту частіше потрібна інша модель: збирати проблеми й продовжувати.

Для наочності можна уявити імпорт так:

flowchart TD
    A[Читаємо CSV] --> B[Парсимо запис]
    B --> C{Валідація успішна?}
    C -- так --> D[Додаємо до результату]
    C -- ні --> E[Запам’ятовуємо помилку]
    D --> F{Є ще записи?}
    E --> F
    F -- так --> B
    F -- ні --> G[Якщо були помилки: повернути всі]

І ось «повернути всі» — це і є тема лекції.

2. Нагадування: помилки в Go — це значення

Перед тим як писати errors.Join, корисно повернутися до базової ідеї Go: помилки — це значення. Це не просто філософія для горнятка з написом «I ❤️ Go», а практичний принцип: помилки можна складати, обгортати, аналізувати й обирати стратегію обробки.

У Go 1.13 додали стандартний механізм «ланцюжка причин»: помилка може обгортати іншу помилку через Unwrap() error, і тоді errors.Is/errors.As уміють шукати потрібну причину всередині ланцюжка.

А починаючи з Go 1.20 з’явився стандартний спосіб зробити майже те саме, але для кількох помилок: errors.Join. Він повертає одну помилку, всередині якої лежить список причин, доступний через Unwrap() []error.

Ідея проста: назовні ми повертаємо одне значення error — так зручніше для API, — а всередині можемо зберігати багато деталей.

3. Мінімальна модель: імпорт задач із CSV

Щоб не писати «абстрактний імпорт абстрактних сутностей», давайте продовжимо нашу навчальну лінію із задачами (tasks). Нехай CSV містить такі поля:

  • id — ціле число
  • title — рядок, обов’язковий
  • donetrue/false

Структура в Go нам уже знайома з попередніх лекцій про struct:

package task

type Task struct {
    ID    int
    Title string
    Done  bool
}

Сигнатура імпорту, до якої будемо прагнути:

func ImportCSV(r io.Reader) ([]Task, error)

Чому ми повертаємо і задачі, і error? Тому що стратегія «прийняти валідні рядки, а про невалідні відзвітувати» — цілком робоча. Іноді ви можете обрати підхід «якщо є хоч одна помилка — нічого не імпортуємо», але навіть у такому разі multi-error усе одно корисний: звіт користувачу стає повнішим.

Зараз ми не сперечаємося про політику «частково приймати чи ні». Ми вчимося техніки: як коректно зібрати кілька помилок.

4. Що таке errors.Join і чому він зручний

errors.Join — це функція стандартної бібліотеки, яка приймає кілька помилок і повертає одну. Найважливіше для практики імпорту:

  • errors.Join(nil, nil) дає nil — тобто помилок не було.
  • errors.Join(err1, nil, err2) ігнорує nil і об’єднує тільки реальні помилки.
  • Повернена помилка підтримує «розгортання» списку причин через Unwrap() []error.

Це означає, що ми можемо зібрати []error у циклі, а наприкінці зробити:

return tasks, errors.Join(errs...)

І отримати один error, який можна:

  • просто надрукувати — часто цього вже достатньо для CLI;
  • аналізувати програмно — наприклад, перевіряти errors.Is/errors.As для кожної причини. Механізм ґрунтується на ідеї unwrap, як і в Go 1.13.

Важливо: errors.Join — це не «магія», а просто акуратний стандартний контейнер для набору помилок. В імпорті це саме те, що треба.

5. Робимо помилки самодостатніми: додаємо контекст рядка

Якщо ми просто складатимемо в errs помилки на кшталт errors.New("invalid id"), підсумковий звіт буде марним: «invalid id» — де? у якому рядку? яке значення? яка колонка?

Тому нам потрібен невеликий тип помилки, який додає контекст: номер запису, ім’я колонки й вихідне значення. І бажано — зберігає «причину» через Unwrap, щоб ми могли використовувати errors.Is/errors.As для вкладеної помилки.

Мінімальний варіант:

package taskcsv

import "fmt"

type RowError struct {
    Row int
    Col string
    Val string
    Err error
}

func (e *RowError) Error() string {
    return fmt.Sprintf("row %d, col %s: %q: %v", e.Row, e.Col, e.Val, e.Err)
}

func (e *RowError) Unwrap() error {
    return e.Err
}

Зверніть увагу: ми зробили Unwrap() error, тобто одну конкретну причину помилки. Це корисно, щоб errors.As і errors.Is могли дістатися до вихідної причини — наприклад, до помилки strconv.Atoi.

6. Збираємо помилки в циклі: валідація не повинна ламати весь імпорт

Тепер уявімо, що в нас є функція parseTask, яка намагається перетворити []string — один CSV-запис — на Task. Якщо не вийшло, вона повертає error.

Важливий момент: якщо parseTask повертає помилку, ми не зобов’язані одразу припиняти імпорт. Ми можемо записати її в список і рухатися далі.

Скелет імпорту виглядає так:

package taskcsv

import (
    "encoding/csv"
    "errors"
    "io"
)

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

    header, err := cr.Read()
    if err != nil {
        return nil, err
    }
    _ = header // заголовок обробимо «як слід» у наступній лекції

    var tasks []Task
    var errs []error

    recNo := 1 // header = 1
    for {
        rec, err := cr.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            return nil, err
        }

        recNo++
        t, err := parseTask(rec, recNo)
        if err != nil {
            errs = append(errs, err)
            continue
        }
        tasks = append(tasks, t)
    }

    return tasks, errors.Join(errs...)
}

Зверніть увагу на дві різні гілки обробки.

Ми виходимо одразу, якщо сталася помилка читання файла або CSV-парсингу. Наприклад, потік пошкоджено. Це «технічна» помилка — далі робити імпорт безглуздо.

Але якщо це помилка даних конкретного рядка, ми додаємо її в errs і продовжуємо.

7. Як акуратно об’єднувати помилки: допоміжна функція joinAll

Прямо писати errors.Join(errs...) можна, але новачкам часто корисно зробити намір явним: якщо помилок немає, повернути nil.

Чому? Бо errs — це слайс, і хочеться, щоб код читався як чітка інструкція. Так менше шансів випадково повернути «порожню» помилку.

Зробімо невелику допоміжну функцію:

package taskcsv

import "errors"

func joinAll(errs []error) error {
    if len(errs) == 0 {
        return nil
    }
    return errors.Join(errs...)
}

І використаємо:

return tasks, joinAll(errs)

Це дрібниця, але вона робить код більш самодокументованим. А читабельність — це така річ, яка починає цінуватися рівно в той момент, коли ви намагаєтеся налагодити імпорт у п’ятницю ввечері.

8. Приклад parseTask: парсимо поля й додаємо контекст

Нехай rec — це []string{ "10", "Buy milk", "false" }.

Ми розпарсимо id, перевіримо title, розпарсимо done. Помилки загорнемо в RowError, щоб угорі отримати «де і чому».

package taskcsv

import (
    "errors"
    "strconv"
    "strings"
)

var ErrEmptyTitle = errors.New("поле title є обовʼязковим")

func parseTask(rec []string, row int) (Task, error) {
    idRaw := strings.TrimSpace(rec[0])
    id, err := strconv.Atoi(idRaw)
    if err != nil {
        return Task{}, &RowError{Row: row, Col: "id", Val: idRaw, Err: err}
    }

    title := strings.TrimSpace(rec[1])
    if title == "" {
        return Task{}, &RowError{Row: row, Col: "title", Val: rec[1], Err: ErrEmptyTitle}
    }

    doneRaw := strings.TrimSpace(rec[2])
    done, err := strconv.ParseBool(doneRaw)
    if err != nil {
        return Task{}, &RowError{Row: row, Col: "done", Val: doneRaw, Err: err}
    }

    return Task{ID: id, Title: title, Done: done}, nil
}

Тут є дві важливі практичні ідеї.

Перша — ми не втрачаємо вихідну причину: якщо Atoi/ParseBool повернули помилку, вона потрапляє в Err і доступна через Unwrap() error.

Друга — ми вводимо «свої» доменні помилки (ErrEmptyTitle). Це зручно, бо такі помилки можна стабільно розпізнавати через errors.Is — зокрема й усередині обгорток.

9. Як показувати й обробляти об’єднану помилку

Що побачить користувач

Уявімо, що в CSV є дві проблеми: в одному рядку id не число, а в іншому — порожній title.

Ми накопичили помилки, зробили errors.Join і повернули одну помилку нагору. Що буде, якщо її надрукувати?

Ось приклад «демо-виводу» — спрощено:

if err != nil {
    println(err.Error())
}

Сенс у тому, що Join зазвичай форматує результат так, щоб було видно кілька повідомлень. Для CLI-утиліти це вже непогано: користувач побачить кілька проблем і зможе виправити файл за один прохід.

Але інколи хочеться зробити вивід привабливішим: наприклад, додати префікс «Import failed:» і далі вивести все по рядках. Для цього нам потрібно вміти дістати окремі причини з об’єднаної помилки.

Як розгорнути errors.Join назад у список помилок

errors.Join повертає тип, який усередині тримає список і надає його через метод Unwrap() []error.

Цей тип не обов’язково експортований, тому зазвичай ми робимо type assertion до інтерфейсу:

package taskcsv

type unwrapper interface {
    Unwrap() []error
}

func unwrapMany(err error) []error {
    u, ok := err.(unwrapper)
    if !ok {
        return nil
    }
    return u.Unwrap()
}

Тепер можна зробити «людський» звіт:

errs := unwrapMany(err)
for _, e := range errs {
    println("-", e.Error())
}

Чому це важливо саме в імпорті? Бо імпорт — це саме те місце, де користувач очікує звіт «списком проблем». А errors.Join дозволяє зберегти його в одній error — зручно для API, — але водночас за потреби розгорнути й красиво вивести.

Перевірка errors.Is за нашими помилками всередині joined error

Нехай ми хочемо зрозуміти: «а чи був у файлі хоча б один рядок із порожнім title?». Ми зробили ErrEmptyTitle як окрему помилку — чудово. Тепер можна:

if errors.Is(err, ErrEmptyTitle) {
    println("У файлі є задачі без title") // приклад повідомлення
}

Сенс саме в тому, що ми не порівнюємо рядки, а перевіряємо семантику помилки через errors.Is, який уміє «пірнати» в обгортки.

Це особливо приємно, коли помилок багато: ми можемо і звіт користувачу показати, і прийняти рішення в коді — наприклад, повернути exit code 2 для «помилок даних».

10. Стратегії імпорту: fail-fast і collect-all

Коли ви проєктуєте імпорт, ви по суті обираєте стратегію. Нижче — компактна таблиця, щоб зафіксувати, що саме змінюється.

Стратегія Поведінка Плюси Мінуси Як реалізувати
Fail-fast Зупинилися на першій помилці Код простіший, менше стану Поганий UX для пакетного імпорту return err одразу
Collect-all Зібрали всі помилки й продовжили Кращий UX, менше повторних запусків Код трохи складніший, треба зберігати помилки errs = append(errs, err) + errors.Join

errors.Join тут хороший тим, що він не змушує вас вигадувати власний «мега-тип помилки» і не порушує контрактів: ви все так само повертаєте звичайний error, просто всередині може бути кілька причин.

11. Типові помилки

Помилка № 1: збирати помилки у вигляді рядків, а не error.
Іноді хочеться зробити []string і потім склеїти через "\n". Це швидко, але ви втрачаєте головну перевагу Go: помилки — це значення. Ви більше не зможете зробити errors.Is, не зможете дістатися до strconv.Atoi, не зможете обробити один клас помилок інакше, ніж інший. Набагато краще накопичувати саме []error, а текст — це вже питання відображення.

Помилка № 2: повертати errors.Join(errs...), але забути про випадок «помилок немає».
Формально errors.Join() і так поверне nil, якщо всі аргументи nil, але новачки часто плутаються, коли errs порожній, коли в ньому лежать nil, і чому інколи «помилка є, але не друкується». Найспокійніший варіант — тримати joinAll(errs) з явною перевіркою len(errs) == 0.

Помилка № 3: не додавати контекст — рядок, колонку й значення.
invalid syntax без указівки «де саме» в CSV-файлі — це майже знущання. В імпорті завжди додавайте контекст: номер запису, ім’я колонки й вихідне значення. Інакше користувач шукатиме проблему очима по всьому файлу.

Помилка № 4: плутати помилки читання CSV і помилки даних.
Якщо csv.Reader повернув помилку читання або парсингу, продовжувати зазвичай безглуздо: ви не знаєте, де перебуваєте в потоці. А от помилки даних конкретного запису — якраз те, що варто накопичувати. Якщо змішати ці класи, можна отримати ситуацію «ми продовжили імпорт після того, як потік став некоректним», і результати будуть непередбачуваними.

Помилка № 5: намагатися «розібрати» multi-error через err.Error() і регулярні вирази.
Щойно ви починаєте розбирати текст помилки, ви майже напевно програєте: формат може змінитися, локалізація може змінитися, та й узагалі це крихко. Якщо вам потрібно програмно обробити причини — використовуйте errors.Is/errors.As і Unwrap-інтерфейси.

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