La gestione degli errori in Go è semplice e flessibile, ma non c'è una struttura!
Dovrebbe essere semplice, giusto? Basta restituire un error
, avvolto in un messaggio, e andare avanti. Bene, questa semplicità si trasforma rapidamente in caos man mano che la nostra base di codice cresce con più pacchetti, più sviluppatori e più "correzioni rapide" che rimangono lì per sempre. Nel tempo, i log sono pieni di "non è stato possibile fare questo" e "non è previsto quello", e nessuno sa se è colpa dell'utente, del server, del codice buggato o se è solo un disallineamento delle stelle!
Gli errori vengono creati con messaggi incoerenti. Ogni pacchetto ha il suo set di stili, costanti o tipi di errore personalizzati. I codici di errore vengono aggiunti arbitrariamente. Non esiste un modo semplice per dire quali errori possono essere restituiti da quale funzione senza scavare nella sua implementazione!
Quindi, ho accettato la sfida di creare un nuovo framework di errore. Abbiamo deciso di usare un sistema strutturato e centralizzato che utilizza codici namespace per rendere gli errori significativi, tracciabili e, cosa più importante, darci tranquillità!
Questa è la storia di come abbiamo iniziato con un semplice approccio di gestione degli errori, ci siamo sentiti profondamente frustrati man mano che i problemi crescevano e alla fine abbiamo costruito il nostro framework di errori. Le decisioni di progettazione, come è stato implementato, le lezioni apprese e perché ha trasformato il nostro approccio alla gestione degli errori. Spero che possa portare qualche idea anche a voi!
Go ha un modo semplice per gestire gli errori: gli errori sono solo valori. Un errore è solo un valore che implementa l'interfaccia error
con un singolo metodo Error() string
. Invece di generare un'eccezione e interrompere il flusso di esecuzione corrente, le funzioni Go restituiscono un valore error
insieme ad altri risultati. Il chiamante può quindi decidere come gestirlo: controllare il suo valore per prendere una decisione, avvolgere con nuovi messaggi e contesto o semplicemente restituire l'errore, lasciando la logica di gestione ai chiamanti padre.
Possiamo rendere qualsiasi tipo un error
aggiungendo il metodo Error() string
su di esso. Questa flessibilità consente a ogni pacchetto di definire la propria strategia di gestione degli errori e di scegliere quella che funziona meglio per loro. Ciò si integra bene anche con la filosofia di componibilità di Go, rendendo facile avvolgere, estendere o personalizzare gli errori come richiesto.
La prassi comune è quella di restituire un valore di errore che implementa l'interfaccia error
e lascia che il chiamante decida cosa fare dopo. Ecco un esempio tipico:
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 fornisce una serie di utilità per lavorare con gli errori:
errors.New()
e fmt.Errorf()
per generare errori semplici.fmt.Errorf()
e il verbo %w
.errors.Join()
unisce più errori in uno singolo.errors.Is()
confronta un errore con un valore specifico, errors.As()
confronta un errore con un tipo specifico ed errors.Unwrap()
recupera l'errore sottostante.
Nella pratica, solitamente osserviamo questi modelli:
errors.New()
o fmt.Errorf()
.Nei primi tempi, come molti sviluppatori Go, abbiamo seguito le pratiche comuni di Go e mantenuto la gestione degli errori minima ma funzionale. Ha funzionato abbastanza bene per un paio d'anni.
Includere stacktrace utilizzando pkg/errors , un pacchetto molto diffuso a quel tempo.
Esporta costanti o variabili per errori specifici del pacchetto.
Utilizzare errors.Is()
per verificare la presenza di errori specifici.
Incorporare gli errori in nuovi messaggi e contesti.
Per gli errori API, definiamo i tipi di errore e i codici con l'enum Protobuf.
Incluso stacktrace con pkg/errors
Abbiamo utilizzato pkg/errors , un popolare pacchetto di gestione degli errori all'epoca, per includere stacktrace nei nostri errori. Ciò è stato particolarmente utile per il debug, poiché ci ha consentito di tracciare l'origine degli errori in diverse parti dell'applicazione.
Per creare, avvolgere e propagare errori con stacktrace, abbiamo implementato funzioni come Newf()
, NewValuef()
e Wrapf()
. Ecco un esempio della nostra prima implementazione:
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, } }
Esportazione delle variabili di errore
Ogni pacchetto nel nostro codice base definisce le proprie variabili di errore, spesso con stili incoerenti.
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")
Controllo degli errori con errors.Is()
e avvolgimento con contesto aggiuntivo
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) }
Ciò ha contribuito a propagare gli errori con maggiori dettagli, ma spesso ha comportato verbosità, duplicazione e minore chiarezza nei registri:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Definizione degli errori esterni con Protobuf
Per le API rivolte all'esterno, abbiamo adottato un modello di errore basato su Protobuf ispirato allaGraph API di Meta :
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; }
Questo approccio ha contribuito a strutturare gli errori, ma nel tempo sono stati aggiunti tipi e codici di errore senza un piano chiaro, causando incongruenze e duplicazioni.
Gli errori sono stati dichiarati ovunque
gorm.ErrRecordNotFound
o user.ErrNotFound
o entrambi?
L'errore casuale di wrapping ha portato a registri incoerenti e arbitrari
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
La mancanza di standardizzazione ha portato a una gestione impropria degli errori
Nessuna categorizzazione ha reso impossibile il monitoraggio
context.Canceled
può essere un comportamento normale quando l'utente chiude la scheda del browser, ma è importante se la richiesta viene annullata perché la query è casualmente lenta.Per affrontare le crescenti sfide, abbiamo deciso di elaborare una strategia di gestione degli errori migliore basata sull'idea fondamentale di codici di errore centralizzati e strutturati .
Error
con un set completo di strumenti di supporto.Tutti i codici di errore sono definiti in un luogo centralizzato con struttura a namespace.
Utilizza i namespace per creare codici di errore chiari, significativi ed estensibili. Esempio:
PRFL.USR.NOT_FOUND
per "Utente non trovato."FLD.NOT_FOUND
per "Documento di flusso non trovato."DEPS.PG.NOT_FOUND
, che significa "Record non trovato in PostgreSQL".
Ogni livello di servizio o libreria deve restituire solo i propri codici di namespace .
gorm.ErrRecordNotFound
da una dipendenza, il pacchetto "database" deve racchiuderlo come DEPS.PG.NOT_FOUND
. In seguito, il servizio "profile/user" deve racchiuderlo di nuovo come PRFL.USR.NOT_FOUND
.
Tutti gli errori devono implementare l' interfaccia Error
.
error
) e i nostri Error
interni.
Un errore può racchiudere uno o più errori. Insieme, formano un albero.
[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]
Richiedi sempre context.Context
. È possibile allegare il contesto all'errore.
trace_id
e senza avere idea della loro provenienza.
Quando gli errori vengono inviati oltre i confini del servizio, viene esposto solo il codice di errore di livello superiore.
Per gli errori esterni, continuare a utilizzare gli attuali ErrorCode ed ErrorType di Protobuf.
Associa automaticamente i codici di errore dello spazio dei nomi ai codici Protobuf, ai codici di stato HTTP e ai tag.
ErrorCode
corrispondente, ErrorType
, stato gRPC, stato HTTP e tag per la registrazione/le metriche.Ci sono alcuni pacchetti fondamentali che costituiscono la base del nostro nuovo framework di gestione degli errori.
connectly.ai/go/pkgs/
errors
: il pacchetto principale che definisce il tipo Error
e i codici.errors/api
: per inviare errori al front-end o all'API esterna.errors/E
: Pacchetto helper pensato per essere utilizzato con dot import.testing
: utilità di test per lavorare con errori nello spazio dei nomi.
Error
e Code
L'interfaccia Error
è un'estensione dell'interfaccia standard error
, con metodi aggiuntivi per restituire un Code
. Un Code
è implementato come 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 { /* ... */ }
errors/E
del pacchetto /E esporta tutti i codici di errore e i tipi comuni
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) { /* ... */ }
Esempi di codici di errore:
// 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
dei pacchetti:
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)) } }
Pacchetto 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
del pacchetto:
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") } // ... }
Bene, ci sono un sacco di nuove funzioni e concetti nel codice sopra. Esaminiamoli passo dopo passo.
Per prima cosa, importa errors/E
utilizzando dot import
Ciò consentirà di utilizzare direttamente tipi comuni come Error
anziché errors.Error
e di accedere ai codici tramite PRFL.USR.NOT_FOUND
anziché errors.PRFL.USR.NOT_FOUND
.
import . "connectly.ai/go/pkgs/errors/E"
Crea nuovi errori usando CODE.New()
Supponiamo che tu riceva una richiesta non valida, puoi creare un nuovo errore:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
è un Code
.Code
espone metodi come New()
o Wrap()
per creare un nuovo errore.New()
riceve context.Context
come primo argomento, seguito da message e argomenti facoltativi.
Stampalo con fmt.Print(err)
:
[PRFL.USR.INVALID_ARGUMENT] invalid request
o con fmt.Printf("%+v")
per vedere maggiori dettagli:
[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
Racchiudi un errore in un nuovo errore usando CODE.Wrap()
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found") usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
produrrà questo output con fmt.Print(usrErr)
:
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
o con 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
Lo stacktrace verrà Error
più interno. Se stai scrivendo una funzione helper, puoi usare CallerSkip(skip)
per saltare i frame:
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, "...") } }
Aggiungere contesto a un errore utilizzando With()
.With(l.String(...))
.logging/l
è un pacchetto di supporto per esportare funzioni sugar per la registrazione.l.String("flag", flag)
restituisce un Tag{String: flag}
e l.UUID("user_id, userID)
restituisce 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")
I tag possono essere visualizzati con 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
Aggiungi contesto agli errori direttamente all'interno di New()
, Wrap()
o MapError()
:
Sfruttando la funzione l.String()
e la sua famiglia, New()
e funzioni simili possono rilevare in modo intelligente i tag tra gli argomenti di formattazione. Non c'è bisogno di introdurre funzioni diverse.
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), )
produrrà:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
, VlError
, ApiError
Attualmente, ci sono 3 tipi che implementano le interfacce Error
. Puoi aggiungere altri tipi se necessario. Ognuno può avere una struttura diversa, con metodi personalizzati per esigenze specifiche.
Error
è un'estensione dell'interfaccia di error
standard di Go
type Error interface { error Code() Message() Fields() []tags.Field StackTrace() stacktrace.StackTrace _base() *base // a private method }
Contiene un metodo privato per garantire che non implementiamo accidentalmente nuovi tipi Error
al di fuori del pacchetto errors
. Potremmo (o meno) rimuovere tale restrizione in futuro quando sperimenteremo più modelli di utilizzo.
Perché non utilizziamo semplicemente l' interfaccia error
standard e l'asserzione di tipo?
Perché vogliamo distinguere tra errori di terze parti e i nostri errori interni. Tutti i livelli e i pacchetti nei nostri codici interni devono sempre restituire Error
. In questo modo possiamo sapere con sicurezza quando dobbiamo convertire errori di terze parti e quando dobbiamo occuparci solo dei nostri codici di errore interni.
Crea anche un confine tra pacchetti migrati e pacchetti non ancora migrati. Tornando alla realtà, non possiamo semplicemente dichiarare un nuovo tipo, agitare una bacchetta magica, sussurrare un prompt di incantesimo e poi tutti i milioni di righe di codice vengono convertiti magicamente e funzionano senza problemi e senza bug! No, quel futuro non è ancora qui. Potrebbe arrivare un giorno, ma per ora, dobbiamo ancora migrare i nostri pacchetti uno per uno.
Error0
è il tipo Error
predefinito
La maggior parte dei codici di errore produrrà un valore Error0
. Contiene una base
e un sotto-errore facoltativo. Puoi usare NewX()
per restituire una struttura concreta *Error0
invece di un'interfaccia Error
, ma devi fare attenzione .
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
è la struttura comune condivisa da tutte le implementazioni Error
, per fornire funzionalità comuni: Code()
, Message()
, StackTrace()
, Fields()
e altro ancora.
type base struct { code Code msg string kv []tags.Field stack stacktrace.StackTrace }
VlError
è per gli errori di convalida
Può contenere più sotto-errori e fornire metodi utili per lavorare con gli helper di convalida.
type VlError struct { base errs []error }
Puoi creare un VlError
simile ad altri Error
:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
Oppure crea un VlBuilder
, aggiungi gli errori e convertilo in un 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)
E includi le coppie chiave/valore come al solito:
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))
Utilizzando fmt.Printf("%+v", vlErr)
verrà visualizzato quanto segue:
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
è un adattatore per la migrazione degli errori API
In precedenza, abbiamo utilizzato una struttura api.Error
separata per restituire errori API al front-end e ai client esterni. Include ErrorType
come ErrorCode
come menzionato prima .
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 // ... }
Questo tipo è ora deprecato. Invece, dichiareremo tutta la mappatura ( ErrorType
, ErrorCode
, codice gRPC, codice HTTP) in un luogo centralizzato e li convertiremo ai limiti corrispondenti. Discuterò della dichiarazione del codice nella prossima sezione .
Per effettuare la migrazione al nuovo framework di errore dello spazio dei nomi, abbiamo aggiunto uno spazio dei nomi temporaneo ZZZ.API_TODO
. Ogni ErrorCode
diventa un codice 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
E ApiError
viene creato come un adattatore. Tutte le funzioni che in precedenza restituivano *api.Error
sono state modificate per restituire Error
(implementato da *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...) }
Una volta completata l'intera migrazione, l'utilizzo precedente:
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()
dovrebbe diventare:
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."))
Si noti che ErrorCode
è implicitamente derivato dal codice dello spazio dei nomi interno. Non c'è bisogno di assegnarlo esplicitamente ogni volta. Ma come dichiarare la relazione tra i codici? Sarà spiegato nella prossima sezione.
A questo punto, sai già come creare nuovi errori da codici esistenti. È il momento di spiegare i codici e come aggiungerne uno nuovo.
Un Code
viene implementato come valore uint16
, a cui corrisponde una presentazione di stringa.
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
Per memorizzare queste stringhe, esiste un array di tutti CodeDesc
disponibili:
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 }
Ecco come vengono dichiarati i codici:
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"` }
Dopo aver dichiarato i nuovi codici, è necessario eseguire lo script di generazione:
run gen-errors
Il codice generato apparirà così:
// 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", })) }
Ogni tipo Error
ha un tipo Code
corrispondente
Ti sei mai chiesto come PRFL.USR.NOT_FOUND.New()
crea un *Error0
e PRFL.USR.INVALID_ARGUMENTS.New()
crea un *VlError
? Ciò avviene perché utilizzano tipi di codice diversi.
E ogni tipo Code
restituisce un tipo Error
diverso, ognuno dei quali può avere i propri metodi extra:
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, /*...*/ } }
Utilizzare api-code
per contrassegnare i codici disponibili per API esterne
Il codice di errore dello spazio dei nomi dovrebbe essere utilizzato internamente.
Per rendere disponibile un codice per la restituzione in un'API HTTP esterna, è necessario contrassegnarlo con api-code
. Il valore è il corrispondente errorpb.ErrorCode
.
Se un codice di errore non è contrassegnato con api-code
, si tratta di codice interno e verrà visualizzato come un Internal Server Error
generico.
Si noti che PRFL.USR.NOT_FOUND
è codice esterno, mentre PRFL.USR.REPO.NOT_FOUND
è codice interno.
Dichiara il mapping tra ErrorCode
, ErrorType
e i codici gRPC/HTTP in protobuf utilizzando l'opzione 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.", }];
Codici UNEXPECTED
e UNKNOWN
Ogni livello ha solitamente 2 codici generici UNEXPECTED
e UNKNOWN
. Hanno scopi leggermente diversi:
UNEXPECTED
viene utilizzato per errori che non dovrebbero mai verificarsi.UNKNOWN
viene utilizzato per gli errori che non vengono gestiti esplicitamente.Quando si riceve un errore restituito da una funzione, è necessario gestirlo: convertire gli errori di terze parti in errori dello spazio dei nomi interno e mappare i codici di errore dai livelli interni a quelli esterni.
Convertire gli errori di terze parti in errori dello spazio dei nomi interno
Il modo in cui gestisci gli errori dipende da: cosa restituisce il pacchetto di terze parti e cosa necessita la tua applicazione. Ad esempio, quando gestisci errori di database o API esterne:
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") }
Utilizzo di helper per errori interni dello spazio dei nomi
IsErrorCode(err, CODES...)
: Controlla se l'errore contiene uno dei codici specificati.IsErrorGroup(err, GROUP)
: restituisce true se l'errore appartiene al gruppo di input.
Tipico schema di utilizzo:
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()
per scrivere più facilmente il codice di mappatura:
Poiché la mappatura dei codici di errore è un pattern comune, esiste un helper MapError()
per rendere più veloce la scrittura del codice. Il codice sopra può essere riscritto come:
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") }
È possibile formattare gli argomenti e aggiungere coppie chiave/valore come di consueto:
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
dello spazio dei nomi Il testing è fondamentale per qualsiasi base di codice seria. Il framework fornisce helper specializzati come ΩxError()
per rendere la scrittura e l'asserzione delle condizioni di errore nei test più semplici ed espressive.
// 👉 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")
Esistono molti altri metodi, che puoi anche concatenare:
Ω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")
Perché usare metodi invece di Ω(err).To(testing.MatchCode())
?
Perché i metodi sono più facilmente individuabili. Quando ti trovi di fronte a decine di funzioni come testing.MatchValues()
, è difficile sapere quali funzioneranno con Error
e quali no. Con i metodi, puoi semplicemente digitare un punto .
, e il tuo IDE elencherà tutti i metodi disponibili specificamente progettati per asserire Error
.
Il framework è solo metà della storia. Scrivere il codice? Quella è la parte facile. La vera sfida inizia quando devi inserirlo in una base di codice enorme e viva, dove decine di ingegneri spingono i cambiamenti ogni giorno, i clienti si aspettano che tutto funzioni alla perfezione e il sistema non riesce a smettere di funzionare.
La migrazione comporta delle responsabilità. Si tratta di dividere con attenzione minuscoli pezzi di codice, apportare piccole modifiche alla volta, interrompere un sacco di test nel processo. Quindi ispezionarli e correggerli manualmente uno per uno, unirli al ramo principale, distribuire in produzione, guardare i log e gli avvisi. Ripeterlo più e più volte...
Ecco alcuni suggerimenti per la migrazione che abbiamo imparato lungo il percorso:
Inizia con la ricerca e sostituzione: inizia sostituendo i vecchi pattern con il nuovo framework. Risolvi eventuali problemi di compilazione che emergono da questo processo.
Ad esempio, sostituisci tutti error
in questo pacchetto con Error
.
type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, error) }
Il nuovo codice apparirà così:
import . "connectly.ai/go/pkgs/errors" type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, Error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error) }
Migra un pacchetto alla volta: inizia con i pacchetti di livello più basso e procedi verso l'alto. In questo modo, puoi assicurarti che i pacchetti di livello inferiore siano completamente migrati prima di passare a quelli di livello superiore.
Aggiungi test unitari mancanti: se parti della base di codice sono prive di test, aggiungili. Se non sei sicuro delle tue modifiche, aggiungi altri test. Sono utili per assicurarti che le tue modifiche non interrompano le funzionalità esistenti.
Se il pacchetto dipende dalla chiamata di pacchetti di livello superiore: valutare la possibilità di modificare le funzioni correlate in DEPRECATE, quindi aggiungere nuove funzioni con il nuovo tipo Error
.
Supponiamo che tu stia migrando il pacchetto del database, che ha il metodo 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) }) }
Ed è utilizzato nel pacchetto del servizio utente:
err = s.DB(ctx).Transaction(func(tx *database.DB) error { user, usrErr := s.repo.CreateUser(ctx, tx, user) if usrErr != nil { return usrErr } }
Poiché stai migrando prima il pacchetto database
, lasciando l' user
e decine di altri pacchetti così come sono. La chiamata s.repo.CreateUser()
restituisce ancora il vecchio tipo error
mentre il metodo Transaction()
deve restituire il nuovo tipo Error
. Puoi cambiare il metodo Transaction()
in DEPRECATED
e aggiungere un nuovo metodo 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) }
Aggiungi nuovi codici di errore man mano che procedi : quando incontri un errore che non rientra in quelli esistenti, aggiungi un nuovo codice. Questo ti aiuterà a creare un set completo di codici di errore nel tempo. I codici di altri pacchetti sono sempre disponibili come riferimenti.
La gestione degli errori in Go può sembrare semplice all'inizio: basta restituire un error
e andare avanti. Ma man mano che la nostra base di codice cresceva, quella semplicità si è trasformata in un groviglio di log vaghi, gestione incoerente e sessioni di debug infinite.
Facendo un passo indietro e ripensando al modo in cui gestiamo gli errori, abbiamo creato un sistema che lavora per noi, non contro di noi. I codici dei namespace centralizzati e strutturati ci danno chiarezza, mentre gli strumenti per la mappatura, il wrapping e il test degli errori ci semplificano la vita. Invece di nuotare in un mare di log, ora abbiamo errori significativi e tracciabili che ci dicono cosa c'è che non va e dove cercare.
Questo framework non riguarda solo rendere il nostro codice più pulito; riguarda anche il risparmio di tempo, la riduzione della frustrazione e l'aiuto per prepararci all'ignoto. È solo l'inizio di un viaggio, stiamo ancora scoprendo altri pattern, ma il risultato è un sistema che in qualche modo può portare tranquillità nella gestione degli errori. Speriamo che possa anche far scaturire qualche idea per i tuoi progetti! 😊
Sono Oliver Nguyen. Un creatore di software che lavora principalmente in Go e JavaScript. Mi piace imparare e vedere una versione migliore di me stesso ogni giorno. Ogni tanto creo nuovi progetti open source. Condivido conoscenze e pensieri durante il mio viaggio.
Il post è pubblicato anche su blog.connectly.ai e olivernguyen.io 👋