Обработката на грешки в Go е проста и гъвкава – но без структура!
Трябва да е просто, нали? Просто върнете error
, обвита със съобщение, и продължете напред. Е, тази простота бързо се превръща в хаотична, тъй като нашата кодова база расте с повече пакети, повече разработчици и повече „бързи поправки“, които остават там завинаги. С течение на времето регистрационните файлове са пълни с „не успях да направя това“ и „неочаквано онова“ и никой не знае дали това е грешка на потребителя, грешка на сървъра, код с грешки или просто неправилно подравняване на звездите!
Грешките се създават с непоследователни съобщения. Всеки пакет има свой собствен набор от стилове, константи или потребителски типове грешки. Кодовете за грешки се добавят произволно. Няма лесен начин да разберете кои грешки могат да бъдат върнати от коя функция, без да се задълбочавате в нейната реализация!
И така, приех предизвикателството да създам нова рамка за грешки. Решихме да използваме структурирана, централизирана система, използваща кодове на пространството от имена, за да направим грешките значими, проследими и – най-важното – да ни осигури спокойствие!
Това е историята за това как започнахме с прост подход за обработка на грешки, бяхме напълно разочаровани с нарастването на проблемите и в крайна сметка изградихме наша собствена рамка за грешки. Решенията за проектиране, как се прилагат, научените уроци и защо промени нашия подход към управлението на грешки. Надявам се, че ще донесе някои идеи и за вас!
Go има лесен начин за обработка на грешки: грешките са просто стойности. Грешката е просто стойност, която имплементира интерфейса error
с единичен метод Error() string
. Вместо да хвърлят изключение и да прекъсват текущия поток на изпълнение, Go функциите връщат стойност error
заедно с други резултати. След това повикващият може да реши как да се справи с него: да провери стойността му, за да вземе решение, да обвие с нови съобщения и контекст или просто да върне грешката, оставяйки логиката на обработка за родителските повикващи.
Можем да направим всеки тип error
, като добавим метода Error() string
към него. Тази гъвкавост позволява на всеки пакет да дефинира своя собствена стратегия за обработка на грешки и да избере това, което работи най-добре за него. Това също така се интегрира добре с философията на Go за композируемост, което улеснява обвиването, разширяването или персонализирането на грешки според нуждите.
Обичайната практика е да се върне стойност за грешка, която реализира интерфейса error
и позволява на повикващия да реши какво да прави по-нататък. Ето един типичен пример:
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 предоставя шепа помощни програми за работа с грешки:
errors.New()
и fmt.Errorf()
за генериране на прости грешки.fmt.Errorf()
и глагола %w
.errors.Join()
обединява множество грешки в една.errors.Is()
съпоставя грешка с конкретна стойност, errors.As()
съпоставя грешка с конкретен тип и errors.Unwrap()
извлича основната грешка.
На практика обикновено виждаме следните модели:
errors.New()
или fmt.Errorf()
.В първите дни, като много разработчици на Go, ние следвахме обичайните практики на Go и поддържахме обработката на грешки минимална, но функционална. Работеше достатъчно добре за няколко години.
Включете stacktrace с помощта на pkg/errors , популярен пакет по това време.
Експортирайте константи или променливи за специфични за пакета грешки.
Използвайте errors.Is()
за да проверите за конкретни грешки.
Обвийте грешките с нови съобщения и контекст.
За API грешки ние дефинираме типове грешки и кодове с Protobuf enum.
Включително stacktrace с pkg/errors
Използвахме pkg/errors , популярен пакет за обработка на грешки по това време, за да включим stacktrace в нашите грешки. Това беше особено полезно за отстраняване на грешки, тъй като ни позволи да проследим произхода на грешките в различни части на приложението.
За да създаваме, обгръщаме и разпространяваме грешки със stacktrace, внедрихме функции като Newf()
, NewValuef()
и Wrapf()
. Ето пример за нашето ранно внедряване:
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, } }
Експортиране на променливи за грешка
Всеки пакет в нашата кодова база дефинира свои собствени променливи за грешка, често с непоследователни стилове.
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")
Проверка на грешки с errors.Is()
и обвиване с допълнителен контекст
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) }
Това помогна за разпространението на грешки с повече подробности, но често доведе до многословие, дублиране и по-малко яснота в регистрационните файлове:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Дефиниране на външни грешки с Protobuf
За API с външни изгледи ние приехме базиран на Protobuf модел на грешка, вдъхновен отGraph API на 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; }
Този подход помогна за структурирането на грешките, но с течение на времето типове грешки и кодове бяха добавени без ясен план, което доведе до несъответствия и дублиране.
Навсякъде бяха декларирани грешки
gorm.ErrRecordNotFound
ли е или user.ErrNotFound
или и двете?
Случайното обвиване на грешки доведе до непоследователни и произволни регистрационни файлове
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
Липсата на стандартизация доведе до неправилно обработване на грешки
Никаква категоризация не направи наблюдението невъзможно
context.Canceled
може да е нормално поведение, когато потребителят затвори раздела на браузъра, но е важно, ако заявката е анулирана, защото тази заявка е произволно бавна.За да отговорим на нарастващите предизвикателства, решихме да изградим по-добра стратегия за грешки около основната идея за централизирани и структурирани кодове за грешки .
Error
с изчерпателен набор от помощни средства.Всички кодове за грешки са дефинирани на централизирано място със структура на пространството от имена.
Използвайте пространства от имена, за да създадете ясни, смислени и разширими кодове за грешки. Пример:
PRFL.USR.NOT_FOUND
за „Потребителят не е намерен“.FLD.NOT_FOUND
за „Документът на потока не е намерен.“DEPS.PG.NOT_FOUND
, което означава „Записът не е намерен в PostgreSQL“.
Всеки слой от услуга или библиотека трябва да връща само собствените си кодове на пространството от имена .
gorm.ErrRecordNotFound
от зависимост, пакетът "база данни" трябва да го обвие като DEPS.PG.NOT_FOUND
. По-късно услугата "профил/потребител" трябва да го обвие отново като PRFL.USR.NOT_FOUND
.
Всички грешки трябва да изпълняват интерфейса Error
.
error
) и нашите вътрешни Error
.
Една грешка може да обхваща една или няколко грешки. Заедно те образуват дърво.
[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]
Винаги изисква context.Context
. Може да прикачи контекст към грешката.
trace_id
и нямаме представа откъде идва.
Когато грешките се изпращат през границата на услугата, се излага само кодът на грешка от най-високо ниво.
За външни грешки продължете да използвате текущите Protobuf ErrorCode и ErrorType.
Автоматично съпоставяне на кодовете за грешки в пространството на имената към Protobuf кодове, HTTP кодове за състояние и тагове.
ErrorCode
, ErrorType
, gRPC статус, HTTP статус и тагове за регистриране/метрики.Има няколко основни пакета, които формират основата на нашата нова рамка за обработка на грешки.
connectly.ai/go/pkgs/
errors
: Основният пакет, който дефинира типа и кодовете Error
.errors/api
: За изпращане на грешки към предния или външен API.errors/E
: Помощен пакет, предназначен да се използва с импортиране на точки.testing
: Тестване на помощни програми за работа с грешки в пространството на имената.
Error
и Code
Интерфейсът Error
е разширение на стандартния интерфейс error
с допълнителни методи за връщане на Code
. Code
е имплементиран като uint16
.
package errors // import "connectly.ai/go/pkgs/errors" type Error interface { error Code() Code } type Code struct { code uint16 } type CodeI interface { CodeDesc() CodeDesc } type GroupI interface { /* ... */ } type CodeDesc struct { /* ... */ }
Package errors/E
експортира всички кодове на грешки и общи типове
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) { /* ... */ }
Примерни кодове за грешка:
// 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)) } }
Пакет 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") } // ... }
Е, има много нови функции и концепции в горния код. Нека ги прегледаме стъпка по стъпка.
Първо, импортирайте errors/E
като използвате импортиране на точки
Това ще ви позволи директно да използвате общи типове като Error
вместо errors.Error
и достъп до кодове от PRFL.USR.NOT_FOUND
вместо errors.PRFL.USR.NOT_FOUND
.
import . "connectly.ai/go/pkgs/errors/E"
Създайте нови грешки с помощта на CODE.New()
Да предположим, че получите невалидна заявка, можете да създадете нова грешка чрез:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
е Code
.Code
разкрива методи като New()
или Wrap()
за създаване на нова грешка.New()
получава context.Context
като първи аргумент, последван от съобщение и незадължителни аргументи.
Отпечатайте го с fmt.Print(err)
:
[PRFL.USR.INVALID_ARGUMENT] invalid request
или с fmt.Printf("%+v")
за да видите повече подробности:
[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
Обвийте грешка в нова грешка с помощта на CODE.Wrap()
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found") usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
ще произведе този изход с fmt.Print(usrErr)
:
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
или с 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
Проследяването на стека ще дойде от най-вътрешната Error
. Ако пишете помощна функция, можете да използвате CallerSkip(skip)
за да пропускате рамки:
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, "...") } }
Добавете контекст към грешка с помощта With()
.With(l.String(...))
.logging/l
е помощен пакет за експортиране на захарни функции за регистриране.l.String("flag", flag)
връща Tag{String: flag}
и l.UUID("user_id, userID)
връща 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")
Етикетите могат да бъдат изведени с 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
Добавете контекст към грешки директно в New()
, Wrap()
или MapError()
:
Чрез използването на функцията l.String()
и нейното семейство, New()
и подобни функции могат интелигентно да откриват тагове сред аргументите за форматиране. Няма нужда да въвеждате различни функции.
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), )
ще изведе:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
, VlError
, ApiError
В момента има 3 вида, които имплементират интерфейсите Error
. Можете да добавите още видове, ако е необходимо. Всеки може да има различна структура, с персонализирани методи за специфични нужди.
Error
е разширение на стандартния интерфейс error
на Go
type Error interface { error Code() Message() Fields() []tags.Field StackTrace() stacktrace.StackTrace _base() *base // a private method }
Той съдържа частен метод, за да гарантира, че няма да внедрим случайно нови типове Error
извън пакета errors
. Може (или не) да премахнем това ограничение в бъдеще, когато се сблъскаме с повече модели на използване.
Защо просто не използваме стандартния интерфейс error
и използваме утвърждаване на типа?
Защото искаме да правим разлика между грешките на трети страни и нашите вътрешни грешки. Всички слоеве и пакети в нашите вътрешни кодове трябва винаги да връщат Error
. По този начин можем безопасно да знаем кога трябва да конвертираме грешки на трети страни и кога трябва да се справим само с нашите вътрешни кодове за грешки.
Той също така създава граница между мигрираните пакети и все още немигрираните пакети. Обратно към реалността, не можем просто да декларираме нов тип, да размахаме магическа пръчица, да прошепнем подкана за заклинание и след това всички милиони редове код да бъдат преобразувани магически и да работят безпроблемно, без грешки! Не, това бъдеще все още не е тук. Може да дойде някой ден, но засега все още трябва да мигрираме нашите пакети един по един.
Error0
е типът Error
по подразбиране
Повечето кодове за грешка ще генерират стойност на Error0
. Той съдържа base
и незадължителна подгрешка. Можете да използвате NewX()
, за да върнете конкретна структура *Error0
вместо интерфейс Error
, но трябва да внимавате .
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
е общата структура, споделена от всички реализации Error
, за осигуряване на обща функционалност: Code()
, Message()
, StackTrace()
, Fields()
и др.
type base struct { code Code msg string kv []tags.Field stack stacktrace.StackTrace }
VlError
е за грешки при валидиране
Може да съдържа множество подгрешки и да предоставя добри методи за работа с помощници за валидиране.
type VlError struct { base errs []error }
Можете да създадете VlError
подобна на друга Error
:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
Или направете VlBuilder
, добавете грешки към него, след което го преобразувайте във 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)
И включете двойки ключ/стойност както обикновено:
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))
Използването на fmt.Printf("%+v", vlErr)
ще изведе:
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
е адаптер за мигриране на API грешки
Преди това използвахме отделна структура api.Error
за връщане на API грешки към предния край и външни клиенти. Той включва ErrorType
като ErrorCode
както беше споменато по-горе .
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 // ... }
Този тип вече е отхвърлен. Вместо това ще декларираме цялото съпоставяне ( ErrorType
, ErrorCode
, gRPC код, HTTP код) на централизирано място и ще ги конвертираме в съответните граници. Ще обсъдя декларирането на код в следващия раздел .
За да извършим миграцията към новата рамка за грешки в пространството от имена, добавихме временно пространство от имена ZZZ.API_TODO
. Всеки ErrorCode
се превръща в код 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
И ApiError
е създаден като адаптер. Всички функции, които преди това са връщали *api.Error
бяха променени, за да връщат Error
(имплементирана от *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...) }
Когато цялата миграция приключи, предишната употреба:
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()
трябва да стане:
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."))
Забележете, че ErrorCode
е имплицитно извлечен от кода на вътрешното пространство на имената. Няма нужда изрично да го задавате всеки път. Но как да декларираме връзката между кодовете? Ще бъде обяснено в следващия раздел.
На този етап вече знаете как да създавате нови грешки от съществуващи кодове. Време е да обясним за кодовете и как да добавим нов.
Кодът е имплементиран като стойност uint16
, която има съответно представяне на низ Code
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
За да съхраните тези низове, има масив от всички налични 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 }
Ето как се декларират кодовете:
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"` }
След като декларирате нови кодове, трябва да изпълните скрипта за генериране:
run gen-errors
Генерираният код ще изглежда така:
// 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", })) }
Всеки тип Error
има съответен тип Code
Чудили ли сте се някога как PRFL.USR.NOT_FOUND.New()
създава *Error0
и PRFL.USR.INVALID_ARGUMENTS.New()
създава *VlError
? Това е така, защото те използват различни типове кодове.
И всеки тип Code
връща различен тип Error
, всеки може да има свои собствени допълнителни методи:
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, /*...*/ } }
Използвайте api-code
, за да маркирате наличните кодове за външен API
Кодът за грешка в пространството на имената трябва да се използва вътрешно.
За да направите код достъпен за връщане във външен HTTP API, трябва да го маркирате с api-code
. Стойността е съответният errorpb.ErrorCode
.
Ако даден код на грешка не е маркиран с api-code
, това е вътрешен код и ще бъде показан като обща Internal Server Error
.
Забележете, че PRFL.USR.NOT_FOUND
е външен код, докато PRFL.USR.REPO.NOT_FOUND
е вътрешен код.
Декларирайте съпоставяне между ErrorCode
, ErrorType
и gRPC/HTTP кодове в protobuf, като използвате опцията enum:
// error/type.proto ERROR_TYPE_PERMISSION_DENIED = 707 [(error_type_detail_option) = { type: "PermissionDeniedError", grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "permission denied", user_title: "Permission denied", user_message: "The caller does not have permission to execute the specified operation.", }]; // error/code.proto ERROR_CODE_DISABlED_ACCOUNT = 70020 [(error_code_detail_option) = { error_type: ERROR_TYPE_DISABlED_ACCOUNT, grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "account is disabled", user_title: "Account is disabled", user_message: "Your account is disabled. Please contact support for more information.", }];
UNEXPECTED
и UNKNOWN
кодове
Всеки слой обикновено има 2 общи кода UNEXPECTED
и UNKNOWN
. Те служат за малко по-различни цели:
UNEXPECTED
код се използва за грешки, които никога не трябва да се случват.UNKNOWN
се използва за грешки, които не се обработват изрично.Когато получавате грешка, върната от функция, трябва да я обработите: да конвертирате грешки на трети страни във вътрешни грешки в пространството на имена и да картографирате кодовете за грешки от вътрешните слоеве към външните слоеве.
Преобразувайте грешки на трети страни във вътрешни грешки в пространството на имена
Как се справяте с грешките зависи от: какво връща пакетът на трета страна и от какво се нуждае вашето приложение. Например, когато обработвате база данни или външни API грешки:
switch { case errors.Is(err, sql.ErrNoRows): // map a database "no rows" error to an internal "not found" error return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found") case errors.Is(err, context.DeadlineExceeded): // map a context deadline exceeded error to a timeout error return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout") default: // wrap any other error as unknown return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error") }
Използване на помощници за вътрешни грешки в пространството на имена
IsErrorCode(err, CODES...)
: Проверява дали грешката съдържа някой от посочените кодове.IsErrorGroup(err, GROUP)
: Връща true, ако грешката принадлежи към входната група.
Типичен модел на употреба:
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()
за по-лесно писане на код за картографиране:
Тъй като картографирането на кодове за грешки е често срещан модел, има помощник MapError()
който прави писането на код по-бързо. Горният код може да бъде пренаписан като:
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") }
Можете да форматирате аргументи и да добавяте двойки ключ/стойност както обикновено:
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 Тестването е критично за всяка сериозна кодова база. Рамката предоставя специализирани помощници като ΩxError()
за да направи писането и потвърждаването на условия за грешка в тестове по-лесно и по-изразително.
// 👉 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")
Има още много методи и можете да ги ланцуговате:
Ω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")
Защо да използвате методи вместо Ω(err).To(testing.MatchCode())
?
Защото методите са по-откриваеми. Когато се сблъскате с десетки функции като testing.MatchValues()
, е трудно да разберете кои ще работят с Error
s и кои не. С методите можете просто да въведете точка .
, и вашето IDE ще изброи всички налични методи, специално предназначени за потвърждаване Error
.
Рамката е само половината от историята. Пишете кода? Това е лесната част. Истинското предизвикателство започва, когато трябва да го поставите в масивна, жива кодова база, където десетки инженери налагат промени ежедневно, клиентите очакват всичко да работи перфектно и системата просто не може да спре да работи.
Миграцията идва с отговорност. Става въпрос за внимателно разделяне на малки парченца код, правене на малки промени наведнъж, нарушаване на тонове тестове в процеса. След това ръчно ги проверявате и поправяте един по един, сливате в основния клон, разгръщате в производство, наблюдавате регистрационните файлове и сигналите. Повтаряйки го отново и отново...
Ето някои съвети за миграция, които научихме по пътя:
Започнете с търсене и замяна: Започнете, като замените старите модели с новата рамка. Коригирайте всички проблеми с компилацията, които възникват от този процес.
Например, заменете всички error
в този пакет с Error
.
type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, error) }
Новият код ще изглежда така:
import . "connectly.ai/go/pkgs/errors" type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, Error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error) }
Мигрирайте един пакет наведнъж: Започнете с пакетите от най-ниско ниво и продължете напред. По този начин можете да гарантирате, че пакетите от по-ниско ниво са напълно мигрирани, преди да преминете към тези от по-високо ниво.
Добавяне на липсващи модулни тестове: Ако части от кодовата база нямат тестове, добавете ги. Ако не сте уверени в промените си, добавете още тестове. Те са полезни, за да се уверите, че вашите промени не нарушават съществуващата функционалност.
Ако вашият пакет зависи от извикване на пакети от по-високо ниво: Помислете за промяна на свързаните функции на ОТСТАРЕЛИ, след което добавете нови функции с новия тип Error
.
Да предположим, че мигрирате пакета на базата данни, който има метода 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) }) }
И се използва в пакета за потребителски услуги:
err = s.DB(ctx).Transaction(func(tx *database.DB) error { user, usrErr := s.repo.CreateUser(ctx, tx, user) if usrErr != nil { return usrErr } }
Тъй като първо мигрирате пакета database
, оставяйки user
и десетки други пакети като него. Извикването s.repo.CreateUser()
все още връща стария тип error
, докато методът Transaction()
трябва да върне новия тип Error
. Можете да промените метода Transaction()
на DEPRECATED
и да добавите нов метод 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) }
Добавяйте нови кодове за грешки, докато вървите : Когато срещнете грешка, която не се вписва в съществуващите, добавете нов код. Това ще ви помогне да изградите изчерпателен набор от кодове за грешки с течение на времето. Кодовете от други пакети са винаги налични като референции.
Отначало обработката на грешки в Go може да изглежда проста – просто върнете error
и продължете напред. Но с нарастването на нашата кодова база тази простота се превърна в заплетена бъркотия от неясни регистрационни файлове, непоследователна обработка и безкрайни сесии за отстраняване на грешки.
Като се оттеглихме и преосмислихме как се справяме с грешките, ние изградихме система, която работи за нас, а не срещу нас. Централизираните и структурирани кодове на пространството от имена ни дават яснота, докато инструментите за картографиране, обвиване и тестване на грешки улесняват живота ни. Вместо да плуваме в морето от трупи, сега имаме значими, проследими грешки, които ни казват какво не е наред и къде да търсим.
Тази рамка не цели само да направи нашия код по-чист; това е за спестяване на време, намаляване на разочарованието и ни помага да се подготвим за неизвестното. Това е само началото на едно пътуване — все още откриваме още модели — но резултатът е система, която по някакъв начин може да внесе спокойствие при обработката на грешки. Надяваме се, че може да предизвика някои идеи и за вашите проекти! 😊
Аз съм Оливър Нгуен. Създател на софтуер, работещ предимно в Go и JavaScript. Харесва ми да уча и да виждам по-добра версия на себе си всеки ден. От време на време създавайте нови проекти с отворен код. Споделяйте знания и мисли по време на моето пътуване.
Публикацията е публикувана и на blog.connectly.ai и olivernguyen.io 👋