Att hantera fel i Go är enkelt och flexibelt – men ingen struktur!
Det ska vara enkelt, eller hur? Bara returnera ett error
med ett meddelande och gå vidare. Tja, den enkelheten förvandlas snabbt till kaotisk när vår kodbas växer med fler paket, fler utvecklare och fler "snabbfixar" som stannar där för alltid. Med tiden är loggarna fulla av "misslyckades med att göra detta" och "oväntat det", och ingen vet om det är användarens fel, serverns fel, buggykod eller om det bara är en felinställning av stjärnorna!
Fel skapas med inkonsekventa meddelanden. Varje paket har sin egen uppsättning stilar, konstanter eller anpassade feltyper. Felkoder läggs till godtyckligt. Inget enkelt sätt att avgöra vilka fel som kan returneras från vilken funktion utan att gräva i dess implementering!
Så jag antog utmaningen att skapa ett nytt felramverk. Vi bestämde oss för att använda ett strukturerat, centraliserat system som använder namnområdeskoder för att göra fel meningsfulla, spårbara och – viktigast av allt – ge oss sinnesfrid!
Det här är historien om hur vi började med ett enkelt felhanteringssätt, blev ordentligt frustrerade när problemen växte och så småningom byggde vårt eget felramverk. Designbesluten, hur det implementeras, lärdomarna och varför det förändrade vår metod för att hantera fel. Jag hoppas att det kommer att ge dig några idéer också!
Go har ett enkelt sätt att hantera fel: fel är bara värden. Ett fel är bara ett värde som implementerar error
med en enda metod Error() string
. Istället för att skapa ett undantag och störa det aktuella exekveringsflödet, returnerar Go-funktioner ett error
tillsammans med andra resultat. Den som ringer kan sedan bestämma hur det ska hanteras: kontrollera dess värde för att fatta beslut, avsluta med nya meddelanden och sammanhang, eller helt enkelt returnera felet och lämna hanteringslogiken för förälder som ringer.
Vi kan göra ett error
av vilken typ som helst genom att lägga till Error() string
på den. Denna flexibilitet gör att varje paket kan definiera sin egen felhanteringsstrategi och välja det som fungerar bäst för dem. Detta integreras också väl med Gos filosofi om komponerbarhet, vilket gör det enkelt att linda, utöka eller anpassa fel efter behov.
Vanlig praxis är att returnera ett felvärde som implementerar error
och låter den som ringer bestämma vad han ska göra härnäst. Här är ett typiskt exempel:
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 tillhandahåller en handfull verktyg för att arbeta med fel:
errors.New()
och fmt.Errorf()
för att generera enkla fel.fmt.Errorf()
och verbet %w
.errors.Join()
slår samman flera fel till ett enda.errors.Is()
matchar ett fel med ett specifikt värde, errors.As()
matchar ett fel till en specifik typ och errors.Unwrap()
hämtar det underliggande felet.
I praktiken ser vi vanligtvis dessa mönster:
errors.New()
eller fmt.Errorf()
.I början, som många Go-utvecklare, följde vi Gos vanliga rutiner och höll felhanteringen minimal men ändå funktionell. Det fungerade bra nog i ett par år.
Inkludera stacktrace med pkg/errors , ett populärt paket på den tiden.
Exportera konstanter eller variabler för paketspecifika fel.
Använd errors.Is()
för att leta efter specifika fel.
Radbryt fel med nya meddelanden och sammanhang.
För API-fel definierar vi feltyper och koder med Protobuf enum.
Inklusive stacktrace med pkg/errors
Vi använde pkg/errors , ett populärt felhanteringspaket på den tiden, för att inkludera stacktrace i våra fel. Detta var särskilt användbart för felsökning, eftersom det gjorde det möjligt för oss att spåra ursprunget till fel över olika delar av applikationen.
För att skapa, radbryta och sprida fel med stacktrace implementerade vi funktioner som Newf()
, NewValuef()
, och Wrapf()
. Här är ett exempel på vår tidiga implementering:
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, } }
Exporterar felvariabler
Varje paket i vår kodbas definierade sina egna felvariabler, ofta med inkonsekventa stilar.
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")
Kontrollera fel med errors.Is()
och radbrytning med ytterligare sammanhang
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) }
Detta hjälpte till att sprida fel med mer detaljer men resulterade ofta i utförlighet, dubbelarbete och mindre tydlighet i loggarna:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Definiera externa fel med Protobuf
För externt vända API:er antog vi en Protobuf-baserad felmodell inspirerad avMetas 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; }
Detta tillvägagångssätt hjälpte till att strukturera fel, men med tiden lades feltyper och koder till utan en tydlig plan, vilket ledde till inkonsekvenser och dubbelarbete.
Överallt deklarerades fel
gorm.ErrRecordNotFound
eller user.ErrNotFound
eller båda?
Slumpmässig felomslutning ledde till inkonsekventa och godtyckliga loggar
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
Ingen standardisering ledde till felaktig felhantering
Ingen kategorisering omöjliggjorde övervakning
context.Canceled
. Avbrutet fel kan vara ett normalt beteende när användaren stänger webbläsarfliken, men det är viktigt om begäran avbryts eftersom den frågan är slumpmässigt långsam.För att möta de växande utmaningarna bestämde vi oss för att bygga en bättre felstrategi kring kärnidén med centraliserade och strukturerade felkoder .
Error
med en omfattande uppsättning hjälpare.Alla felkoder definieras på en centraliserad plats med namnområdesstruktur.
Använd namnutrymmen för att skapa tydliga, meningsfulla och utökningsbara felkoder. Exempel:
PRFL.USR.NOT_FOUND
för "Användaren hittades inte."FLD.NOT_FOUND
för "Flödesdokument hittades inte."DEPS.PG.NOT_FOUND
, vilket betyder "Record hittades inte i PostgreSQL."
Varje lager av tjänst eller bibliotek får bara returnera sina egna namnområdeskoder .
gorm.ErrRecordNotFound
från ett beroende måste paketet "databas" linda det som DEPS.PG.NOT_FOUND
. Senare måste tjänsten "profil/användare" omsluta den igen som PRFL.USR.NOT_FOUND
.
Alla fel måste implementera Error
.
error
) och våra interna Error
.
Ett fel kan omsluta ett eller flera fel. Tillsammans bildar de ett träd.
[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]
Kräv alltid context.Context
. Kan koppla ett sammanhang till felet.
trace_id
och har ingen aning om var det kommer ifrån.
När fel skickas över tjänstegränsen, exponeras endast felkoden på toppnivå.
För externa fel, fortsätt att använda den nuvarande Protobuf ErrorCode och ErrorType.
Automatiskt mappa namnutrymmesfelkoder till Protobuf-koder, HTTP-statuskoder och taggar.
ErrorCode
, ErrorType
, gRPC-status, HTTP-status och taggar för loggning/mätvärden.Det finns några kärnpaket som utgör grunden för vårt nya felhanteringsramverk.
connectly.ai/go/pkgs/
errors
: Huvudpaketet som definierar Error
och koder.errors/api
: För att skicka fel till front-end eller extern API.errors/E
: Hjälppaket avsett att användas med punktimport.testing
: Testar verktyg för att arbeta med namnområdesfel.
Error
och Code
Error
är en förlängning av error
, med ytterligare metoder för att returnera en Code
. En Code
implementeras som en 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 { /* ... */ }
Paketfel errors/E
exporterar alla felkoder och vanliga typer
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) { /* ... */ }
Exempel på felkoder:
// 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
:
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)) } }
Paket 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 }
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") } // ... }
Tja, det finns många nya funktioner och koncept i ovanstående kod. Låt oss gå igenom dem steg för steg.
Importera först paketfel errors/E
med hjälp av punktimport
Detta gör att du direkt kan använda vanliga typer som Error
istället för errors.Error
och tillgång till koder av PRFL.USR.NOT_FOUND
istället för errors.PRFL.USR.NOT_FOUND
.
import . "connectly.ai/go/pkgs/errors/E"
Skapa nya fel med CODE.New()
Anta att du får en ogiltig begäran kan du skapa ett nytt fel genom att:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
är en Code
.Code
avslöjar metoder som New()
eller Wrap()
för att skapa ett nytt fel.New()
tar emot context.Context
som det första argumentet, följt av meddelande och valfria argument.
Skriv ut det med fmt.Print(err)
:
[PRFL.USR.INVALID_ARGUMENT] invalid request
eller med fmt.Printf("%+v")
för att se mer information:
[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
Radera ett fel i ett nytt fel med CODE.Wrap()
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found") usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
kommer att producera denna utdata med fmt.Print(usrErr)
:
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
eller med 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
Stackspåret kommer från det innersta Error
. Om du skriver en hjälpfunktion kan du använda CallerSkip(skip)
för att hoppa över ramar:
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, "...") } }
Lägg till sammanhang till ett fel med hjälp With()
.With(l.String(...))
.logging/l
är ett hjälppaket för att exportera sockerfunktioner för loggning.l.String("flag", flag)
returnerar en Tag{String: flag}
och l.UUID("user_id, userID)
returnerar 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")
Taggarna kan matas ut med 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
Lägg till kontext till fel direkt inuti New()
, Wrap()
, eller MapError()
:
Genom att utnyttja funktionen l.String()
och dess familj, kan New()
och liknande funktioner på ett smart sätt upptäcka taggar bland formateringsargument. Du behöver inte införa olika funktioner.
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), )
kommer att mata ut:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
, VlError
, ApiError
För närvarande finns det 3 typer som implementerar Error
. Du kan lägga till fler typer om det behövs. Var och en kan ha olika struktur, med anpassade metoder för specifika behov.
Error
är en förlängning av Gos error
type Error interface { error Code() Message() Fields() []tags.Field StackTrace() stacktrace.StackTrace _base() *base // a private method }
Den innehåller en privat metod för att säkerställa att vi inte av misstag implementerar nya Error
utanför errors
. Vi kan (eller kanske inte) häva den begränsningen i framtiden när vi upplever fler användningsmönster.
Varför använder vi inte bara standardfelgränssnittet error
använder typpåstående?
Eftersom vi vill skilja mellan tredjepartsfel och våra interna fel. Alla lager och paket i våra interna koder måste alltid returnera Error
. På så sätt kan vi säkert veta när vi måste konvertera tredjepartsfel och när vi bara behöver hantera våra interna felkoder.
Det skapar också en gräns mellan migrerade paket och ännu inte migrerade paket. Tillbaka till verkligheten, vi kan inte bara deklarera en ny typ, vifta med en trollstav, viska en besvärjelseuppmaning och sedan konverteras alla miljoner rader kod på magiskt sätt och fungerar sömlöst utan buggar! Nej, den framtiden är inte här än. Det kan komma en dag, men för närvarande måste vi fortfarande migrera våra paket ett efter ett.
Error0
är Error
De flesta felkoder ger ett Error0
värde. Den innehåller en base
och ett valfritt underfel. Du kan använda NewX()
för att returnera en konkret *Error0
struktur istället för ett Error
-gränssnitt, men du måste vara försiktig .
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
är den gemensamma strukturen som delas av all Error
, för att tillhandahålla gemensamma funktioner: Code()
, Message()
, StackTrace()
, Fields()
, och mer.
type base struct { code Code msg string kv []tags.Field stack stacktrace.StackTrace }
VlError
är för valideringsfel
Det kan innehålla flera underfel och ger bra metoder att arbeta med valideringshjälpare.
type VlError struct { base errs []error }
Du kan skapa ett VlError
som liknar andra Error
:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
Eller gör en VlBuilder
, lägg till fel i den och konvertera den sedan till en 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)
Och inkludera nyckel/värdepar som vanligt:
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))
Användning av fmt.Printf("%+v", vlErr)
kommer att mata ut:
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
är en adapter för migrering av API-fel
Tidigare använde vi en separat api.Error
struct för att returnera API-fel till front-end och externa klienter. Den inkluderar ErrorType
som ErrorCode
som nämnts tidigare .
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 // ... }
Denna typ är nu utfasad. Istället kommer vi att deklarera all mappning ( ErrorType
, ErrorCode
, gRPC-kod, HTTP-kod) på en centraliserad plats och konvertera dem vid motsvarande gränser. Jag kommer att diskutera koddeklaration i nästa avsnitt .
För att göra migreringen till det nya namnutrymmesfelramverket lade vi till ett tillfälligt namnområde ZZZ.API_TODO
. Varje ErrorCode
blir en ZZZ.API_TODO
-kod.
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
Och ApiError
skapas som en adapter. Alla funktioner som tidigare returnerade *api.Error
ändrades till att returnera Error
(implementerat av *ApiError
) istället.
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...) }
När all migrering är klar, den tidigare användningen:
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()
ska bli:
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."))
Observera att ErrorCode
implicit härleds från den interna namnområdeskoden. Du behöver inte uttryckligen tilldela det varje gång. Men hur deklarerar man förhållandet mellan koder? Det kommer att förklaras i nästa avsnitt.
Vid det här laget vet du redan hur man skapar nya fel från befintliga koder. Det är dags att förklara om koder och hur man lägger till en ny.
En Code
implementeras som ett uint16
värde, som har en motsvarande strängpresentation.
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
För att lagra dessa strängar finns det en uppsättning av alla tillgängliga 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 }
Så här deklareras koder:
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"` }
Efter att ha deklarerat nya koder måste du köra generationsskriptet:
run gen-errors
Den genererade koden kommer att se ut så här:
// 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", })) }
Varje Error
har en motsvarande Code
Har du någonsin undrat hur PRFL.USR.NOT_FOUND.New()
skapar en *Error0
och PRFL.USR.INVALID_ARGUMENTS.New()
skapar en *VlError
? Det beror på att de använder olika kodtyper.
Och varje Code
returnerar olika Error
, var och en kan ha sina egna extra metoder:
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, /*...*/ } }
Använd api-code
för att markera koderna som är tillgängliga för externt API
Namnutrymmets felkod ska användas internt.
För att göra en kod tillgänglig för retur i extern HTTP API måste du markera den med api-code
. Värdet är motsvarande errorpb.ErrorCode
.
Om en felkod inte är markerad med api-code
är det den interna koden och kommer att visas som ett allmänt Internal Server Error
.
Lägg märke till att PRFL.USR.NOT_FOUND
är extern kod, medan PRFL.USR.REPO.NOT_FOUND
är intern kod.
Deklarera mappning mellan ErrorCode
, ErrorType
och gRPC/HTTP-koder i protobuf med enum-alternativet:
// 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
och UNKNOWN
koder
Varje lager har vanligtvis 2 generiska koder UNEXPECTED
och UNKNOWN
. De tjänar lite olika syften:
UNEXPECTED
kod används för fel som aldrig borde inträffa.UNKNOWN
kod används för fel som inte explicit hanteras.När du tar emot ett fel som returneras från en funktion måste du hantera det: konvertera tredjepartsfel till interna namnområdesfel och mappa felkoder från inre skikt till yttre skikt.
Konvertera tredjepartsfel till interna namnområdesfel
Hur du hanterar fel beror på: vad tredjepartspaketet returnerar och vad din applikation behöver. Till exempel, när du hanterar databas- eller externa API-fel:
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") }
Använda hjälpredor för interna namnområdesfel
IsErrorCode(err, CODES...)
: Kontrollerar om felet innehåller någon av de angivna koderna.IsErrorGroup(err, GROUP)
: Returnerar sant om felet tillhör indatagruppen.
Typiskt användningsmönster:
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()
för att skriva mappningskod lättare:
Eftersom mappningsfelkoder är ett vanligt mönster finns det en MapError()
-hjälpare för att göra skrivning av kod snabbare. Ovanstående kod kan skrivas om som:
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") }
Du kan formatera argument och lägga till nyckel/värdepar som vanligt:
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 Testning är avgörande för alla seriösa kodbaser. Ramverket tillhandahåller specialiserade hjälpmedel som ΩxError()
för att göra skrivning och hävda feltillstånd i test enklare och mer uttrycksfull.
// 👉 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")
Det finns många fler metoder, och du kan koppla ihop dem också:
Ω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")
Varför använda metoder istället för Ω(err).To(testing.MatchCode())
?
Eftersom metoder är mer upptäckbara. När du står inför dussintals funktioner som testing.MatchValues()
, är det svårt att veta vilka som fungerar med Error
s och vilka som inte kommer att fungera. Med metoder kan du helt enkelt skriva en prick .
, och din IDE kommer att lista alla tillgängliga metoder som är speciellt utformade för att hävda Error
.
Ramen är bara halva historien. Skriver du koden? Det är den lätta delen. Den verkliga utmaningen börjar när du måste ta in den i en massiv, levande kodbas där dussintals ingenjörer driver förändringar dagligen, kunder förväntar sig att allt ska fungera perfekt och systemet helt enkelt inte kan sluta fungera.
Migration kommer med ansvar. Det handlar om att försiktigt dela upp hår små bitar av kod, göra små ändringar åt gången, bryta massor av tester i processen. Sedan manuellt inspektera och fixa dem en efter en, slå samman i huvudgrenen, distribuera till produktion, titta på loggarna och varningarna. Upprepa det om och om igen...
Här är några tips för migration som vi lärde oss på vägen:
Börja med sök och ersätt: Börja med att ersätta gamla mönster med det nya ramverket. Åtgärda eventuella kompileringsproblem som uppstår från den här processen.
Ersätt till exempel alla error
i det här paketet med Error
.
type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, error) }
Den nya koden kommer att se ut så här:
import . "connectly.ai/go/pkgs/errors" type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, Error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error) }
Migrera ett paket i taget: Börja med de lägsta paketen och arbeta dig uppåt. På så sätt kan du se till att paketen på lägre nivå är helt migrerade innan du går vidare till de högre nivåerna.
Lägg till saknade enhetstester: Om delar av kodbasen saknar test, lägg till dem. Om du inte är säker på dina ändringar, lägg till fler tester. De är användbara för att se till att dina ändringar inte bryter mot befintlig funktionalitet.
Om ditt paket är beroende av att anropa paket på högre nivå: Överväg att ändra de relaterade funktionerna till DEPRECATED och lägg sedan till nya funktioner med den nya Error
.
Antag att du migrerar databaspaketet, som har metoden 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) }) }
Och det används i användartjänstpaketet:
err = s.DB(ctx).Transaction(func(tx *database.DB) error { user, usrErr := s.repo.CreateUser(ctx, tx, user) if usrErr != nil { return usrErr } }
Eftersom du migrerar database
först, lämnar user
och dussintals andra paket som det. Anropet s.repo.CreateUser()
returnerar fortfarande den gamla error
medan Transaction()
-metoden behöver returnera den nya Error
. Du kan ändra metoden Transaction()
till DEPRECATED
och lägga till en ny TransactionV2()
metod:
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) }
Lägg till nya felkoder när du går : När du stöter på ett fel som inte passar in i de befintliga, lägg till en ny kod. Detta hjälper dig att bygga en omfattande uppsättning felkoder över tid. Koder från andra paket finns alltid som referenser.
Felhantering i Go kan kännas enkelt till en början – returnera bara ett error
och gå vidare. Men när vår kodbas växte förvandlades den enkelheten till en trasslig röra av vaga loggar, inkonsekvent hantering och oändliga felsökningssessioner.
Genom att ta ett steg tillbaka och tänka om hur vi hanterar fel har vi byggt ett system som fungerar för oss, inte mot oss. Centraliserade och strukturerade namnområdeskoder ger oss klarhet, medan verktyg för kartläggning, radbrytning och testning av fel gör våra liv enklare. Istället för att simma genom ett hav av stockar har vi nu meningsfulla, spårbara fel som talar om för oss vad som är fel och var vi ska leta.
Detta ramverk handlar inte bara om att göra vår kod renare; det handlar om att spara tid, minska frustration och hjälpa oss att förbereda oss för det okända. Det är bara början på en resa – vi upptäcker fortfarande fler mönster – men resultatet är ett system som på något sätt kan ge sinnesro till felhantering. Förhoppningsvis kan det väcka några idéer för dina projekt också! 😊
Jag är Oliver Nguyen. En mjukvarutillverkare som mest arbetar med Go och JavaScript. Jag tycker om att lära mig och se en bättre version av mig själv varje dag. Då och då spin av nya projekt med öppen källkod. Dela kunskap och tankar under min resa.
Inlägget publiceras även på blog.connectly.ai och olivernguyen.io 👋