Вебинар: Использование PVS-Studio при разработке встраиваемых систем - 14.05
Tе, кто до Go писал на языках с конструкциями try/catch, таких как Java и C#, наверняка испытывают легкое замешательство. Руки так и тянутся к try/catch заменителю — использованию recover в связке с defer — как к самому очевидному аналогу. Но это плохая практика и так делать не стоит. В этой статье разберёмся, почему и как можно ошибиться при обработке ошибок в Go.

Идея статьи появилась спонтанно. Материалов про обработку ошибок в Go и так достаточно, поэтому не хотелось повторять одно и то же.
Но ситуация изменилась в процессе реализации и тестирования диагностических правил для нового Go анализатора PVS-Studio. Мы уже описывали в статье, как тестируем свой анализатор на реальных проектах.
Если кратко: во время тестирования нашего статического анализатора PVS-Studio на реальных и достаточно крупных проектах с GitHub были обнаружены типовые ошибки при обработке ошибок (извините за тавтологию), которыми мы и хотим поделиться в этой статье. Возможно, в будущем таких ошибок станет меньше, как говорится: "Предупреждён — значит вооружён".
Но для тех, кто не в курсе или хочет постепенно войти в контекст, давайте разберёмся вообще, как принято обрабатывать ошибки в Go, и какая идея за этим стоит.
Отказ от обработки ошибок через выброс и перехват исключений — не недоработка разработчиков языка, а осознанное решение. Авторы языка считают, что привязывание обработки ошибок к управляющей конструкции try catch приводит к тому, что код становится излишне извилистым и сложным. А также вынуждает рассматривать простые ошибки как исключения (прочитать эту информацию можно здесь).
Например, наша программа должна открывать файл и вносить туда какие-то изменения. Возможны различные ситуации: файл может как существовать, так и отсутствовать, поэтому странно относиться к ошибке открытия файла, как к исключению.
Go придерживается простого принципа: ошибка — это значение. Обычное значение, которое функция возвращает явно и которое вызывающий код обязан явно обработать. Например:
file, err := os.Open(filename)
if err != nil {
// error handling
}
Да, на первый взгляд выглядит многословно, и это явный бойлерплейт. Но, с другой стороны, поток управления абсолютно понятен, и программу удобно читать сверху вниз.
Но зачем тогда panic и recover?
Паника — это сигнал о неустранимой ошибке. Это ситуации, которых в корректно написанной программе быть не должно: выход за границы среза, разыменование nil-указателя и так далее. Механизм recover же позволяет перехватить панику без падения всей программы. Но использовать эту связку как замену try/catch — значит идти против идиоматики языка и общепринятых норм.
Однако есть сценарии использования отлавливания паники, когда это уместно. Самый распространенный вариант — на границах пакетов или горутин, чтобы паника внутри не роняла всю программу:
func (e *encodeState) marshal(v any, opts encOpts) (err error) {
defer func() {
if r := recover(); r != nil {
if je, ok := r.(jsonError); ok {
err = je.error
} else {
panic(r)
}
}
}()
e.reflectValue(reflect.ValueOf(v), opts)
return nil
}
Этот пример из стандартного пакета json из файла encode.go. В данном случае, если происходит паника, которая разворачивает стек до вызова функции верхнего уровня, то программа восстанавливается после ошибки и возвращает соответствующее значение ошибки.
То есть recover должен служить защитным барьером от падения программы, а не механизмом бизнес-логики.
Мы разобрались, что в Go ошибку надо записывать в переменную и проверять её на nil. Казалось бы, проще некуда, но даже при таком простом подходе можно ошибиться. В качестве примера давайте рассмотрим код из открытого инструмента для менеджмента чувствительных данных — vault.
func (c *Core) PersistTOTPKey(....) error {
ks := &totpKey{
Key: key,
}
val, err := jsonutil.EncodeJSON(ks)
if err != nil {
return err
}
if c.barrier.Put(ctx, &logical.StorageEntry{
Key: fmt.Sprintf(....),
Value: val,
}); err != nil {
return err
}
return nil
}
Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. login_mfa.go 1099
В данном фрагменте кода можно заметить проверку ошибки, полученную в результате вызова функции jsonutil.EncodeJSON(ks). Затем эта же ошибка проверяется в следующем условии:
if c.barrier.Put(ctx, &logical.StorageEntry{
Key: fmt.Sprintf(....),
Value: val,
}); err != nil {
return err
}
Очевидно, что нет смысла дважды проверять одну и ту же ошибку. Кроме того, если err != nil является верным, то произойдёт возврат из метода. Скорее всего, предполагалось получить из метода Put какое-то значение и проверить его. Тем более метод Put возвращает значение ошибки, которое как раз стоит проверить:
type Storage interface {
....
Put(context.Context, *StorageEntry) error
....
}
Поэтому исправить код можно следующим образом:
val, err := jsonutil.EncodeJSON(ks)
if err != nil {
return err
}
if err := c.barrier.Put(ctx, &logical.StorageEntry{
Key: fmt.Sprintf(....),
Value: val,
}); err != nil {
return err
}
В таком случае во втором if мы проверим ошибку, полученную в результате метода Put.
А в исходном коде проекта FerretDB, который является альтернативой MongoDB, можно встретить следующее упущение:
func (h *Handler) msgKillCursors(....) (*middleware.Response, error) {
....
cursorsV, err := getRequiredParamAny(doc, "cursors")
if err != nil {
return nil, err
}
curArr, ok := cursorsV.(wirebson.AnyArray)
if !ok { // <=
msg := fmt.Sprintf(....)
return nil, lazyerrors.Error(....)
}
cursors, err := curArr.Decode()
if !ok { // <=
return nil, lazyerrors.Error(err)
}
....
}
Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. msg_killcursors.go 61
В данном случае значение переменной err изменяется. Но зато дважды проверяется успешность приведения переменной cursorsV к типу wirenson.AnyArray. Скорее всего второе условие !ok написали случайно, а код должен выглядеть следующим образом:
cursors, err := curArr.Decode()
if err != nil {
return nil, lazyerrors.Error(err)
}
Код, в котором случайно проверяют одну и ту же ошибку, можно также встретить в менеджере виртуальных машин Incus.
func (d *proxy) Start() (*deviceConfig.RunConfig, error) {
....
err = p.Save(pidPath)
if err != nil {
// Kill Process if started, but could not save the file
err2 := p.Stop()
if err != nil {
return fmt.Errorf("....: %s: %s", err, err2)
}
return fmt.Errorf("....: %w", d.name, err)
}
....
}
Предупреждение PVS-Studio: V8020 Recurring check. Thе 'err != nil' condition was already verified on line 407 proxy.go 407
Очевидно, что во втором условии стоит проверять err2 вместо err, учитывая, что один раз err уже была проверена, и после этого она не менялась. А также err2 фигурирует в обработке ошибки:
return fmt.Errorf("....: %s: %s", err, err2)
Поэтому было бы странно её не проверить.
Больше всего подобных ошибок с помощью анализатора PVS-Studio было найдено в открытом коде платформы Rancher. Целых 65 срабатываний такого вида:
func (c *MachineClient) ListAll(opts *types.ListOpts) (....) {
resp := &MachineCollection{}
resp, err := c.List(opts)
if err != nil {
return resp, err
}
data := resp.Data
for next, err := resp.Next();
next != nil && err == nil; next, err = next.Next() {
data = append(data, next.Data...)
resp = next
resp.Data = data
}
if err != nil {
return resp, err
}
return resp, err
}
Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. zz_generated_cluster.x-k8s.io.machine.go 113
Этот фрагмент кода очень интересен тем, что вполне себе логичен: итерация по коллекции происходит до тех пор, пока next != nil и err == nil. И после цикла полезно проверитьerr != nil. Но есть один нюанс.
Анализатор подсказывает нам, что фигурирующий в цикле err — это не тот же самый err, который проверяется после цикла. err, проверяемый после цикла, — это все та же ошибка, которая получается в результате объявления:
resp, err := c.List(opts)
То есть, если во время итерации окажется, что err != nil, то цикл прервется, и в таком случае хотелось бы вывести ошибку. Но будет выведена ошибка, которая получена в результате приведённого выше объявления.
Чтобы исправить это, достаточно проверять err внутри самого цикла с последующим return. Тогда метод вернёт ту ошибку, которая получилась в результате итераций.
Количество срабатываний легко объясняется тем, что существует много типов, для которых нужно реализовать один и тот же код с небольшими изменениями. То есть старый код просто копируется вместе со всеми багами.
Мне стало интересно поискать корни этого кода, и я наткнулся на коммит, в котором добавили аж 307251 строчек кода. А комментарий "Add generated code" позволяет нам окончательно убедиться в том, что код с ошибкой был сгенерирован, и затем его разнесли по всему проекту
Что в итоге? Go бесспорно более ясный и явный в обработке ошибок. Но, тем не менее, ошибку допустить все-таки возможно. Вы могли убедиться в этом при просмотре примеров из открытых проектов с GitHub. Примечательно, что проекты достаточно большие и популярные (некоторые больше 30k звезд), но в них все равно можно найти простейшие ошибки.
Это ещё раз говорит о необходимости тестирования проектов. В частности, в данных случаях поможет статический анализатор.
Недавно в PVS-Studio появилась программа раннего доступа к нашим новым анализаторам, в которой вы можете принять участие по ссылке! EAP доступно по направлениям JavaScript, TypeScript и Go.
Берегите себя и свой код!
0