paint-brush
Evita che gli errori crescano con questo nuovo frameworkdi@olvrng
270 letture

Evita che gli errori crescano con questo nuovo framework

di Oliver Nguyen30m2024/12/11
Read on Terminal Reader

Troppo lungo; Leggere

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 aumentavano e alla fine abbiamo creato il nostro framework di gestione degli errori.
featured image - Evita che gli errori crescano con questo nuovo framework
Oliver Nguyen HackerNoon profile picture
0-item
1-item

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!


Gli errori Go sono solo valori

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.

Ogni pacchetto deve gestire gli errori

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:

  • Creazione di errori: errors.New() e fmt.Errorf() per generare errori semplici.
  • Errori di wrapping: avvolgi gli errori con contesto aggiuntivo utilizzando fmt.Errorf() e il verbo %w .
  • Combinazione di errori: errors.Join() unisce più errori in uno singolo.
  • Controllo e gestione degli errori: 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:

  • Utilizzo di pacchetti standard: restituzione di errori semplici con errors.New() o fmt.Errorf() .
  • Esportazione di costanti o variabili: ad esempio, go-redis e gorm.io definiscono variabili di errore riutilizzabili.
  • Tipi di errore personalizzati: librerie come lib/pq grpc/status.Error creano tipi di errore specializzati, spesso con codici associati per contesto aggiuntivo.
  • Interfacce di errore con implementazioni: aws-sdk-go utilizza un approccio basato sull'interfaccia per definire i tipi di errore con varie implementazioni.
  • Oppure interfacce multiple: come errdefs di Docker , che definisce interfacce multiple per classificare e gestire gli errori.

Abbiamo iniziato con un approccio comune

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.


E i problemi sono cresciuti nel tempo

Gli errori sono stati dichiarati ovunque

  • Ogni pacchetto definisce le proprie costanti di errore senza un sistema centralizzato.
  • Costanti e messaggi erano sparsi nel codice base, rendendo poco chiaro quali errori una funzione potesse restituire: ugh, è gorm.ErrRecordNotFound o user.ErrNotFound o entrambi?


L'errore casuale di wrapping ha portato a registri incoerenti e arbitrari

  • Molte funzioni racchiudevano gli errori in messaggi arbitrari e incoerenti senza dichiarare i propri tipi di errore.
  • I registri erano prolissi, ridondanti e difficili da consultare o monitorare.
  • I messaggi di errore erano generici e spesso non spiegavano cosa era andato storto o come era successo. Inoltre, erano fragili e inclini a cambiamenti inosservati.
 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

  • Ogni pacchetto gestisce gli errori in modo diverso, rendendo difficile sapere se una funzione restituisce, racchiude o trasforma gli errori.
  • Spesso il contesto andava perso a causa della propagazione degli errori.
  • I livelli superiori hanno ricevuto vaghi errori interni del server 500 senza chiare cause profonde.


Nessuna categorizzazione ha reso impossibile il monitoraggio

  • Gli errori non sono stati classificati in base alla gravità o al comportamento: un errore 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.
  • Le questioni importanti venivano nascoste sotto registri rumorosi, rendendone difficile l'identificazione.
  • Senza categorizzazione, era impossibile monitorare efficacemente la frequenza, la gravità o l'impatto degli errori.

È tempo di centralizzare la gestione degli errori

Ritorno al tavolo da disegno

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 .

  • Gli errori vengono dichiarati ovunque → Centralizzare la dichiarazione degli errori in un unico posto per una migliore organizzazione e tracciabilità.
  • Registri incoerenti e arbitrari → Codici di errore strutturati con formattazione chiara e coerente.
  • Gestione non corretta degli errori → Standardizzare la creazione e il controllo degli errori sul nuovo tipo di Error con un set completo di strumenti di supporto.
  • Nessuna categorizzazione → Categorizza i codici di errore con tag per un monitoraggio efficace tramite registri e metriche.

Decisioni di progettazione

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."
  • Entrambi possono condividere un codice di base sottostante 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 .

  • Ogni livello di servizio, repository o libreria dichiara il proprio set di codici di errore.
  • Quando un layer riceve un errore da una dipendenza, deve racchiuderlo nel proprio codice namespace prima di restituirlo.
  • Ad esempio: quando si riceve un errore 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 .

  • Ciò crea un confine netto tra gli errori provenienti da librerie di terze parti ( error ) e i nostri Error interni.
  • Ciò aiuta anche a far progredire la migrazione, distinguendo i pacchetti migrati da quelli non ancora migrati.


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.

  • Spesso abbiamo visto registri con errori autonomi, senza contesto, senza trace_id e senza avere idea della loro provenienza.
  • È possibile allegare agli errori una chiave/valore aggiuntiva, che può essere utilizzata nei registri o nel monitoraggio.


Quando gli errori vengono inviati oltre i confini del servizio, viene esposto solo il codice di errore di livello superiore.

  • I chiamanti non hanno bisogno di vedere i dettagli di implementazione interna di quel servizio.


Per gli errori esterni, continuare a utilizzare gli attuali ErrorCode ed ErrorType di Protobuf.

  • Ciò garantisce la retrocompatibilità, quindi i nostri clienti non devono riscrivere il loro codice.


Associa automaticamente i codici di errore dello spazio dei nomi ai codici Protobuf, ai codici di stato HTTP e ai tag.

  • Gli ingegneri definiscono la mappatura in un luogo centralizzato e il framework mapperà ciascun codice di errore al Protobuf ErrorCode corrispondente, ErrorType , stato gRPC, stato HTTP e tag per la registrazione/le metriche.
  • Ciò garantisce coerenza e riduce le duplicazioni.

Framework degli errori dello spazio dei nomi

Pacchetti e tipi principali

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) { /* ... */ }

Esempio di utilizzo

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.

Creazione e confezionamento degli errori

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 .
  • Un Code espone metodi come New() o Wrap() per creare un nuovo errore.
  • La funzione 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 agli errori

Aggiungere contesto a un errore utilizzando With()

  • È possibile aggiungere ulteriori coppie chiave/valore agli errori tramite .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}

Diversi tipi: 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.

Dichiarazione di nuovi codici di errore

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:

  • Il codice UNEXPECTED viene utilizzato per errori che non dovrebbero mai verificarsi.
  • Il codice UNKNOWN viene utilizzato per gli errori che non vengono gestiti esplicitamente.

Mappatura degli errori sul nuovo codice

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

Test con 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 .


Migrazione

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.


Conclusione

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



Autore

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 👋