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

Obsługuj błędy na właściwej 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 jest interpretacja błędu: gdzie powinien być obsłużony, gdzie opakowany, gdzie przetłumaczony, gdzie zalogowany oraz co należy udostępnić wywołującemu — to jest pytanie o architekturę.

Go traktuje błędy jako wartości. Uczynia to niepowodzenia jawnymi. Oznacza to również, że baza kodu wymaga jasnej strategii obsługi błędów. Bez niej błędy stają się losowymi ciągami znaków, obsłużniki HTTP ujawniają szczegóły bazodanowe, logi duplikują to samo niepowodzenie pięć razy, ponowne próby zachodzą z błędnych powodów, a wywołujący analizują tekst zamiast zachowania.

Architektura obsługi błędów w Go: przepływ błędów między warstwami

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

Jest praktycznym przewodnikiem po architekturze obsługi błędów w Go: opakowywaniu, błędach sygnalizacyjnych, niestandardowych typach błędów, errors.Is, errors.As, granicach błędów, mapowaniu API, logowaniu, ponownych próbach, bezpieczeństwie i wzorcach produkcyjnych.

Wersja nieco zdeterminowana: nie próbujcie ukrywać błędów w Go. Sprawcie, by miały znaczenie na odpowiedniej granicy.

czym 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 odplatkowania stosu. Niepowodzenie 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 opakowuje każdy błąd bez dyscypliny, operatorzy otrzymują głośne komunikaty, a deweloperzy zdezorientowane łańcuchy. Jeśli żadna warstwa nie opakowuje błędów, niepowodzenia tracą kontekst.

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

Trzy zadania błędu

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

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

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

Przykład:

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

To daje kontekst. Mówi, że niepowodzenie wystąpiło 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 opakowuje oryginalny błąd, aby wywołujący mogli go zbadać za pomocą errors.Is lub errors.As.

Zadanie 3: Pozwolić granicy na podjęcie decyzji

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

Przykłady:

  • Zwrócić HTTP 404
  • Zwrócić HTTP 409
  • Ponowić próbę operacji
  • Zalogować na poziomie ostrzeżenia
  • Wyświetlić bezpieczny dla użytkownika komunikat
  • Anulować transakcję
  • Wysłać błąd do monitoringu
  • Zignorować anulowanie

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

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

Nowoczesny Go oferuje 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")

Jest to przydatne do błędów sygnalizacyjnych.

fmt.Errorf z %w

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

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

Opakowywanie dodaje kontekst, zachowując oryginalny błąd do inspekcji.

errors.Is

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

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

Używaj tego do błędów sygnalizacyjnych i znanych stanów.

errors.As

Używaj errors.As do wyodrębniania określonego 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 kilka błędów i wszystkie powinny zostać zachowane:

return errors.Join(closeErr, flushErr)

Połączone błędy nadal można badać za pomocą errors.Is i errors.As.

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

Błędy sygnalizacyjne

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

Przykład:

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

Błędy sygnalizacyjne są przydatne, gdy wywołujący musi tylko wiedzieć, jaka kategoria niepowodzenia 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 może sprawdzić:

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

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

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

  • Stan jest stabilny.
  • Wywołujący musi rozgałęzić kod na jego podstawie.
  • 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 sygnalizacyjnych

Nie twórz sygnalizatorów 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ą kodu na podstawie tych błędów, mogą one być tylko komunikatami.

Bądź też ostrożny z eksportowaniem zbyt wielu sygnalizatorów. Wyeksportowane błędy sygnalizacyjne 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)
}

Jest to lepsze niż parsowanie ciągu 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ą kontraktu Twojego pakietu.
  • Wywołujący może potrzebować różnej obsługi wielu wartości.

Przykłady:

  • Błąd walidacji z nazwą pola
  • Błąd limitu przepływności z czasem ponownej próby
  • Błąd HTTP z kodem stanu
  • Błąd parsowania z numerem wiersza i kolumny
  • 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 przydatnych danych, sygnalizator często wystarcza.

Opakowywanie błędów

Opakowywanie dodaje kontekst do błędu, zachowując 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 nie powiedzie się, wywołujący otrzymuje:

  • 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 opakowywanie z %w warto stosować konsekwentnie.

Opakowuj z przydatnym kontekstem

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

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

Złe opakowywanie dodaje szum:

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

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

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

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

Taka łańcuch jest technicznie poprawny, ale praktycznie uciążliwy.

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

Kiedy opakowywać, a kiedy nie

To jedna z najważniejszych decyzji architektonicznych.

Opakowuj przy przekraczaniu znaczącej granicy

Opakowuj, 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 staje się teraz częścią operacji usługi, a ten dodany kontekst jest przydatny, gdy operatorzy śledzą niepowodzenie w logach.

Nie opakowuj 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 opakowuj, jeśli tłumaczysz

Czasami należy 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 bazodanowe i ujawnia stan domenowy.

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

Nie ujawniaj przypadkowo szczegółów implementacji

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

To zwykle jest dobre wewnątrz aplikacji.

Ale w publicznym interfejsie API pakietu opakowywanie może ujawnić szczegóły implementacji jako część kontraktu.

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

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

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

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

Następnie zwróć go z granicy pakietu.

Granice błędów

Najprzydatniejszy sposób myślenia o obsłudze błędów w Go to myślenie przez granice.

Granica to miejsce, gdzie błąd zmienia znaczenie lub odbiorcę.

Typowe granice obejmują:

  • 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 widocznego dla użytkownika
  • awaria tymczasowa do decyzji o ponownej próbie
  • niepowodzenie operacji do zdarzenia logującego
  • błąd domenowy do odpowiedzi API

Architektura błędów to w większości projektowanie granic. Każda granica to punkt decyzyjny, gdzie błędy zyskują kontekst, tracą szczegóły implementacyjne lub są tłumaczone na formę, z której następna warstwa może skorzystać.

Granica repozytorium

Repozytorium komunikuje się z magazynem.

Zazwyczaj powinno 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 ujawnia ErrUserNotFound — czysta granica, która oznacza, że usługa nie musi znać nic o tym, jak magazyn reprezentuje “nie znaleziono”.

Granica usługi

Usługa posiada znaczenie biznesowe.

Zazwyczaj powinna dodawać kontekst operacyjny 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 stan domenowy, dodając 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 na poziomie biznesowym — tworzonych bezpośrednio z logiki domenowej, 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, gdzie 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 ujawniać surowe szczegóły bazy danych lub wewnętrznego błędu. To jest miejsce, gdzie wiele aplikacji Go popełnia błędy — ujawniają zbyt wiele szczegółów wewnętrznych lub redukują wszystkie błędy do HTTP 500. Aby uzyskać pełny obraz wzorców obsłużników i middleware w API Go, Budowanie REST API w Go omawia uwierzytelnianie, routing i obsługę błędów w standardowej bibliotece, Gin, Echo i Fiber.

Granica CLI

CLI ma inną granicę niż API HTTP.

W CLI błąd powinien być przydatny dla osoby uruchamiają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ż publiczne API, ale nadal powinien unikać wycieki haseł.

Wzorzec typu błędu API

Dla API HTTP mały niestandardowy typ błędu na poziomie 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 osobno 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 pozwól, aby warstwa domenowa zwracała kody stanu HTTP:

return &APIError{Status: http.StatusConflict}

To łączy logikę biznesową z HTTP i uniemożliwia warstwie usługi czystą pracę przez HTTP, CLI, workerów, testy i przyszłe adaptery gRPC. Mapowanie transportowe należy do granicy transportu, a nie do kodu domenowego. Aby uzyskać wskazówki dotyczące miejsca definiowania błędów domenowych, sygnalizatorów i adapterów transportowych 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 do ponownej próby

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

Nie decyduj o tym na podstawie dopasowywania ciągów 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
}

Funkcja pomocnicza:

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 ciąg błędu zawiera “timeout” — dopasowywanie ciągów znaków cicho się psuje, gdy komunikaty się zmieniają, i tworzy niewidzialne sprzężenie 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 ciągu błędu.

Wielokrotne błędy

Czasami kilka rzeczy się nie powodzi.

Przykłady:

  • zamykanie wielu zasobów
  • walidacja wielu pól
  • wyłączanie kilku workerów
  • uruchamianie niezależnych kontroli
  • zmywanie 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)
}

Zarówno errors.Is, jak i errors.As mogą badać 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 logowania.

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

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

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 panic dla oczekiwanych błędów.

Źle:

if err != nil {
	panic(err)
}

Używaj panic 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 panic, ponieważ zapytanie do bazy danych nie powiodło się lub użytkownik przesłał nieprawidłowe dane wejściowe.

To są normalne błędy.

Logowanie błędów

Typowym błędem w Go jest logowanie 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 logi dla jednego niepowodzenia.

Lepiej:

  • opakowuj błędy, gdy wędrują w górę
  • loguj raz na granicy, gdzie błąd jest obsługiwany
  • zawieraj strukturalny kontekst w logu

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 logu z pełnym łańcuchem błędów. Aby uzyskać gotowe do produkcji ustawienie strukturalnego logowania, Strukturalne logowanie w Go z slog omawia rekordy log/slog, obsłużniki JSON, korelację kontekstu i redakcję — wszystkie z nich naturalnie łączą się z logowaniem błędów na poziomie granic.

Kiedy logować wewnątrz niższych warstw

Loguj wewnątrz niższych warstw tylko wtedy, gdy warstwa faktycznie obsługuje błąd lub dodaje ważny kontekst operacyjny, który nie będzie widoczny w innym miejscu.

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

Ale repozytorium nie powinno logować każdego błędu zapytania, jeśli obsłużnik zaloguje ostateczne niepowodzenie żądania.

Błędy widoczne dla użytkownika vs błędy operatora

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

Błąd wewnętrzny:

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

Komunikat widoczny dla użytkownika:

internal server error

Log 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 osobno:

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

Wymuszanie jednego ciągu błędu, aby obsługiwał wszystkich tych odbiorców, powoduje要么是 ryzyko ujawnienia,要么是 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 ujawniania:

  • ciągów połączeń 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 polityk autoryzacji

To ma szczególne znaczenie w API HTTP.

Źle:

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

Dobrze:

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

Zaloguj wewnętrzny błąd bezpiecznie dla operatorów. Zwróć bezpieczny komunikat użytkownikowi.

Kody błędów

Dla publicznych 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 dla:

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

Nie zmuszaj klientów do parsowania komunikatów błędów po angielsku.

Praktyczny warstwowy projekt obsługi błędów

Oto czysty wzorzec dla wielu usług backendowych w Go.

Warstwa repozytorium

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

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.
  • Opakuje nieoczekiwane błędy niższych poziomów.
  • Nie zwraca kodów stanu HTTP.
  • Zazwyczaj nie loguje.

Przykład:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Warstwa transportowa

  • Mapuje błędy domenowe na odpowiedzi HTTP, gRPC lub CLI.
  • Loguje 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 oddzielenie utrzymuje obsługę błędów zrozumiałą i pozwala każdej warstwie ewoluować niezależnie — możesz zmienić technologię magazynowania, nie dotykając logiki usługi ani mapowania transportowego. Warstwowy projekt działa najlepiej, gdy zależności są wstrzykiwane, a nie zakodowane na sztywno; 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 end-to-end.

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 ciągów znaków
  • brak wycieku transportu do kodu domenowego

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

Testowanie zachowania błędów

Zachowanie błędów powinno być testowane tak dokładnie, jak szczęśliwa ścieżka, ponieważ decyzje graniczne — mapowanie sygnalizatorów, ekstrahowanie typów, kody HTTP — to często miejsca, gdzie ukrywają się błędy najdłużej. Aby uzyskać pełny przewodnik po strukturze testów Go, mockowaniu i wzorcach pokrycia, zobacz Testowanie jednostkowe w Go: Struktura i najlepsze praktyki.

Testuj mapowanie sygnalizatorów

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 ekstrahowanie 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, aby refaktoryzacja warstw magazynowych lub transportowych nie mogła cicho zmienić kontraktu awarii.

Typowe antywzorce

Antywzorzec 1: Dopasowywanie ciągów znaków

Źle:

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

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

Antywzorzec 2: Utrata przyczyny

Źle:

return errors.New("query failed")

Lepiej:

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

Antywzorzec 3: Opakowywanie bez znaczenia

Źle:

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

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

Antywzorzec 4: Logowanie na każdej warstwie

Źle:

log.Println(err)
return err

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

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

Ź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: Ujawnianie wewnętrznych błędów użytkownikom

Źle:

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

Zwracaj bezpieczne, ogólne komunikaty użytkownikom i loguj pełny wewnętrzny błąd ze strukturalnym kontekstem dla operatorów. Nigdy nie ujawniaj ciągów połączeń bazy danych, ścieżek plików ani surowych śladów stosu w odpowiedziach API.

Antywzorzec 7: Zbyt wiele wyeksportowanych sygnalizatorów

Wyeksportowane błędy są częścią interfejsu API Twojego pakietu, a ich dodawanie zobowiązuje Cię do ich utrzymania. Nie eksportuj każdej wewnętrznej kondycji, chyba że zewnętrzni wywołujący naprawdę muszą na niej rozgałęzić kod — preferuj utrzymywanie sygnalizatorów niewyeksportowanych, dopóki nie będzie wyraźnej potrzeby.

Antywzorzec 8: Używanie panic dla oczekiwanych awarii

Źle:

panic(err)

dla normalnych awarii czasu wykonania. Rezerwuj 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 prawdziwą przyczyną było context.Canceled. Zachowuj błędy kontekstu, aby wywołujący mogli rozróżnić prawdziwe niepowodzenie operacji od anulowanego lub przekraczającego limit czasu żądania i odpowiednio reagować na każde. Aby uzyskać wyczerpujące omówienie tego, jak anulowanie kontekstu i propagacja limitów czasu działają przez warstwy usług, zobacz Go context.Context Done Right.

Lista kontrolna przeglądu błędów

Używaj tej listy podczas przeglądu kodu.

Tworzenie błędów

  • Czy to znany stan?
  • Czy powinien być sygnalizatorem?
  • Czy potrzebuje danych strukturalnych?
  • Czy powinien być niestandardowym typem?
  • Czy komunikat błędu jest jasny?

Opakowywanie błędów

  • Czy opakowanie dodaje przydatny kontekst operacyjny?
  • Czy %w zachowuje przyczynę tam, gdzie jest potrzebna?
  • Czy kod przypadkowo ujawnia 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 kod za pomocą errors.Is lub errors.As?
  • Czy anulowanie kontekstu i limity czasu są obsługiwane poprawnie?
  • Czy błędy do ponownej próby są jawnie identyfikowane?
  • Czy błędy walidacji są strukturalne?

Logowanie

  • Czy błąd jest logowany raz, na granicy obsługi?
  • Czy logi są strukturalne?
  • Czy poufne szczegóły są wykluczone z odpowiedzi użytkowników?
  • 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 o ponownej próbie są testowane?

Moje zdeterminowane zasady

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

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

Zasada 2: Opakowuj dla kontekstu, nie dla dekoracji

Jeśli opakowanie nie dodaje przydatnych informacji o tym, która operacja nie powiodła się, nie opakowuj. 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ło 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 pod spodem.

Zasada 4: Nie parsuj ciągów błędów

Jeśli kod musi rozgałęzić kod na podstawie typu awarii, używaj sygnalizatorów, niestandardowych typów, errors.Is lub errors.As. Inspekcja ciągów znaków tworzy niewidzialne sprzężenie, które cicho się psuje, gdy komunikaty błędów się zmieniają.

Zasada 5: Loguj raz

Opakowuj, gdy błędy wędrują w górę. Loguj tam, gdzie błąd jest ostatecznie obsługiwany.

Zasada 6: Utrzymuj komunikaty użytkownika bezpieczne

Wewnętrzne błędy diagnostyczne są dla logów. Komunikaty widoczne dla użytkownika są dla użytkowników.

Zasada 7: Utrzymuj 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 domenowy 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 wiecznym pisaniu if err != nil — polega na czynieniu niepowodzenia jawnym i zrozumiałym 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 trudniejsza:

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 sprytne, nie magiczne, ale wystarczająco jasne, aby następny deweloper, operator, klient API i przyszłe “ty” mogli zrozumieć, co nie powiodło się i co powinno się stać dalej. Aby uzyskać szerszy obraz produkcyjnych wzorców 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.