Řešení chyb v Go je jednoduché a flexibilní – ale žádná struktura!
Má to být jednoduché, ne? Stačí vrátit error
, zabalenou se zprávou a jít dál. Tato jednoduchost se rychle mění v chaotickou, jak se naše kódová základna rozrůstá o další balíčky, více vývojářů a více „rychlých oprav“, které tam zůstanou navždy. Postupem času jsou protokoly plné "nepodařilo se to udělat" a "neočekávaného tamto" a nikdo neví, jestli je to chyba uživatele, serveru, chybný kód nebo je to jen špatné zarovnání hvězd!
S nekonzistentními zprávami se vytvářejí chyby. Každý balíček má vlastní sadu stylů, konstant nebo vlastních typů chyb. Chybové kódy se přidávají libovolně. Není snadný způsob, jak zjistit, které chyby mohou být vráceny z které funkce, aniž byste se museli zabývat její implementací!
Takže jsem přijal výzvu vytvořit nový chybový rámec. Rozhodli jsme se použít strukturovaný, centralizovaný systém využívající kódy jmenného prostoru, aby byly chyby smysluplné, sledovatelné a – co je nejdůležitější – nám poskytly klid!
Toto je příběh o tom, jak jsme začali s jednoduchým přístupem k řešení chyb, byli jsme důkladně frustrovaní, jak problémy narůstaly, a nakonec jsme vytvořili vlastní rámec chyb. Rozhodnutí o návrhu, způsob jeho implementace, ponaučení a proč to změnilo náš přístup ke správě chyb. Doufám, že to přinese nějaké nápady i vám!
Go má jednoduchý způsob, jak se vypořádat s chybami: chyby jsou jen hodnoty. Chyba je pouze hodnota, která implementuje error
rozhraní pomocí jediné metody Error() string
. Místo vyvolání výjimky a přerušení aktuálního toku provádění vrátí funkce Go vedle jiných výsledků error
hodnotu. Volající se pak může rozhodnout, jak s ní zacházet: zkontrolovat její hodnotu, aby se mohl rozhodnout, zabalit do nových zpráv a kontextu, nebo jednoduše vrátit chybu, přičemž logiku zpracování ponechat nadřazeným volajícím.
Z jakéhokoli typu můžeme udělat error
tím, že na něj přidáme Error() string
. Tato flexibilita umožňuje každému balíčku definovat svou vlastní strategii řešení chyb a vybrat si to, co mu nejlépe vyhovuje. To se také dobře integruje s filozofií skládání Go's, což usnadňuje zabalení, rozšíření nebo přizpůsobení chyb podle potřeby.
Běžnou praxí je vrátit chybovou hodnotu, která implementuje error
rozhraní a nechá volajícího rozhodnout, co dál. Zde je typický příklad:
func loadCredentials() (Credentials, error) { data, err := os.ReadFile("cred.json") if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("file not found: %w", err) } if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } cred, err := verifyCredentials(cred); if err != nil { return nil, fmt.Errorf("invalid credentials: %w", err) } return cred, nil }
Go poskytuje několik nástrojů pro práci s chybami:
errors.New()
a fmt.Errorf()
pro generování jednoduchých chyb.fmt.Errorf()
a slovesa %w
.errors.Join()
sloučí více chyb do jediné.errors.Is()
porovnává chybu se specifickou hodnotou, errors.As()
porovnává chybu s určitým typem a errors.Unwrap()
načte základní chybu.
V praxi se obvykle setkáváme s těmito vzory:
errors.New()
nebo fmt.Errorf()
.V začátcích, stejně jako mnoho vývojářů Go, jsme se řídili běžnými postupy Go a udržovali jsme řešení chyb minimální, ale funkční. Pár let to fungovalo dost dobře.
Zahrňte stacktrace pomocí pkg/errors , v té době populárního balíčku.
Exportujte konstanty nebo proměnné pro chyby specifické pro balíček.
Pomocí errors.Is()
zkontrolujte konkrétní chyby.
Zabalte chyby do nových zpráv a kontextu.
Pro chyby API definujeme typy chyb a kódy pomocí Protobuf enum.
Včetně stacktrace s pkg/errors
K zahrnutí stacktrace do našich chyb jsme použili pkg/errors , v té době oblíbený balíček pro zpracování chyb. To bylo užitečné zejména při ladění, protože nám to umožnilo sledovat původ chyb v různých částech aplikace.
Pro vytváření, zalamování a šíření chyb pomocí stacktrace jsme implementovali funkce jako Newf()
, NewValuef()
a Wrapf()
. Zde je příklad naší rané implementace:
type xError struct { msg message, stack: callers(), } func Newf(msg string, args ...any) error { return &xError{ msg: fmt.Sprintf(msg, args...), stack: callers(), // 👈 stacktrace } } func NewValuef(msg string, args ...any) error { return fmt.Errorf(msg, args...) // 👈 no stacktrace } func Wrapf(err error, msg string, args ...any) error { if err == nil { return nil } stack := getStack(err) if stack == nil { stack = callers() } return &xError{ msg: fmt.Sprintf(msg, args...), stack: stack, } }
Export chybových proměnných
Každý balíček v naší kódové základně definoval své vlastní chybové proměnné, často s nekonzistentními styly.
package database var ErrNotFound = errors.NewValue("record not found") var ErrMultipleFound = errors.NewValue("multiple records found") var ErrTimeout = errors.NewValue("request timeout")
package profile var ErrUserNotFound = errors.NewValue("user not found") var ErrBusinessNotFound = errors.NewValue("business not found") var ErrContextCancel = errors.NewValue("context canceled")
Kontrola chyb pomocí errors.Is()
a zabalení do dalšího kontextu
res, err := repo.QueryUser(ctx, req) switch { case err == nil: // continue case errors.Is(database.NotFound): return nil, errors.Wrapf(ErrUserNotFound, "user not found (id=%v)", req.UserID) default: return nil, errors.Wrapf(ctx, "failed to query user (id=%v)", req.UserID) }
To pomohlo propagovat chyby podrobněji, ale často to vedlo k upovídanosti, duplikaci a menší srozumitelnosti v protokolech:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Definování externích chyb pomocí Protobuf
Pro externí API jsme přijali chybový model založený na Protobufu inspirovanýMeta's Graph API :
message Error { string message = 1; ErrorType type = 2; ErrorCode code = 3; string user_title = 4; string user_message = 5; string trace_id = 6; map<string, string> details = 7; } enum ErrorType { ERROR_TYPE_UNSPECIFIED = 1; ERROR_TYPE_AUTHENTICATION = 2; ERROR_TYPE_INVALID_REQUEST = 3; ERROR_TYPE_RATE_LIMIT = 4; ERROR_TYPE_BUSINESS_LIMIT = 5; ERROR_TYPE_WEBHOOK_DELIVERY = 6; } enum ErrorCode { ERROR_CODE_UNSPECIFIED = 1 [(error_type = UNSPECIFIED)]; ERROR_CODE_UNAUTHENTICATED = 2 [(error_type = AUTHENTICATION)]; ERROR_CODE_CAMPAIGN_NOT_FOUND = 3 [(error_type = NOT_FOUND)]; ERROR_CODE_META_CHOSE_NOT_TO_DELIVER = 4 /* ... */; ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS = 5; }
Tento přístup pomohl strukturovat chyby, ale postupem času byly typy chyb a kódy přidávány bez jasného plánu, což vedlo k nekonzistentnosti a duplicitě.
Všude byly deklarovány chyby
gorm.ErrRecordNotFound
nebo user.ErrNotFound
nebo obojí?
Náhodné zalamování chyb vedlo k nekonzistentním a svévolným protokolům
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
Žádná standardizace nevedla k nesprávnému zpracování chyb
Žádná kategorizace neznemožnila sledování
context.Canceled
. Zrušená chyba může být normální chování, když uživatel zavře kartu prohlížeče, ale je důležité, pokud je požadavek zrušen, protože dotaz je náhodně pomalý.Abychom se vypořádali s rostoucími výzvami, rozhodli jsme se vytvořit lepší chybovou strategii na základě základní myšlenky centralizovaných a strukturovaných chybových kódů .
Error
pomocí komplexní sady pomocníků.Všechny chybové kódy jsou definovány na centralizovaném místě se strukturou jmenného prostoru.
Použijte jmenné prostory k vytvoření jasných, smysluplných a rozšiřitelných chybových kódů. Příklad:
PRFL.USR.NOT_FOUND
pro "Uživatel nenalezen."FLD.NOT_FOUND
pro "Dokument toku nenalezen."DEPS.PG.NOT_FOUND
, což znamená "Záznam nebyl nalezen v PostgreSQL."
Každá vrstva služby nebo knihovna musí vracet pouze své vlastní kódy jmenného prostoru .
gorm.ErrRecordNotFound
ze závislosti jej musí balíček „databáze“ zabalit jako DEPS.PG.NOT_FOUND
. Později jej musí služba „profil/user“ znovu zabalit jako PRFL.USR.NOT_FOUND
.
Všechny chyby musí implementovat rozhraní Error
.
error
) a našimi interními Error
.
Chyba může zalomit jednu nebo více chyb. Společně tvoří strom.
[FLD.INVALID_ARGUMENT] invalid argument → [TPL.INVALID_PARAMS] invalid input params 1. [TPL.PARAM.EMPTY] name can not be empty 2. [TPL.PARAM.MALFORM] invalid format for param[2]
Vždy vyžadovat kontext. context.Context
. Může k chybě připojit kontext.
trace_id
a neměli jsme tušení, odkud pochází.
Když jsou chyby odesílány přes hranice služby, je odhalen pouze kód chyby nejvyšší úrovně.
Pro externí chyby používejte aktuální ErrorCode a ErrorType Protobuf.
Automaticky mapovat chybové kódy jmenného prostoru na kódy Protobuf, stavové kódy HTTP a značky.
ErrorCode
, ErrorType
, stav gRPC, stav HTTP a značky pro protokolování/metriky.Existuje několik základních balíčků, které tvoří základ našeho nového rámce pro zpracování chyb.
connectly.ai/go/pkgs/
errors
: Hlavní balíček, který definuje typ Error
a kódy.errors/api
: Pro odesílání chyb do rozhraní front-end nebo externího rozhraní API.errors/E
: Pomocný balíček určený k použití s importem bodů.testing
: Testovací nástroje pro práci s chybami jmenného prostoru.
Error
a Code
Rozhraní Error
je rozšířením standardního rozhraní error
s dalšími metodami pro vrácení Code
. Code
je implementován jako uint16
.
package errors // import "connectly.ai/go/pkgs/errors" type Error interface { error Code() Code } type Code struct { code uint16 } type CodeI interface { CodeDesc() CodeDesc } type GroupI interface { /* ... */ } type CodeDesc struct { /* ... */ }
Package errors/E
exportuje všechny chybové kódy a běžné typy
package E // import "connectly.ai/go/pkgs/errors/E" import "connectly.ai/go/pkgs/errors" type Error = errors.Error var ( DEPS = errors.DEPS PRFL = errors.PRFL ) func MapError(ctx context.Context, err error) errors.Mapper { /* ... */ } func IsErrorCode(err error, codes ...errors.CodeI) { /* ... */ } func IsErrorGroup(err error, groups ...errors.GroupI) { /* ... */ }
Příklady chybových kódů:
// dependencies → postgres DEPS.PG.NOT_FOUND DEPS.PG.UNEXPECTED // sdk → hash SDK.HASH.UNEXPECTED // profile → user PRFL.USR.NOT_FOUND PFRL.USR.UNKNOWN // profile → user → repository PRFL.USR.REPO.NOT_FOUND PRFL.USR.REPO.UNKNOWN // profile → auth PRFL.AUTH.UNAUTHENTICATED PRFL.AUTH.UNKNOWN PRFL.AUTH.UNEXPECTED
database
balíčků:
package database // import "connectly.ai/go/pkgs/database" import "gorm.io/gorm" import . "connectly.ai/go/pkgs/errors/E" type DB struct { gorm: gorm.DB } func (d *DB) Exec(ctx context.Context, sql string, params ...any) *DB { tx := d.gorm.WithContext(ctx).Exec(sql, params...) return wrapTx(tx) } func (x *DB) Error(msgArgs ...any) Error { return wrapError(tx.Error()) // 👈 convert gorm error to 'Error' } func (x *DB) SingleRowError(msgArgs ...any) Error { if err := x.Error(); err != nil { return err } switch { case x.RowsAffected == 1: return nil case x.RowsAffected == 0: return DEPS.PG.NOT_FOUND.CallerSkip(1). New(x.Context(), formatMsgArgs(msgArgs)) default: return DEPS.PG.UNEXPECTED.CallerSkip(1). New(x.Context(), formatMsgArgs(msgArgs)) } }
Balíček pb/services/profile
:
package profile // import "connectly.ai/pb/services/profile" // these types are generated from services/profile.proto type QueryUserRequest struct { BusinessId string UserId string } type LoginRequest struct { Username string Password string }
Balíčková service/profile
:
package profile import uuid "github.com/google/uuid" import . "connectly.ai/go/pkgs/errors/E" import l "connectly.ai/go/pkgs/logging/l" import profilepb "connectly.ai/pb/services/profile" // repository requests type QueryUserByUsernameRequest struct { Username string } // repository layer → query user func (r *UserRepository) QueryUserByUsernameAuth( ctx context.Context, req *QueryUserByUsernameRequest, ) (*User, Error) { if req.Username == "" { return PRFL.USR.REPO.INVALID_ARGUMENT.New(ctx, "empty request") } var user User sqlQuery := `SELECT * FROM "user" WHERE username = ? LIMIT 1` tx := r.db.Exec(ctx, sqlQuery, req.Username).Scan(&user) err := tx.SingleRowError() switch { case err == nil: return &user, nil case IsErrorCode(DEPS.PG.NOT_FOUND): return PRFL.USR.REPO.USER_NOT_FOUND. With(l.String("username", req.Username)) Wrap(ctx, "user not found") default: return PRFL.USR.REPO.UNKNOWN. Wrap(ctx, "failed to query user") } } // user service layer → query user func (u *UserService) QueryUser( ctx context.Context, req *profilepb.QueryUserRequest, ) (*profilepb.QueryUserResponse, Error) { // ... rr := QueryUserByUsernameRequest{ Username: req.Username } err := u.repo.QueryUserByUsername(ctx, rr) if err != nil { return nil, MapError(ctx, err). Map(PRFL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "the user %q cannot be found", req.UserName, api.UserTitle("User Not Found"), api.UserMsg("The requested user id %q can not be found", req.UserId)). KeepGroup(PRFL.USR). Default(PRFL.USR.UNKNOWN, "failed to query user") } // ... return resp, nil } // auth service layer → login user func (a *AuthService) Login( ctx context.Context, req *profilepb.LoginRequest, ) (*profilepb.LoginResponse, *profilepb.LoginResponse, Error) { vl := PRFL.AUTH.INVALID_ARGUMENT.WithMsg("invalid request") vl.Vl(req.Username != "", "no username", api.Detail("username is required")) vl.Vl(req.Password != "", "no password", api.Detail("password is required")) if err := vl.ToError(ctx); err != nil { return err } hashpwd, err := hash.Hash(req.Password) if err != nil { return PRFL.AUTH.UNEXPECTED.Wrap(ctx, err, "failed to calc hash") } usrReq := profilepb.QueryUserByUsernameRequest{/*...*/} usrRes, err := a.userServiceClient.QueryUserByUsername(ctx, usrReq) if err != nil { return nil, MapError(ctx, err). Map(PRFL.USR.NOT_FOUND, PRFL.AUTH.UNAUTHENTICATED, "unauthenticated"). Default(PRFL.AUTH.UNKNOWN, "failed to query by username") } // ... }
Ve výše uvedeném kódu je spousta nových funkcí a konceptů. Pojďme si je projít krok za krokem.
Nejprve importujte errors/E
balíčku pomocí importu teček
To vám umožní přímo používat běžné typy jako Error
místo errors.Error
a přístup ke kódům pomocí PRFL.USR.NOT_FOUND
namísto errors.PRFL.USR.NOT_FOUND
.
import . "connectly.ai/go/pkgs/errors/E"
Vytvořte nové chyby pomocí CODE.New()
Předpokládejme, že obdržíte neplatný požadavek, můžete vytvořit novou chybu takto:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
je Code
.Code
odhaluje metody jako New()
nebo Wrap()
pro vytvoření nové chyby.New()
obdrží context.Context
jako první argument, za nímž následuje zpráva a volitelné argumenty.
Vytiskněte jej pomocí fmt.Print(err)
:
[PRFL.USR.INVALID_ARGUMENT] invalid request
nebo pomocí fmt.Printf("%+v")
zobrazíte další podrobnosti:
[PRFL.USR.INVALID_ARGUMENT] invalid request connectly.ai/go/services/profile.(*UserService).QueryUser /usr/i/src/go/services/profile/user.go:1234 connectly.ai/go/services/profile.(*UserRepository).QueryUser /usr/i/src/go/services/profile/repo/user.go:2341
Zabalte chybu do nové chyby pomocí CODE.Wrap()
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found") usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
vytvoří tento výstup pomocí fmt.Print(usrErr)
:
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
nebo pomocí fmt.Printf("%+v", usrErr)
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found connectly.ai/go/services/profile.(*UserService).QueryUser /usr/i/src/go/services/profile/user.go:1234
Stacktrace bude pocházet z nejvnitřnější Error
. Pokud píšete pomocnou funkci, můžete použít CallerSkip(skip)
k přeskakování snímků:
func mapUserError(ctx context.Context, err error) Error { switch { case IsErrorCode(err, DEPS.PG.NOT_FOUND): return PRFL.USR.NOT_FOUND.CallerSkip(1).Wrap(ctx, err, "...") default: return PRFL.USR.UNKNOWN.CallerSkip(1).Wrap(ctx, err, "...") } }
Přidejte kontext k chybě pomocí With()
.With(l.String(...))
.logging/l
je pomocný balíček pro export funkcí cukru pro protokolování.l.String("flag", flag)
vrací Tag{String: flag}
a l.UUID("user_id, userID)
return Tag{Stringer: userID}
. import l "connectly.ai/go/pkgs/logging/l" usrErr := PRFL.USR.NOT_FOUND. With(l.UUID("user_id", req.UserID), l.String("flag", flag)). Wrap(ctx, dbErr, "user not found")
Značky lze vytisknout pomocí fmt.Printf("%+v", usrErr)
:
[PRFL.USR.NOT_FOUND] user not found {"user_id": "81febc07-5c06-4e01-8f9d-995bdc2e0a9a", "flag": "ABRW"} → [DEPS.PG.NOT_FOUND] not found {"a number": 42} → record not found
Přidejte kontext k chybám přímo do New()
, Wrap()
nebo MapError()
:
Pomocí funkce l.String()
a její rodiny mohou funkce New()
a podobné funkce chytře detekovat značky mezi argumenty formátování. Různé funkce netřeba představovat.
err := INF.HEALTH.NOT_READY.New(ctx, "service %q is not ready (retried %v times)", req.ServiceName, l.String("flag", flag) countRetries, l.Number("count", countRetries), )
vydá:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
, VlError
, ApiError
V současné době existují 3 typy, které implementují rozhraní Error
. V případě potřeby můžete přidat více typů. Každý z nich může mít jinou strukturu s vlastními metodami pro specifické potřeby.
Error
je rozšíření standardního error
rozhraní Go
type Error interface { error Code() Message() Fields() []tags.Field StackTrace() stacktrace.StackTrace _base() *base // a private method }
Obsahuje soukromou metodu, která zajišťuje, že omylem neimplementujeme nové typy Error
mimo balíček errors
. Toto omezení můžeme (nebo nemusíme) v budoucnu zrušit, až se setkáme s více způsoby používání.
Proč prostě nepoužijeme standardní error
rozhraní a nepoužijeme typové tvrzení?
Protože chceme oddělit chyby třetích stran a naše interní chyby. Všechny vrstvy a balíčky v našich interních kódech musí vždy vrátit Error
. Tímto způsobem můžeme bezpečně vědět, kdy musíme převést chyby třetích stran a kdy se potřebujeme vypořádat pouze s našimi interními chybovými kódy.
Vytváří také hranici mezi migrovanými balíčky a ještě nemigrovanými balíčky. Zpátky do reality, nemůžeme jen deklarovat nový typ, mávnout kouzelnou hůlkou, zašeptat výzvu ke kouzlu a pak jsou všechny miliony řádků kódu magicky převedeny a fungují hladce bez chyb! Ne, ta budoucnost tu ještě není. Možná to jednou přijde, ale zatím stále musíme migrovat naše balíčky jeden po druhém.
Error0
je výchozí typ Error
Většina chybových kódů vytvoří hodnotu Error0
. Obsahuje base
a volitelnou dílčí chybu. Můžete použít NewX()
k vrácení konkrétní struktury *Error0
namísto rozhraní Error
, ale musíte být opatrní .
type Error0 struct { base err error } var errA: Error = DEPS.PG.NOT_FOUND.New (ctx, "not found") var errB: *Error0 = DEPS.PG.NOT_FOUND.NewX(ctx, "not found")
base
je společná struktura sdílená všemi implementacemi Error
, která poskytuje společné funkce: Code()
, Message()
, StackTrace()
, Fields()
a další.
type base struct { code Code msg string kv []tags.Field stack stacktrace.StackTrace }
VlError
je pro chyby ověření
Může obsahovat více dílčích chyb a poskytuje pěkné metody pro práci s validačními pomocníky.
type VlError struct { base errs []error }
Můžete vytvořit chybu VlError
podobnou jiné Error
:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
Nebo vytvořte VlBuilder
, přidejte do něj chyby a poté jej převeďte na VlError
:
userID, err0 := parseUUID(req.UserId) err1 := validatePassword(req.Password) vl := PRFL.USR.INVALID_ARGUMENT.WithMsg("invalid request") vl.Add(err0, err1) vlErr := vl.ToError(ctx)
A jako obvykle zahrňte páry klíč/hodnota:
vl := PRFL.USR.INVALID_ARGUMENT. With(l.Bool("testingenv", true)). WithMsg("invalid request") userID, err0 := parseUUID(req.UserId) err1 := validatePassword(req.Password) vl.Add(err0, err1) vlErr := vl.ToError(ctx, l.String("user_id", req.UserId))
Použití fmt.Printf("%+v", vlErr)
vypíše:
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
je adaptér pro migraci chyb API
Dříve jsme používali samostatnou strukturu api.Error
pro vracení chyb API do front-endu a externích klientů. Zahrnuje ErrorType
jako ErrorCode
jak bylo zmíněno výše .
package api import errorpb "connectly.ai/pb/models/error" // Deprecated type Error struct { pbType errorpb.ErrorType pbCode errorpb.ErrorCode cause error msg string usrMsg string usrTitle string // ... }
Tento typ je nyní zastaralý. Místo toho deklarujeme všechna mapování ( ErrorType
, ErrorCode
, gRPC kód, HTTP kód) na centralizovaném místě a převedeme je na odpovídajících hranicích. O deklaraci kódu budu diskutovat v další části .
Abychom provedli migraci na nový rámec chyb jmenného prostoru, přidali jsme dočasný jmenný prostor ZZZ.API_TODO
. Každý ErrorCode
se stane kódem ZZZ.API_TODO
.
ZZZ.API_TODO.UNEXPECTED ZZZ.API_TODO.INVALID_REQUEST ZZZ.API_TODO.USERNAME_ ZZZ.API_TODO.META_CHOSE_NOT_TO_DELIVER ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS
A ApiError
je vytvořen jako adaptér. Všechny funkce, které dříve vracely *api.Error
byly změněny tak, aby vracely Error
(implementované *ApiError
).
package api import . "connectly.ai/go/pkgs/errors/E" // previous func FailPreconditionf(err error, msg string, args ...any) *Error { return &Error{ pbType: ERROR_TYPE_FAILED_PRECONDITION, pbCode: ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS, cause: err, msg: fmt.Sprintf(msg, args...) } } // current: this is deprecated, and serves and an adapter func FailPreconditionf(err error, msg string, args ...any) *Error { ctx := context.TODO() return ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS. CallerSkip(1). // correct the stacktrace by 1 frame Wrap(ctx, err, msg, args...) }
Když je celá migrace hotová, předchozí použití:
wabaErr := verifyWabaTemplateStatus(tpl) apiErr := api.FailPreconditionf(wabaErr, "template cannot be edited"). WithErrorCode(ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS). WithUserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."). ErrorOrNil()
by se měl stát:
CPG.TPL.EDIT_ONCE_IN_24_HOURS.Wrap( wabaErr, "template cannot be edited", api.UserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."))
Všimněte si, že ErrorCode
je implicitně odvozen z interního kódu jmenného prostoru. Není potřeba to pokaždé explicitně přiřazovat. Jak ale deklarovat vztah mezi kódy? To bude vysvětleno v další části.
V tuto chvíli již víte, jak vytvořit nové chyby ze stávajících kódů. Je čas vysvětlit kódy a jak přidat nový.
Kód Code
implementován jako hodnota uint16
, která má odpovídající prezentaci řetězce.
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
Pro uložení těchto řetězců existuje pole všech dostupných CodeDesc
:
const MaxCode = 321 // 👈 this value is generated var allCodes [MaxCode]CodeDesc type CodeDesc { c int // 42 code string // DEPS.PG.NOT_FOUND api APICodeDesc } type APICodeDesc { ErrorType errorpb.ErrorType ErrorCode errorpb.ErrorCode HttpCode int DefMessage string UserMessage string UserTitle string }
Kódy se deklarují takto:
var DEPS deps // dependencies var PRFL prfl // profile var FLD fld // flow document type deps struct { PG pg // postgres RD rd // redis } // tag:postgres type pg struct { NOT_FOUND Code0 // record not found CONFLICT Code0 // record already exist MALFORM_SQL Code0 } // tag:profile type PRFL struct { REPO prfl_repo USR usr AUTH auth } // tag:profile type prfl_repo struct { NOT_FOUND Code0 // internal error code INVALID_ARGUMENT VlCode // internal error code } // tag:usr type usr struct { NOT_FOUND Code0 `api-code:"USER_NOT_FOUND"` INVALID_ARGUMENT VlCode `api-code:"INVALID_ARGUMENT"` DISABlED_ACCOUNT Code0 `api-code:"DISABLED_ACCOUNT"` } // tag:auth type auth struct { UNAUTHENTICATED Code0 `api-code:"UNAUTHENTICATED"` PERMISSION_DENIED Code0 `api-code:"PERMISSION_DENIED"` }
Po deklaraci nových kódů je třeba spustit generovací skript:
run gen-errors
Vygenerovaný kód bude vypadat takto:
// Code generated by error-codes. DO NOT EDIT. func init() { // ... PRFL.AUTH.UNAUTHENTICATED = Code0{Code{code: 143}} PRFL.AUTH.PERMISSION_DENIED = Code0{Code{code: 144}} // ... allCodes[143] = CodeDesc{ c: 143, code: "PRFL.AUTH.UNAUTHENTICATED", tags: []string{"auth", "profile"}, api: APICodeDesc{ ErrorType: ERROR_TYPE_UNAUTHENTICATED, ErrorCode: ERROR_CODE_UNAUTHENTICATED, HTTPCode: 401, DefMessage: "Unauthenticated error", UserMessage: "You are not authenticated.", UserTitle: "Unauthenticated error", })) }
Každý typ Error
má odpovídající typ Code
Přemýšleli jste někdy nad tím, jak PRFL.USR.NOT_FOUND.New()
vytvoří *Error0
a PRFL.USR.INVALID_ARGUMENTS.New()
vytvoří *VlError
? Je to proto, že používají různé typy kódu.
A každý typ Code
vrací jiný typ Error
, každý může mít své vlastní extra metody:
type Code0 struct { Code } type VlCode struct { Code } func (c Code0) New(/*...*/) Error { return &Error0{/*...*/} } func (c VlCode) New(/*...*/) Error { return &VlError{/*...*/} } // extra methods on VlCode to create VlBuilder func (c VlCode) WithMsg(msg string, args ...any) *VlBuilder {/*...*/} type VlBuilder struct { code VlCode msg string args []any } func (b *VlBuilder) ToError(/*...*/) Error { return &VlError{Code: code, /*...*/ } }
Použijte api-code
k označení kódů dostupných pro externí API
Kód chyby jmenného prostoru by měl být použit interně.
Chcete-li kód zpřístupnit pro vracení v externím rozhraní HTTP API, musíte jej označit pomocí api-code
. Hodnota je odpovídající errorpb.ErrorCode
.
Pokud kód chyby není označen api-code
, jedná se o interní kód a zobrazí se jako obecná Internal Server Error
.
Všimněte si, že PRFL.USR.NOT_FOUND
je externí kód, zatímco PRFL.USR.REPO.NOT_FOUND
je interní kód.
Deklarujte mapování mezi kódy ErrorCode
, ErrorType
a gRPC/HTTP v protobufu pomocí volby enum:
// error/type.proto ERROR_TYPE_PERMISSION_DENIED = 707 [(error_type_detail_option) = { type: "PermissionDeniedError", grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "permission denied", user_title: "Permission denied", user_message: "The caller does not have permission to execute the specified operation.", }]; // error/code.proto ERROR_CODE_DISABlED_ACCOUNT = 70020 [(error_code_detail_option) = { error_type: ERROR_TYPE_DISABlED_ACCOUNT, grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "account is disabled", user_title: "Account is disabled", user_message: "Your account is disabled. Please contact support for more information.", }];
UNEXPECTED
a UNKNOWN
kódy
Každá vrstva má obvykle 2 generické kódy UNEXPECTED
a UNKNOWN
. Slouží trochu jiným účelům:
UNEXPECTED
kód se používá pro chyby, ke kterým by nikdy nemělo dojít.UNKNOWN
kód se používá pro chyby, které nejsou explicitně zpracovány.Když přijmete chybu vrácenou funkcí, musíte ji ošetřit: převést chyby třetích stran na chyby interního jmenného prostoru a mapovat chybové kódy z vnitřních vrstev na vnější vrstvy.
Převeďte chyby třetích stran na chyby interního oboru názvů
Jak nakládáte s chybami, závisí na tom, co balíček třetí strany vrací a co vaše aplikace potřebuje. Například při zpracování chyb databáze nebo externího rozhraní API:
switch { case errors.Is(err, sql.ErrNoRows): // map a database "no rows" error to an internal "not found" error return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found") case errors.Is(err, context.DeadlineExceeded): // map a context deadline exceeded error to a timeout error return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout") default: // wrap any other error as unknown return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error") }
Použití pomocníků pro interní chyby jmenného prostoru
IsErrorCode(err, CODES...)
: Zkontroluje, zda chyba obsahuje některý ze zadaných kódů.IsErrorGroup(err, GROUP)
: Vrátí hodnotu true, pokud chyba patří do vstupní skupiny.
Typický vzor použití:
user, err := queryUser(ctx, userReq) switch { case err == nil: // continue case IsErrorCode(PRL.USR.REPO.NOT_FOUND): // check for specific error code and convert to external code // and return as HTTP 400 Not Found return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found") case IsGroup(PRL.USR): // errors belong to the PRFL.USR group are returned as is return nil, err default: return nil, PRL.USR.UNKNOWN.Wrap(ctx, err, "failed to query user") }
MapError()
pro jednodušší psaní mapovacího kódu:
Vzhledem k tomu, že mapování chybových kódů je běžný vzor, existuje pomocník MapError()
který urychlí psaní kódu. Výše uvedený kód lze přepsat jako:
user, err := queryUser(ctx, userReq) if err != nil { return nil, MapError(ctx, err). Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user not found"). KeepGroup(PRF.USR). Default(PRL.USR.UNKNOWN, "failed to query user") }
Můžete formátovat argumenty a přidávat páry klíč/hodnota jako obvykle:
return nil, MapError(ctx, err). Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user %v not found", username, l.String("flag", flag)). KeepGroup(PRF.USR). Default(PRL.USR.UNKNOWN, "failed to query user", l.Any("retries", retryCount))
Error
s Testování je rozhodující pro jakoukoli seriózní kódovou základnu. Rámec poskytuje specializované pomocníky, jako je ΩxError()
aby bylo psaní a potvrzení chybových podmínek v testech jednodušší a výraznější.
// 👉 return true if the error contains the message ΩxError(err).Contains("not found") // 👉 return true if the error does not contain the message ΩxError(err).NOT().Contains("not found")
Existuje mnoho dalších metod a můžete je také zřetězit:
ΩxError(err). MatchCode(DEPS.PG.NOT_FOUND). // match any code in top or wrapped errors TopErrorMatchCode(PRFL.TPL.NOT_FOUND) // only match code from the top error MatchAPICode(API_CODE.WABA_TEMPLATE_NOTE_FOUND). // match errorpb.ErrorCode MatchExact("exact message to match")
Proč používat metody místo Ω(err).To(testing.MatchCode())
?
Protože metody jsou objevnější. Když se potýkáte s desítkami funkcí, jako je testing.MatchValues()
, je těžké vědět, které z nich budou fungovat s Error
s a které ne. Pomocí metod můžete jednoduše napsat tečku .
a vaše IDE vypíše všechny dostupné metody speciálně navržené pro uplatnění Error
.
Rámec je jen polovina příběhu. Psaní kódu? To je ta snadná část. Skutečná výzva začíná, když jej musíte přenést do masivní, živé kódové základny, kde desítky inženýrů denně prosazují změny, zákazníci očekávají, že vše bude perfektně fungovat, a systém prostě nemůže přestat fungovat.
Migrace přichází se zodpovědností. Je to o pečlivém rozdělování malých kousků kódu vlasů , provádění drobných změn najednou a prolomení spousty testů v procesu. Pak je ručně kontrolujte a opravujte jeden po druhém, začleňte do hlavní větve, nasaďte do výroby, sledujte protokoly a výstrahy. Opakovat to znovu a znovu...
Zde je několik tipů pro migraci, které jsme se během cesty naučili:
Začněte hledáním a nahrazením: Začněte nahrazením starých vzorů novým rámcem. Opravte všechny problémy s kompilací, které z tohoto procesu vyplývají.
Například všechny error
v tomto balíčku nahraďte chybou Error
.
type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, error) }
Nový kód bude vypadat takto:
import . "connectly.ai/go/pkgs/errors" type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, Error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error) }
Migrujte jeden balíček po druhém: Začněte s balíčky nejnižší úrovně a postupujte nahoru. Tímto způsobem můžete zajistit, že balíčky nižší úrovně budou plně migrovány před přechodem na balíčky vyšší úrovně.
Přidejte chybějící testy jednotek: Pokud části kódové základny postrádají testy, přidejte je. Pokud si svými změnami nejste jisti, přidejte další testy. Jsou užitečné, abyste se ujistili, že vaše změny nenaruší stávající funkce.
Pokud váš balíček závisí na volání balíčků vyšší úrovně: Zvažte změnu souvisejících funkcí na DEPRECATED a poté přidejte nové funkce s novým typem Error
.
Předpokládejme, že provádíte migraci databázového balíčku, který má metodu Transaction()
:
package database func (db *DB) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { return db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) }
A používá se v balíčku uživatelských služeb:
err = s.DB(ctx).Transaction(func(tx *database.DB) error { user, usrErr := s.repo.CreateUser(ctx, tx, user) if usrErr != nil { return usrErr } }
Protože nejprve migrujete database
balíček, ponecháte user
a desítky dalších balíčků tak, jak je. Volání s.repo.CreateUser()
stále vrací starý typ error
, zatímco metoda Transaction()
potřebuje vrátit nový typ Error
. Metodu Transaction()
můžete změnit na DEPRECATED
a přidat novou metodu TransactionV2()
:
package database // DEPRECATED: use TransactionV2 instead func (db *DB) Transaction_DEPRECATED(ctx context.Context, fn func(tx *gorm.DB) error) error { return db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) } func (db *DB) TransactionV2(ctx context.Context, fn func(tx *gorm.DB) error) Error { err := db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) return adaptToErrorV2(err) }
Přidávejte nové chybové kódy za pochodu : Když narazíte na chybu, která nezapadá do stávajících, přidejte nový kód. To vám pomůže v průběhu času vytvořit komplexní sadu chybových kódů. Kódy z jiných balíčků jsou vždy k dispozici jako reference.
Zpracování chyb v Go může být zpočátku jednoduché – stačí vrátit error
a jít dál. Ale jak se naše kódová základna rozrůstala, tato jednoduchost se změnila ve spletitou změť vágních protokolů, nekonzistentního zacházení a nekonečných relací ladění.
Tím, že jsme ustoupili a přehodnotili, jak nakládáme s chybami, jsme vytvořili systém, který pracuje pro nás, ne proti nám. Centralizované a strukturované kódy jmenného prostoru nám poskytují přehlednost, zatímco nástroje pro mapování, zalamování a testování chyb nám usnadňují život. Místo toho, abychom proplouvali mořem klád, máme nyní smysluplné, dohledatelné chyby, které nám říkají, co je špatně a kde hledat.
Tento rámec není jen o tom, aby byl náš kód čistší; jde o úsporu času, snížení frustrace a pomoc při přípravě na neznámé. Je to jen začátek cesty – stále objevujeme další vzorce – ale výsledkem je systém, který může nějakým způsobem přinést klid do práce s chybami. Doufejme, že to může podnítit nějaké nápady pro vaše projekty! 😊
Jsem Oliver Nguyen. Tvůrce softwaru pracující převážně v Go a JavaScriptu. Rád se učím a vidím každý den lepší verzi sebe sama. Občas oddělte nové open source projekty. Sdílejte znalosti a myšlenky během mé cesty.
Příspěvek je také zveřejněn na blog.connectly.ai a olivernguyen.io 👋