paint-brush
Спречете ги грешките да растат со оваа нова рамкаод страна на@olvrng
270 читања

Спречете ги грешките да растат со оваа нова рамка

од страна на Oliver Nguyen30m2024/12/11
Read on Terminal Reader

Премногу долго; Да чита

Ова е приказна за тоа како започнавме со едноставен пристап за справување со грешки, бевме целосно фрустрирани како што проблемите растеа и на крајот изградивме сопствена рамка за грешки.
featured image - Спречете ги грешките да растат со оваа нова рамка
Oliver Nguyen HackerNoon profile picture
0-item
1-item

Справувањето со грешките во Go е едноставно и флексибилно - но нема структура!


Тоа треба да биде едноставно, нели? Само вратете error , завиткана со порака и продолжете понатаму. Па, таа едноставност брзо се претвора во хаотична, бидејќи нашата база на кодови расте со повеќе пакети, повеќе програмери и повеќе „брзи поправки“ кои остануваат таму засекогаш. Со текот на времето, дневниците се полни со „не успеав да го направам ова“ и „неочекувано она“, и никој не знае дали е вина на корисникот, вина на серверот, код за баг или е само погрешно усогласување на ѕвездите!


Грешки се создаваат со неконзистентни пораки. Секој пакет има свој сет на стилови, константи или сопствени типови на грешки. Кодовите за грешка се додаваат произволно. Нема лесен начин да се каже кои грешки може да се вратат од која функција без да се копа во нејзината имплементација!


Така, го прифатив предизвикот да создадам нова рамка за грешки. Решивме да одиме со структуриран, централизиран систем кој користи кодови за именски простор за да ги направи грешките значајни, следени и - што е најважно - да ни даде мир на умот!


Ова е приказна за тоа како започнавме со едноставен пристап за справување со грешки, бевме целосно фрустрирани како што проблемите растеа и на крајот изградивме сопствена рамка за грешки. Одлуките за дизајн, како се имплементира, научените лекции и зошто го трансформираа нашиот пристап кон управувањето со грешките. Се надевам дека ќе донесе некои идеи и за вас!


Грешките на Go се само вредности

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-redis и gorm.io дефинираат променливи за грешка за повеќекратна употреба.
  • Прилагодени типови грешки: Библиотеките како lib/pq grpc/status. Error создаваат специјализирани типови грешки, често со поврзани кодови за дополнителен контекст.
  • Интерфејси на грешки со имплементации: aws-sdk-go користи пристап базиран на интерфејс за да ги дефинира типовите на грешки со различни имплементации.
  • Или повеќе интерфејси: како Docker's erdefs , кој дефинира повеќе интерфејси за класификација и управување со грешките.

Почнавме со заеднички пристап

Во раните денови, како и многу програмери на 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


Ниту една стандардизација не доведе до неправилно ракување со грешките

  • Секој пакет различно се справува со грешките, што го прави тешко да се знае дали функцијата вратила, завиткала или трансформирала грешки.
  • Контекстот често се губеше додека грешките се шират.
  • Горните слоеви добија нејасни 500 внатрешни грешки на серверот без јасни основни причини.


Ниту една категоризација не го оневозможи следењето

  • Грешките не беа класифицирани според сериозноста или однесувањето: 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 и ознаки.

  • Инженерите го дефинираат мапирањето на централизирано место, а рамката ќе го мапира секој код за грешка на соодветните Protobuf 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 👋

L O A D I N G
. . . comments & more!

About Author

Oliver Nguyen HackerNoon profile picture
Oliver Nguyen@olvrng
I’m a software maker working mostly in Go and JavaScript. Share knowledge and thoughts during my journey.

ВИСЕТЕ ТАГОВИ

ОВОЈ СТАТИЈА БЕШЕ ПРЕТСТАВЕН ВО...