Справувањето со грешките во 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 инспириран одMeta's Graph API :
message Error { string message = 1; ErrorType type = 2; ErrorCode code = 3; string user_title = 4; string user_message = 5; string trace_id = 6; map<string, string> details = 7; } enum ErrorType { ERROR_TYPE_UNSPECIFIED = 1; ERROR_TYPE_AUTHENTICATION = 2; ERROR_TYPE_INVALID_REQUEST = 3; ERROR_TYPE_RATE_LIMIT = 4; ERROR_TYPE_BUSINESS_LIMIT = 5; ERROR_TYPE_WEBHOOK_DELIVERY = 6; } enum ErrorCode { ERROR_CODE_UNSPECIFIED = 1 [(error_type = UNSPECIFIED)]; ERROR_CODE_UNAUTHENTICATED = 2 [(error_type = AUTHENTICATION)]; ERROR_CODE_CAMPAIGN_NOT_FOUND = 3 [(error_type = NOT_FOUND)]; ERROR_CODE_META_CHOSE_NOT_TO_DELIVER = 4 /* ... */; ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS = 5; }
Овој пристап помогна да се структурираат грешките, но со текот на времето, типовите и шифрите на грешки беа додадени без јасен план, што доведе до недоследности и дуплирање.
Грешки беа прогласени насекаде
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
s.
Грешка може да заврши една или повеќе грешки. Заедно, тие формираат дрво.
[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 { /* ... */ }
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 👋