Architektura obsługi błędów w Go: granice i wzorce

Obsługuj błędy na odpowiedniej granicy.

Page content

Obsługa błędów w Go jest łatwa do krytykowania. Każdy programista Go setki razy pisał ten kod:

if err != nil {
	return err
}

To nie jest ta interesująca część. Interesująca część to to, co błąd oznacza, gdzie powinien być obsłużony, gdzie powinien zostać owinięty (wrapped), gdzie przetłumaczony, gdzie zarejestrowany (logged) oraz co powinno być odsłonięty dla wywołującego — to jest pytanie architektoniczne.

Go traktuje błędy jako wartości. Sprawia to, że awarie są jawne. Oznacza to również, że Twoja baza kodu potrzebuje jasnego projektu obsługi błędów. Bez niego błędy stają się losowymi łańcuchami znaków, obsłużniki HTTP odsłaniają szczegóły bazy danych, dzienniki (logi) duplikują tę samą awarię pięć razy, ponowe próby (retries) zachodzą z niewłaściwych powodów, a wywołujący analizują tekst zamiast zachowania.

Architektura obsługi błędów w Go: błędy przepływające między warstwami

Ten artykuł nie jest wprowadzeniem dla początkujących do if err != nil.

To praktyczny przewodnik po architekturze obsługi błędów w Go: owijanie (wrapping), wartości sentineli, niestandardowe typy błędów, errors.Is, errors.As, granice błędów, mapowanie API, rejestrowanie (logging), ponowe próby, bezpieczeństwo i wzorce produkcyjne.

Nieco zdeterminowana wersja: nie próbuj sprawiać, by błędy w Go znikały. Spraw, by miały znaczenie na właściwej granicy.

Co to są błędy w Go

W Go błąd to po prostu wartość implementująca ten interfejs:

type error interface {
	Error() string
}

Ten mały interfejs jest powodem, dla którego obsługa błędów w Go wydaje się tak bezpośrednia.

Funkcje zwracają błędy jawnie:

func LoadUser(id string) (*User, error) {
	// ...
}

Wywołujący decydują, co zrobić:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

Nie ma wyjątków ani ukrytego rozwijania stosu. Awaria jest częścią sygnatury funkcji.

To jest dobre, ale oznacza również, że błędy potrzebują projektu. Jeśli każdy pakiet zwraca dowolne komunikaty, wywołujący nie mogą podejmować niezawodnych decyzji. Jeśli każda warstwa owija każdy błąd bez dyscypliny, operatorzy otrzymują głośne komunikaty, a programiści zdezorientowane łańcuchy. Jeśli żadna warstwa nie owija błędów, awarie tracą kontekst.

Celem nie jest mniej obsługi błędów, ale lepsze znaczenie błędów.

Trzy zadania błędu

Przydatny błąd zazwyczaj ma jedno lub więcej zadań.

Zadanie 1: Wyjaśnić, co się nie powiodło

Dla ludzi błąd powinien wyjaśnić, która operacja się nie powiodła.

Przykład:

return fmt.Errorf("load user %s: %w", id, err)

To daje kontekst. Mówi, że awaria wystąpiła podczas ładowania użytkownika.

Zadanie 2: Zachować przyczynę

Dla kodu błąd powinien zachować podstawową przyczynę, gdy ta przyczyna ma znaczenie.

Przykład:

return fmt.Errorf("load user %s: %w", id, err)

%w owija oryginalny błąd, więc wywołujący mogą go inspekcjonować za pomocą errors.Is lub errors.As.

Zadanie 3: Pozwolić granicy podjąć decyzję

Na pewnej granicy program musi podjąć decyzję, co zrobić.

Przykłady:

  • Zwróć HTTP 404
  • Zwróć HTTP 409
  • Powtórz operację
  • Zarejestruj na poziomie ostrzeżenia
  • Wyświetl bezpieczny dla użytkownika komunikat
  • Anuluj transakcję
  • Wyślij błąd do monitoringu
  • Zignoruj anulowanie

Ta decyzja powinna zwykle opierać się na tożsamości lub typie błędu, a nie na dopasowywaniu łańcuchów znaków.

Główne narzędzia do obsługi błędów w nowoczesnym Go

Nowoczesny Go daje Ci mały, ale potężny zestaw narzędzi.

errors.New

Używaj errors.New do tworzenia prostej wartości błędu:

var ErrNotFound = errors.New("not found")

To jest przydatne dla błędów sentineli.

fmt.Errorf z %w

Używaj fmt.Errorf z %w do owijania błędu:

return fmt.Errorf("query user: %w", err)

Owijanie dodaje kontekst, zachowując jednocześnie oryginalny błąd do inspekcji.

errors.Is

Używaj errors.Is do sprawdzenia, czy błąd pasuje do konkretnego celu gdzieś w jego łańcuchu:

if errors.Is(err, ErrNotFound) {
	// obsłuż nie znaleziono
}

Używaj tego dla błędów sentineli i znanych warunków.

errors.As

Używaj errors.As do wyodrębnienia konkretnego typu błędu z łańcucha:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// użyj validationErr.Field lub validationErr.Reason
}

Używaj tego, gdy błąd przenosi dane strukturalne.

errors.Join

Używaj errors.Join, gdy wystąpiło wiele błędów i wszystkie powinny zostać zachowane:

return errors.Join(closeErr, flushErr)

Połączone błędy mogą nadal być inspekcjonowane za pomocą errors.Is i errors.As.

Używaj tego ostrożnie. Połączony błąd oznacza, że kilka awarii jest częścią jednego wyniku.

Błędy sentineli

Błąd sentineli to wartość błędu na poziomie pakietu, która reprezentuje znany warunek.

Przykład:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

Błędy sentineli są przydatne, gdy wywołujący musi wiedzieć tylko, jaka kategoria awarii wystąpiła.

Przykład:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

Następnie usługa lub obsłużnik mogą sprawdzić:

if errors.Is(err, ErrUserNotFound) {
	// zwróć 404
}

Kiedy używać błędów sentineli

Używaj błędów sentineli, gdy:

  • Warunek jest stabilny.
  • Wywołujący musi na nim rozgałęzić się.
  • Nie są potrzebne dodatkowe dane strukturalne.
  • Błąd należy do Twojego pakietu lub domeny.

Dobre przykłady:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

Kiedy nie używać błędów sentineli

Nie twórz sentineli dla każdej możliwej awarii.

Źle:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

Jeśli wywołujący nie rozgałęziają się na tych błędach, mogą być one tylko komunikatami.

Bądź również ostrożny z eksportowaniem zbyt wielu sentineli. Eksportowane błędy sentineli stają się częścią interfejsu API Twojego pakietu.

Niestandardowe typy błędów

Niestandardowy typ błędu jest przydatny, gdy błąd przenosi informacje strukturalne.

Przykład:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

Wywołujący:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

To jest lepsze niż parsowanie łańcucha błędu.

Kiedy używać niestandardowych typów błędów

Używaj niestandardowych typów błędów, gdy:

  • Wywołujący potrzebują danych strukturalnych.
  • Błąd ma znaczące pola.
  • Typ jest częścią umowy pakietu.
  • Wywołujący może potrzebować obsługiwać wiele wartości inaczej.

Przykłady:

  • Błąd walidacji z nazwą pola
  • Błąd limitu częstotliwości z czasem ponownej próby
  • Błąd HTTP z kodem stanu
  • Błąd parsowania z wierszem i kolumną
  • Błąd domenowy z identyfikatorem zasobu

Kiedy nie używać niestandardowych typów błędów

Nie twórz niestandardowych typów tylko po to, aby uniknąć errors.New.

To jest niepotrzebne:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

Jeśli nie ma użytecznych danych, sentinela często wystarczy.

Owijanie błędów

Owijanie dodaje kontekst do błędu, zachowując jednocześnie oryginalny błąd.

Przykład:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

Jeśli os.ReadFile się nie powiedzie, wywołujący otrzymuje oba:

  • operację wysokiego poziomu: odczyt konfiguracji
  • przyczynę niskiego poziomu: brak uprawnień, plik nie znaleziony itp.

Oba są dostępne przez łańcuch błędów, co sprawia, że owijanie z %w jest warte stosowania konsekwentnie.

Owijaj z użytecznym kontekstem

Dobre owijanie mówi, która operacja się nie powiodła:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Złe owijanie dodaje szum:

return fmt.Errorf("error: %w", err)

To nie mówi wywołującemu niczego.

Unikaj również powtarzania tego samego rzeczownika na każdej warstwie:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

Tego rodzaju łańcuch jest technicznie poprawny, ale praktycznie irytujący.

Owijaj tam, gdzie kontekst zmienia znaczenie. Jeśli nie możesz wyjaśnić jednym zdaniem, która operacja się nie powiodła, prawdopodobnie owijasz zbyt agresywnie lub zbyt mało.

Kiedy owijać i kiedy nie owijać

To jest jedna z najważniejszych decyzji architektonicznych.

Owijaj, gdy przekraczasz znaczącą granicę

Owijaj, gdy błąd przechodzi z jednej operacji do operacji wyższego poziomu.

Przykład:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

Błąd repozytorium jest teraz częścią operacji usługi, a ten dodany kontekst jest przydatny, gdy operatorzy śledzą awarię z powrotem przez dzienniki.

Nie owijaj tylko po to, aby powiedzieć “nie powiodło się”

Źle:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

Słowo “failed” jest zwykle implikowane przez sam fakt istnienia błędu.

Nie owijaj, jeśli tłumaczysz

Czasami powinieneś przetłumaczyć jeden błąd na inny błąd domenowy.

Przykład:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

To celowo ukrywa szczegóły bazy danych i odsłania warunek domenowy.

Możesz nadal zachować przyczynę, jeśli jest to użyteczne, ale rób to świadomie.

Nie odsłaniaj przypadkowo szczegółów implementacyjnych

Jeśli owijesz błąd niskiego poziomu z %w, wywołujący mogą go inspekcjonować.

To jest zwykle dobre wewnątrz Twojej aplikacji.

Ale w publicznym interfejsie API pakietu, owijanie może odsłonić szczegóły implementacyjne jako część Twojej umowy.

Na przykład, jeśli Twój pakiet owija sql.ErrNoRows, wywołujący mogą zacząć od niego zależać:

if errors.Is(err, sql.ErrNoRows) {
	// wywołujący teraz wie, że używasz database/sql
}

Jeśli możesz zmienić magazyn danych w przyszłości, preferuj sentinela domenowy:

var ErrUserNotFound = errors.New("user not found")

Następnie zwróć go z granicy pakietu.

Granice błędów

Najbardziej użytecznym sposobem myślenia o obsłudze błędów w Go jest myślenie przez granice.

Granica to miejsce, w którym błąd zmienia znaczenie lub odbiorcę.

Do wspólnych granic należą:

  • baza danych do repozytorium
  • repozytorium do usługi
  • usługa do obsłużnika HTTP
  • usługa do polecenia CLI
  • wewnętrzny błąd do komunikatu kierowanego do użytkownika
  • awaria tymczasowa do decyzji o ponownej próbie
  • awaria operacji do zdarzenia dziennika
  • błąd domenowy do odpowiedzi API

Architektura błędów to głównie projekt granic. Każda granica to punkt decyzji, w którym błędy either zyskują kontekst, tracą szczegóły implementacyjne lub są tłumaczone na formę, której następna warstwa może użyć.

Granica repozytorium

Repozytorium komunikuje się z magazynem danych.

Powinno ono zwykle tłumaczyć błędy specyficzne dla bazy danych na błędy domenowe.

Przykład:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Repozytorium ukrywa sql.ErrNoRows i odsłania ErrUserNotFound — czysta granica, która oznacza, że usługa nie musi wiedzieć nic o tym, jak magazyn reprezentuje “nie znaleziono”.

Granica usługi

Usługa posiada znaczenie biznesowe.

Powinna ona zwykle dodawać kontekst operacji i zachowywać błędy domenowe.

Przykład:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

To zachowuje warunek domenowy, dodając jednocześnie kontekst dla nieoczekiwanych błędów.

W przypadku bardziej złożonych reguł biznesowych usługa może tworzyć błędy domenowe bezpośrednio:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

Usługa jest właściwym miejscem dla błędów poziomu biznesowego — tworzonych bezpośrednio z logiki domeny, a nie tłumaczonych z warunków infrastrukturalnych.

Granica obsłużnika HTTP

Obsłużnik HTTP tłumaczy błędy aplikacji na odpowiedzi HTTP.

To jest granica, w której wewnętrzne szczegóły powinny stać się bezpiecznymi dla użytkownika odpowiedziami.

Przykład:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Mapowanie błędów:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

Obsłużnik mapuje błędy domenowe na semantykę HTTP, zamiast odsłaniać surowe szczegóły bazy danych lub wewnętrzne błędy. To jest miejsce, w którym wiele aplikacji Go popełnia błędy — albo odsłaniają za dużo wewnętrznych szczegółów, albo kolapsują wszystkie błędy do HTTP 500. Aby uzyskać kompletny obraz wzorców obsłużników i middleware w interfejsach API Go, Budowanie REST API w Go omawia uwierzytelnianie, routing i obsługę błędów w bibliotekach standardowych, Gin, Echo i Fiber.

Granica CLI

CLI ma inną granicę niż interfejs API HTTP.

W CLI błąd powinien być użyteczny dla osoby wykonującej polecenie.

Przykład:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

Na granicy polecenia:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Mapuj znane błędy na kody wyjścia:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

CLI często może pokazać więcej szczegółów niż publiczny interfejs API, ale nadal powinien unikać wycieki haseł.

Wzorzec typu błędu API

Dla interfejsów API HTTP mały typ błędu poziomu aplikacji może być przydatny.

Przykład:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

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

Konstruktor:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Użycie:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

Obsłużnik:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

Ten wzorzec jest przydatny, gdy chcesz strukturalne błędy API ze stabilnymi kodami.

Używaj go na granicy API. Nie wymuszaj, aby każdy wewnętrzny pakiet zwracał błędy specyficzne dla API.

Błędy domenowe vs błędy transportowe

Trzymaj błędy domenowe oddzielnie od błędów transportowych.

Błąd domenowy:

var ErrInsufficientBalance = errors.New("insufficient balance")

Mapowanie transportowe:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Nie sprawiaj, aby warstwa domeny zwracała kody stanu HTTP:

return &APIError{Status: http.StatusConflict}

To wiąże logikę biznesową z HTTP i zapobiega czystej pracy warstwy usługi przez HTTP, CLI, workerów, testy i przyszłe adaptery gRPC. Mapowanie transportowe należy do granicy transportu, a nie do kodu domeny. Aby uzyskać wskazówki, gdzie definiować błędy domenowe, sentineli i adaptery transportowe w układzie projektu, Struktura Projektu Go: Praktyki i Wzorce omawia konwencje internal/, pkg/ i adapterów, które utrzymują te warstwy czysto oddzielone.

Błędy ponownej próby

Niektóre błędy powinny wywołać ponowną próbę. Inne nie powinny.

Nie decyduj o tym, dopasowując łańcuchy znaków.

Używaj interfejsu znacznikowego lub jawnej funkcji.

Przykład:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

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

Pomocnik:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

Użycie:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

Pętla ponownej próby:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// ponów próbę z backoff
	}
	return err
}

To jest znacznie lepsze niż sprawdzanie, czy łańcuch błędu zawiera “timeout” — dopasowywanie łańcuchów znaków psuje się cicho, gdy komunikaty się zmieniają, i tworzy niewidoczną zależność między producentem a konsumentem.

Błędy walidacji

Błędy walidacji często potrzebują danych strukturalnych.

Przykład:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

Użycie:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Obsłużnik:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

To jest dobre użycie errors.As, ponieważ wywołujący potrzebuje informacji strukturalnych — nazw pól i komunikatów walidacji — a nie tylko nieprzejrzystego łańcucha błędu.

Wiele błędów

Czasami kilka rzeczy się nie powodzi.

Przykłady:

  • zamykanie wielu zasobów
  • walidacja wielu pól
  • wyłączanie kilku workerów
  • uruchamianie niezależnych sprawdzeń
  • wysyłanie i zamykanie wyjścia

Używaj errors.Join, gdy wszystkie błędy powinny zostać zachowane.

Przykład:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

Wywołujący:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

Oba errors.Is i errors.As mogą inspekcjonować połączone błędy, co oznacza, że wartości połączonych błędów pozostają w pełni zgodne ze standardowymi wzorcami sprawdzania błędów.

Kiedy nie używać errors.Join

Nie używaj errors.Join, gdy jest jeden główny błąd i pewien kontekst dziennika.

Nie używaj go, aby uniknąć decydowania, który błąd ma znaczenie.

Nie zwracaj ogromnych połączonych błędów do użytkowników.

Połączone błędy są przydatne, ale mogą szybko stać się głośne.

Panic nie jest obsługą błędów

W normalnym kodzie aplikacji nie używaj panicu dla oczekiwanych błędów.

Źle:

if err != nil {
	panic(err)
}

Używaj panicu dla błędów programisty lub naprawdę nieodwracalnych sytuacji.

Przykłady:

  • niemożliwe naruszenie wewnętrznej inwariantności
  • nieprawidłowa inicjalizacja pakietu
  • awaria pomocnika testowego z t.Fatal lub panic w ograniczonych przypadkach
  • nieodwracalny błąd konfiguracji startowej, w zależności od stylu

Nie używaj panicu,因为 zapytanie do bazy danych się nie powiodło lub użytkownik przesłał nieprawidłowe dane wejściowe.

To są normalne błędy.

Rejestrowanie błędów

Powszechnym błędem w Go jest rejestrowanie tego samego błędu na każdej warstwie.

Źle:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

To tworzy zduplikowane wpisy dziennika dla jednej awarii.

Lepiej:

  • owijaj błędy, gdy wędrują w górę
  • rejestruj raz na granicy, gdzie błąd jest obsługiwany
  • włącz strukturalny kontekst do dziennika

Przykład:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

To daje jedno zdarzenie dziennika z pełnym łańcuchem błędów. Aby uzyskać gotowy na produkcję setup strukturalnego rejestrowania, Strukturalne Rejestrowanie w Go z slog omawia rekordy log/slog, obsłużniki JSON, korelację kontekstu i redakcję — wszystko to naturalnie łączy się z rejestrowaniem błędów na poziomie granic.

Kiedy rejestrować w niższych warstwach

Rejestruj w niższych warstwach tylko wtedy, gdy warstwa faktycznie obsługuje błąd lub dodaje ważny kontekst operacyjny, który nie będzie widoczny gdzie indziej.

Na przykład pętla ponownej próby może rejestrować każdą próbę ponownej próby na poziomie debug lub ostrzeżenia.

Ale repozytorium nie powinno rejestrować każdego błędu zapytania, jeśli obsłużnik zarejestruje ostateczną awarię żądania.

Błędy kierowane do użytkownika vs błędy operatora

Nie pokazuj wewnętrznych błędów bezpośrednio użytkownikom.

Wewnętrzny błąd:

query user by id: dial tcp 10.0.4.12:5432: connection refused

Komunikat kierowany do użytkownika:

internal server error

Dziennik operatora:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

To są różne odbiorcy, a dobra architektura błędów utrzymuje je oddzielnie:

  • wewnętrzny błąd diagnostyczny
  • bezpieczna dla użytkownika odpowiedź
  • stabilny kod błędu API
  • kontekst dziennika operatora

Wymuszanie jednego łańcucha błędu do obsługi wszystkich tych odbiorców produkuje albo ryzyko odsłonięcia, albo koszmar debugowania. Projektuj swoją architekturę błędów wokół odrębnych wartości dla odrębnych konsumentów.

Bezpieczna obsługa błędów

Błędy mogą wyciekać poufnych informacji.

Unikaj odsłonięcia:

  • łańcuchów połączenia bazy danych
  • zapytań SQL z sekretami
  • wewnętrznych nazw hostów
  • ścieżek plików
  • tokenów dostępu
  • kluczy API
  • śladów stosu
  • prywatnych danych klientów
  • szczegółów polityki autoryzacji

To ma szczególne znaczenie w interfejsach API HTTP.

Źle:

http.Error(w, err.Error(), http.StatusInternalServerError)

Dobrze:

http.Error(w, "internal server error", http.StatusInternalServerError)

Rejestruj wewnętrzny błąd bezpiecznie dla operatorów. Zwróć bezpieczny komunikat do użytkownika.

Kody błędów

Dla publicznych interfejsów API stabilne kody błędów są często lepsze niż poleganie tylko na komunikatach.

Przykładowa odpowiedź:

{
  "code": "user_not_found",
  "message": "user not found"
}

Komunikat może się zmienić. Kod powinien być stabilny.

Używaj kodów błędów do:

  • zachowania klienta
  • dokumentacji
  • SDK
  • lokalizacji
  • diagnostyki wsparcia

Nie sprawiaj, aby klienci parsowali angielskie komunikaty błędów.

Praktyczny warstwowy projekt błędów

Oto czysty wzorzec dla wielu usług backendowych Go.

Warstwa repozytorium

  • Komunikuje się z bazą danych lub zewnętrznym magazynem.
  • Konwertuje błędy “nie znaleziono” specyficzne dla magazynu na błędy domenowe.
  • Owija nieoczekiwane błędy magazynu kontekstem operacji.
  • Nie zwraca błędów HTTP.
  • Zazwyczaj nie rejestruje.

Przykład:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

Warstwa usługi

  • Posiada reguły biznesowe.
  • Tworzy błędy domenowe.
  • Zachowuje znane błędy domenowe.
  • Owija nieoczekiwane błędy niższego poziomu.
  • Nie zwraca kodów stanu HTTP.
  • Zazwyczaj nie rejestruje.

Przykład:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Warstwa transportu

  • Mapuje błędy domenowe na odpowiedzi HTTP, gRPC lub CLI.
  • Rejestruje nieobsługiwane lub nieoczekiwane błędy.
  • Ukrywa wewnętrzne szczegóły przed użytkownikami.
  • Ustawia kody stanu i kody błędów API.

Przykład:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

To rozdzielenie utrzymuje obsługę błędów zrozumiałą i pozwala każdej warstwie ewoluować niezależnie — możesz zmienić technologię magazynowania bez dotyku do logiki usługi lub mapowania transportu. Warstwowy projekt działa najlepiej, gdy zależności są wstrzykiwane, a nie hard-coded; Wstrzykiwanie Zależności w Go: Wzorce i Najlepsze Praktyki omawia wzorce konstruktorów i interfejsów, które sprawiają, że każda granica jest łatwa do testowania w izolacji.

Kompletny przykład

Oto mały przykład od początku do końca.

Błędy domenowe:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

Repozytorium:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Usługa:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

Obsłużnik HTTP:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

Ta struktura daje Ci:

  • błędy domenowe
  • tłumaczenie magazynu
  • kontekst usługi
  • bezpieczne mapowanie HTTP
  • inspekcjonowalne łańcuchy błędów
  • brak dopasowywania łańcuchów znaków
  • brak wycieku transportu do kodu domeny

To jest rodzaj architektury błędów, która się skaluje — wystarczająco prosty, aby nowy współtwórca mógł go zrozumieć, ale wystarczająco strukturalny, aby logika domeny nigdy nie wyciekała do odpowiedzi transportowych.

Testowanie zachowania błędów

Zachowanie błędów powinno być testowane tak samo dokładnie jak ścieżka sukcesu, ponieważ decyzje graniczne — mapowanie sentineli, wyodrębnianie typów, kody HTTP — to często miejsca, gdzie błędy kryją się najdłużej. Aby uzyskać pełny przewodnik po strukturze testów Go, mockach i wzorcach pokrycia, zobacz Testowanie Jednostkowe w Go: Struktura i Najlepsze Praktyki.

Testuj mapowanie sentineli

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

Testuj wyodrębnianie niestandardowych błędów

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

Testuj mapowanie HTTP

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

Testy powinny udowodnić, że znane błędy produkują właściwe zachowanie na każdej granicy, więc refaktoryzacja warstw magazynu lub transportu nie może cicho zmienić umowy awarii.

Powszechne antywzorce

Antywzorzec 1: Dopasowywanie łańcuchów znaków

Źle:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Używaj errors.Is lub errors.As zamiast tego — obie obsługują łańcuchy owiniętych błędów automatycznie i nie psują się, gdy komunikaty są sformatowane na nowo lub zlokalizowane.

Antywzorzec 2: Tracenie przyczyny

Źle:

return errors.New("query failed")

Lepiej:

return fmt.Errorf("query user: %w", err)

Antywzorzec 3: Owijanie bez znaczenia

Źle:

return fmt.Errorf("error happened: %w", err)

Owijaj z kontekstem operacji, który wyjaśnia, co było próbowane, np. "create invoice %s: %w" zamiast niejasnego prefiksu, który nie dodaje wartości diagnostycznej.

Antywzorzec 4: Rejestrowanie na każdej warstwie

Źle:

log.Println(err)
return err

na każdym poziomie. Rejestruj raz, gdzie błąd jest ostatecznie obsługiwany, a nie na każdej pośredniej warstwie, która po prostu go przekazuje w górę.

Antywzorzec 5: Zwracanie błędów HTTP z kodu domeny

Źle:

return &APIError{Status: http.StatusNotFound}

z usługi domenowej. Mapuj błędy domenowe na kody stanu HTTP i ciała odpowiedzi na granicy obsłużnika, utrzymując warstwę usługi niezależną od zagadnień transportowych.

Antywzorzec 6: Odsłanianie wewnętrznych błędów użytkownikom

Źle:

http.Error(w, err.Error(), http.StatusInternalServerError)

Zwracaj bezpieczne, generyczne komunikaty do użytkowników i rejestruj pełny wewnętrzny błąd ze strukturalnym kontekstem dla operatorów. Nigdy nie odsłaniaj łańcuchów połączenia bazy danych, ścieżek plików lub surowych śladów stosu w odpowiedziach API.

Antywzorzec 7: Za dużo eksportowanych sentineli

Eksportowane błędy są częścią interfejsu API Twojego pakietu, a ich dodawanie zobowiązuje Cię do ich utrzymania. Nie eksportuj każdego wewnętrznego warunku, chyba że zewnętrzni wywołujący naprawdę potrzebują na nim rozgałęzić się — preferuj trzymanie sentineli nieeksportowanych, dopóki nie będzie jasnej potrzeby.

Antywzorzec 8: Używanie panicu dla oczekiwanych awarii

Źle:

panic(err)

dla normalnych awarii czasu działania. Zastrzeż panic dla naprawdę nieodwracalnych warunków lub błędów programisty, a nie dla brakujących rekordów lub nieprawidłowych danych wejściowych użytkownika — zawsze zwracaj błędy w tych przypadkach.

Antywzorzec 9: Ignorowanie błędów kontekstu

Źle:

return fmt.Errorf("request failed")

gdy rzeczywistą przyczyną był context.Canceled. Zachowuj błędy kontekstu, aby wywołujący mogli odróżnić prawdziwą awarię operacji od anulowanego lub przekroczonym limitu czasu żądania i odpowiednio na nie reagować.

Lista kontrolowana przeglądu błędów

Używaj tej listy kontrolowanej podczas przeglądu kodu.

Tworzenie błędów

  • Czy to jest znany warunek?
  • Czy powinien być to sentinela?
  • Czy potrzebuje strukturalnych danych?
  • Czy powinien być to niestandardowy typ?
  • Czy komunikat błędu jest jasny?

Owijanie błędów

  • Czy owijanie dodaje użyteczny kontekst operacji?
  • Czy %w zachowuje przyczynę tam, gdzie jest potrzebna?
  • Czy kod przypadkowo odsłania szczegóły implementacyjne?
  • Czy łańcuch jest zbyt głośny?

Tłumaczenie błędów

  • Czy błąd niskiego poziomu jest tłumaczony na właściwej granicy?
  • Czy zachowanie specyficzne dla bazy danych jest ukryte przed kodem usługi?
  • Czy błędy domenowe są niezależne od zagadnień HTTP lub CLI?

Obsługa błędów

  • Czy wywołujący rozgałęzia się z errors.Is lub errors.As?
  • Czy anulowanie kontekstu i limity czasu są obsługiwane poprawnie?
  • Czy błędy ponownej próby są identyfikowane jawnie?
  • Czy błędy walidacji są strukturalne?

Rejestrowanie

  • Czy błąd jest rejestrowany raz, na granicy obsługi?
  • Czy dzienniki są strukturalne?
  • Czy poufne szczegóły są wykluczone z odpowiedzi użytkownika?
  • Czy jest wystarczająco kontekstu dla operatorów?

Testowanie

  • Czy znane przypadki błędów są testowane?
  • Czy mapowania HTTP lub CLI są testowane?
  • Czy szczegóły walidacji są testowane?
  • Czy decyzje ponownej próby są testowane?

Moje zdeterminowane zasady

Zasada 1: Błędy powinny przekraczać granice ze znaczeniem

Nie tylko przekazywać błędy. Decyduj, co oznaczają na każdej warstwie.

Zasada 2: Owijaj dla kontekstu, nie dla dekoracji

Jeśli owijanie nie dodaje użytecznych informacji o tym, która operacja się nie powiodła, nie owijaj. Dodatkowa warstwa kontekstu bez znaczenia sprawia, że łańcuch błędów jest trudniejszy do odczytania i nie dodaje wartości diagnostycznej.

Zasada 3: Tłumacz błędy implementacyjne na błędy domenowe

Nie pozwól, aby sql.ErrNoRows stał się częścią Twojej logiki biznesowej. Tłumacz błędy implementacyjne na błędy domenowe na granicy magazynu, aby reszta aplikacji nigdy nie musiała wiedzieć, która baza danych lub ORM jest na dole.

Zasada 4: Nie parsuj łańcuchów błędów

Jeśli kod musi rozgałęzić się na typie awarii, używaj sentineli, niestandardowych typów, errors.Is lub errors.As. Inspekcja łańcuchów znaków tworzy niewidoczną zależność, która psuje się cicho, gdy komunikaty błędów się zmieniają.

Zasada 5: Rejestruj raz

Owijaj, gdy błędy wędrują w górę. Rejestruj, gdzie błąd jest ostatecznie obsługiwany.

Zasada 6: Trzymaj komunikaty użytkownika bezpieczne

Wewnętrzne błędy diagnostyczne są dla dzienników. Komunikaty kierowane do użytkowników są dla użytkowników.

Zasada 7: Trzymaj błędy transportowe na granicy transportu

Kody stanu HTTP należą do obsłużników lub adapterów API, a nie do usług domenowych. Kod domeny powinien być wielokrotnego użytku przez transporty — dziś HTTP, jutro CLI, gRPC lub worker napędzany zdarzeniami.

Ostateczne myśli

Obsługa błędów w Go nie polega na pisaniu if err != nil w nieskończoność — polega na sprawianiu, aby awaria była jawna i zrozumiała na każdej granicy.

Mechanika jest prosta:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

Architektura jest trudniejszą częścią:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

To jest dobrze wykonana obsługa błędów w Go — nie pomysłowa, nie magiczna, ale wystarczająco jasna, aby następny programista, operator, klient API i przyszłe Ty mogli zrozumieć, co się nie powiodło i co powinno się stać dalej. Aby uzyskać szerszy widok na produkcyjne wzorce Go w integracji, testowaniu i dostępie do danych, zobacz Architektura Aplikacji w Produkcji.

Źródła

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.