paint-brush
Zabraňte růstu chyb s tímto novým rámcempodle@olvrng
270 čtení

Zabraňte růstu chyb s tímto novým rámcem

podle Oliver Nguyen30m2024/12/11
Read on Terminal Reader

Příliš dlouho; Číst

Toto je příběh o tom, jak jsme začali s jednoduchým přístupem k řešení chyb, byli jsme důkladně frustrováni, jak problémy narůstaly, a nakonec jsme vytvořili vlastní chybový rámec.
featured image - Zabraňte růstu chyb s tímto novým rámcem
Oliver Nguyen HackerNoon profile picture
0-item
1-item

Ř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 chyby jsou jen hodnoty

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.

Každý balíček se musí vypořádat s chybami

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:

  • Vytváření chyb: errors.New() a fmt.Errorf() pro generování jednoduchých chyb.
  • Chyby zalamování: Zabalte chyby do dalšího kontextu pomocí fmt.Errorf() a slovesa %w .
  • Kombinování chyb: errors.Join() sloučí více chyb do jediné.
  • Kontrola a zpracování chyb: 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:

  • Použití standardních balíčků: Vracení jednoduchých chyb s errors.New() nebo fmt.Errorf() .
  • Export konstant nebo proměnných: Například go-redis a gorm.io definují opakovaně použitelné chybové proměnné.
  • Vlastní typy chyb: Knihovny jako lib/pq grpc/status.Error vytvářejí specializované typy chyb, často s přidruženými kódy pro další kontext.
  • Chybová rozhraní s implementacemi: aws-sdk-go používá k definování typů chyb s různými implementacemi přístup založený na rozhraní.
  • Nebo více rozhraní: Jako Docker's errdefs , který definuje více rozhraní pro klasifikaci a správu chyb.

Začali jsme společným přístupem

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ě.


A problémy postupem času narůstaly

Všude byly deklarovány chyby

  • Každý balíček definoval své vlastní chybové konstanty bez centralizovaného systému.
  • Konstanty a zprávy byly rozptýleny po kódové základně, takže nebylo jasné, jaké chyby může funkce vrátit – fuj, je to gorm.ErrRecordNotFound nebo user.ErrNotFound nebo obojí?


Náhodné zalamování chyb vedlo k nekonzistentním a svévolným protokolům

  • Mnoho funkcí obaluje chyby libovolnými, nekonzistentními zprávami, aniž by deklarovaly své vlastní typy chyb.
  • Protokoly byly podrobné, nadbytečné a obtížně se vyhledávaly nebo monitorovaly.
  • Chybové zprávy byly obecné a často nevysvětlovaly, co se pokazilo nebo jak se to stalo. Také křehký a náchylný k nepozorovaným změná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

  • Každý balíček zacházel s chybami jinak, takže je těžké zjistit, zda funkce vrátila, zabalila nebo transformovala chyby.
  • Při šíření chyb se často ztrácel kontext.
  • Horní vrstvy obdržely vágních 500 interních chyb serveru bez jasných hlavních příčin.


Žádná kategorizace neznemožnila sledování

  • Chyby nebyly klasifikovány podle závažnosti nebo chová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ý.
  • Důležité problémy byly pohřbeny pod hlučnými záznamy, takže bylo těžké je identifikovat.
  • Bez kategorizace nebylo možné efektivně monitorovat frekvenci chyb, závažnost nebo dopad.

Je čas centralizovat zpracování chyb

Zpět na rýsovací prkno

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ů .

  • Chyby jsou deklarovány všude → Centralizujte hlášení chyb na jednom místě pro lepší organizaci a sledovatelnost.
  • Nekonzistentní a libovolné protokoly → Strukturované chybové kódy s jasným a konzistentním formátováním.
  • Nesprávné zpracování chyb → Standardizujte vytváření chyb a kontrolu nového typu Error pomocí komplexní sady pomocníků.
  • Žádná kategorizace → Kategorizace chybových kódů pomocí značek pro efektivní monitorování prostřednictvím protokolů a metrik.

Designová rozhodnutí

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."
  • Oba mohou sdílet základní základní kód 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 .

  • Každá vrstva služby, úložiště nebo knihovna deklaruje vlastní sadu chybových kódů.
  • Když vrstva obdrží chybu ze závislosti, musí ji zabalit do vlastního kódu jmenného prostoru, než ji vrátí.
  • Například: Při přijetí chyby 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 .

  • To vytváří jasnou hranici mezi chybami z knihoven třetích stran ( error ) a našimi interními Error .
  • To také pomáhá při migraci, aby se oddělily migrované balíčky a ty, které ještě nebyly migrovány.


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.

  • Mnohokrát jsme viděli protokoly se samostatnými chybami bez kontextu, bez trace_id a neměli jsme tušení, odkud pochází.
  • Může k chybám připojit další klíč/hodnotu, které lze použít v protokolech nebo monitorování.


Když jsou chyby odesílány přes hranice služby, je odhalen pouze kód chyby nejvyšší úrovně.

  • Volající nemusí vidět podrobnosti o interní implementaci této služby.


Pro externí chyby používejte aktuální ErrorCode a ErrorType Protobuf.

  • To zajišťuje zpětnou kompatibilitu, takže naši klienti nemusí přepisovat svůj kód.


Automaticky mapovat chybové kódy jmenného prostoru na kódy Protobuf, stavové kódy HTTP a značky.

  • Inženýři definují mapování na centralizovaném místě a framework namapuje každý chybový kód na odpovídající Protobuf ErrorCode , ErrorType , stav gRPC, stav HTTP a značky pro protokolování/metriky.
  • To zajišťuje konzistenci a snižuje duplicitu.

Chybový rámec jmenného prostoru

Základní balíčky a typy

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říklad použití

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.

Chyby při vytváření a balení

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.
  • Funkce 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řidání kontextu k chybám

Přidejte kontext k chybě pomocí With()

  • K chybám můžete přidat další páry klíč/hodnota pomocí .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}

Různé typy: 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.

Hlášení nových chybových kódů

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.

Mapování chyb na nový kód

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))

Testování pomocí jmenného prostoru 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 .


Migrace

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.


Závěr

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! 😊



Autor

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 👋

L O A D I N G
. . . comments & more!

About Author

Oliver Nguyen HackerNoon profile picture
Oliver Nguyen@olvrng
I’m a software maker working mostly in Go and JavaScript. Share knowledge and thoughts during my journey.

ZAVĚŠIT ZNAČKY

TENTO ČLÁNEK BYL PŘEDSTAVEN V...