Практика Go — Обработка ошибок (2 часть)
- воскресенье, 10 сентября 2023 г. в 00:00:13
Ранее: 1 часть
Общим договором для функций, возвращающих значение интерфейсного типа error
, является то, что вызывающая сторона не должна ничего предполагать о состоянии других значений, возвращаемых в результате этого вызова, без предварительной проверки ошибки.
В большинстве случаев значения ошибок, возвращаемые функциями, должны быть непрозрачными для вызывающей стороны. То есть проверка на то, что ошибка равна nil
, указывает на успешность или неуспешность вызова, и не более того.
В небольшом количестве случаев, как правило, связанных с взаимодействием с миром за пределами вашего процесса, например, с сетевой активностью, вызывающая сторона должна выяснить природу ошибки, чтобы решить, целесообразно ли повторить операцию.
Часто авторы пакетов просят возвращать ошибки известного публичного типа, чтобы вызывающая сторона могла их проверить. Я считаю, что такая практика приводит к ряду нежелательных результатов:
Публичные типы ошибок увеличивают площадь API пакета.
Новые реализации должны возвращать только типы, указанные в объявлении интерфейса, даже если они плохо подходят.
Тип ошибки не может быть изменён или объявлен устаревшим после введения без нарушения совместимости, что приводит к хрупкости API.
Вызывающая сторона должна чувствовать себя не более комфортно, утверждая, что ошибка относится к определенному типу, чем если бы они утверждали, что строка, возвращаемая функцией Error()
, соответствует определенному шаблону.
Вместо этого я предлагаю решение, которое позволит авторам и потребителям пакетов сообщать о своих намерениях, не слишком привязывая свою реализацию к вызывающей стороне.
Не утверждать, что значение ошибки относится к определенному типу, а утверждать, что значение реализует определённое поведение.
Это предложение соответствует характеру has a
неявных интерфейсов Go, а не характеру is a [subtype of]
языков, основанных на наследовании. Рассмотрим следующий пример:
func isTimeout(err error) bool {
type timeout interface {
Timeout() bool
}
te, ok := err.(timeout)
return ok && te.Timeout()
}
Вызывающая сторона может использовать функцию isTimeout()
для определения того, связана ли ошибка с таймаутом, через свою реализацию интерфейса таймаута, а затем подтвердить, что ошибка была связана с таймаутом - и всё это без знания типа или первоначального источника значения ошибки.
Данный метод позволяет обернуть ошибки, обычно с помощью библиотек, аннотирующих путь ошибки; при условии, что обёрнутые типы ошибок также реализуют интерфейсы обёрнутой ошибки.
Это может показаться неразрешимой проблемой, но на практике существует относительно небольшое количество часто используемых интерфейсных методов, так что Timeout() bool
и Temporary() bool
покроют большое количество случаев использования.
Не утверждайте ошибки для типа, утверждайте для поведения.
Для авторов пакетов, если ваш пакет генерирует ошибки временного характера, убедитесь, что вы возвращаете типы ошибок, реализующие соответствующие методы интерфейса. Если вы оборачиваете значения ошибок на выходе, убедитесь, что ваши обёртки уважают интерфейс (интерфейсы), в котором реализовано базовое значение ошибки.
Для пользователей пакетов, если вам необходимо проверить ошибку, используйте интерфейсы для утверждения ожидаемого поведения, а не типа ошибки. Не требуйте от авторов пакетов публичных типов ошибок; попросите их привести свои типы в соответствие с общими интерфейсами, снабдив их соответствующими методами Timeout()
или Temporary()
.
Это мысленный эксперимент о значениях дозорных ошибок в Go.
Дозорные ошибки - это плохо, они вносят сильную связь между исходным кодом и временем выполнения, но иногда они необходимы. io.EOF
- одно из таких дозорных значений. В идеале дозорное значение должно вести себя как константа, то есть быть неизменяемым и взаимозаменяемым.
Первая проблема заключается в том, что io.EOF
является публичной переменной - любой код, импортирующий пакет io
, может изменить значение io.EOF
. Оказывается, что в большинстве случаев это не имеет большого значения, но может стать очень запутанной проблемой при отладке.
fmt.Println(io.EOF == io.EOF) // true
x := io.EOF
fmt.Println(io.EOF == x) // true
io.EOF = fmt.Errorf("whoops")
fmt.Println(io.EOF == io.EOF) // true
fmt.Println(x == io.EOF) // false
Вторая проблема заключается в том, что io.EOF
ведёт себя как синглтон, а не как константа. Даже если мы в точности повторим процедуру, используемую пакетом io
для создания собственного значения EOF
, они будут несопоставимы.
err := errors.New("EOF") // io/io.go line 38
fmt.Println(io.EOF == err) // false
Объединив эти свойства, можно получить набор странных поведений, обусловленных тем, что значения ошибок в Go, которые традиционно создаются с помощью errors.New
или fmt.Errorf
, не являются константами.
Прежде чем я представлю своё решение, давайте вспомним, как работает интерфейс ошибок в Go. Любой тип, имеющий строковый метод Error()
, соответствует интерфейсу ошибок. Сюда входят примитивные типы подобно string
, в том числе и константные строки.
На этом фоне рассмотрим реализацию этой ошибки.
type Error string
func (e Error) Error() string { return string(e) }
Она похожа на реализацию errors.errorString
, которая используется в errors.New
. Однако в отличие от errors.errorString
этот тип является константным выражением.
const err = Error("EOF")
const err2 = errorString{"EOF"} // const initializer errorString literal is not a constant
Поскольку константы типа Error не являются переменными, они неизменяемы.
const err = Error("EOF")
err = Error("not EOF") // error, cannot assign to err
Кроме того, две константы-строки всегда равны, если равно их содержимое, а значит, равны и два значения Error
с одинаковым содержимым.
const err = Error("EOF")
fmt.Println(err == Error("EOF")) // true
По-другому говоря, равные значения Error
являются одинаковыми, подобно тому как константа 1
является такой же, как и любая другая константа 1
.
const eof = Error("eof")
type Reader struct{}
func (r *Reader) Read([]byte) (int, error) {
return 0, eof
}
func main() {
var r Reader
_, err := r.Read([]byte{})
fmt.Println(err == eof) // true
}
Можем ли мы изменить определение io.EOF
на константу? Оказалось, что это прекрасно компилируется и проходит все тесты, но для контракта Go 1 это, скорее всего, натяжка.
Однако это не мешает вам использовать данную идиому в своем коде. Хотя в любом случае не стоит использовать дозорные ошибки.
January, 2012
Как Go правильно использует исключения? За счёт того, что их вообще нет.
До моего времени существовал язык C, и ошибки были вашей проблемой. В общем-то, это было нормально, потому что если вы владели винтажным мини-компьютером 70-х годов, то у вас наверняка была своя доля проблем. Поскольку C был языком с одним возвратом, все становилось немного сложнее, когда нужно было узнать результат функции, которая иногда могла пойти не так. Отличным примером этого является IO
, или сокеты, но есть и более опасные случаи, например, преобразование строки в целочисленное значение. Для решения этой проблемы появилось несколько идиом. Например, если у вас есть функция, которая должна возиться с содержимым структуры, вы можете передать ей указатель на неё, а код возврата покажет, была ли эта работа успешной. Есть и другие идиомы, но я не программист на Си, да и не в этом суть статьи.
Затем появился C++, который рассматривал ситуацию с ошибками и пытался её улучшить. Если у вас была функция, которая выполняла какую-то работу, она могла вернуть значение или выбросить исключение, которое вы должны были поймать и обработать. Бам! Теперь программисты на C++ могут сигнализировать об ошибках без необходимости объединять их единственным возвращаемым значением. Ещё лучше то, что исключения можно обрабатывать в любом месте стека вызовов. Если вы не знаете, как обработать это исключение, оно попадет к тому, кто знает. Все неприятности с errno
и потоками решены. Решение найдено!
Вроде бы.
Недостатком исключений в C++ является то, что вы не можете сказать (не имея исходного текста и стимула для проверки), может ли какая-либо вызываемая вами функция выбросить исключение. В дополнение к заботам об утечках ресурсов и деструкторах приходится заботиться о RAII и транзакционной семантике, чтобы гарантировать безопасность своих методов от исключений, если они находятся где-то в стеке вызовов в момент выброса исключения. Решив одну проблему, C++ создал другую.
Поэтому разработчики Java сели, погладили бороды и решили, что проблема заключается не в самих исключениях, а в том, что они могут быть выброшены без предупреждения; поэтому в Java появились проверяемые исключения. Вы не можете бросить исключение внутри метода, не указав в сигнатуре метода, что вы можете это сделать, и вы не можете вызвать метод, который может бросить исключение, не обернув его в код для обработки потенциального исключения. С помощью магии компиляции и дисциплины проблема ошибок решена, не так ли?
Это примерно то время, когда я вступил в эту историю, начало тысячелетия, примерно Java 1.4. Тогда, как и сейчас, я был согласен с тем, что способ проверки исключений в Java более цивилизованный, более безопасный, чем способ в C++. Думаю, что я был не один такой. Поскольку исключения стали безопасными, разработчики начали исследовать их границы. Появились системы корутин, построенные с использованием исключений, и, по крайней мере, одна известная мне библиотека разбора XML использовала исключения в качестве техники управления потоком данных. Обычным явлением для устоявшихся Java-веб-приложений стало то, что при запуске они выдают на экран множество исключений, послушно регистрируемых вместе со стеком вызовов. Исключения в Java перестали быть исключительными, они стали обычным явлением. Они используются во всех случаях - от благотворных до катастрофических, причём дифференцировать степень серьёзности исключений приходится вызывающему функцию.
Если этого было недостаточно, то не все исключения Java проверяются, подклассы java.Error
и java.RuntimeException
не проверяются. Их не нужно объявлять, достаточно просто бросить. Возможно, это было хорошей идеей, нулевые ссылки и ошибки подмассивов массивов теперь просто реализовать во время выполнения, но в то же время, поскольку каждое исключение Java расширяет java.Exception
, любой кусок кода может его поймать, даже если в этом нет особого смысла, что приводит к шаблонам типа
catch (e Exception) { // ignore }
Таким образом, Java в основном решила проблему непроверяемых исключений в C++ и ввела целый ряд своих собственных. Однако я утверждаю, что Java не решила реальной проблемы, той проблемы, которую не решил и C++. Проблема того, как сигнализировать вызывающему функцию пользователю о том, что что-то пошло не так.
Go решает проблему исключений за счёт отсутствия исключений. Вместо этого Go позволяет функциям возвращать тип ошибки в дополнение к результату благодаря поддержке множественных возвращаемых значений. Объявляя возвращаемое значение интерфейсного типа error
, вы указываете вызывающей стороне, что данный метод может работать неправильно. Если функция возвращает значение и ошибку, то вы не можете ничего предполагать о значении, пока не проверили ошибку. Единственное, где может быть допустимо игнорировать значение ошибки, - это когда вам безразличны другие возвращаемые значения.
В Go есть функция, называемая panic
, и если сильно прищуриться, то можно подумать, что panic
- это то же самое, что и throw
, но вы ошибётесь. Когда вы бросаете исключение, вы делаете его проблемой вызывающей стороны
throw new SomeoneElsesProblem();
Например, в C++ вы можете бросить исключение, когда не можете преобразовать перечисление в его строковый эквивалент, или в Java при разборе даты из строки. В мире, подключённом к Интернету, где каждый входной сигнал из сети должен рассматриваться как враждебный, является ли неспособность разобрать строку на дату действительно исключительной? Конечно, нет.
Когда вы паникуете в Go, вы сходите с ума, это не чья-то проблема, это конец игры.
panic("inconceivable")
И panic
всегда фатален для вашей программы. При panic
вы никогда не предполагаете, что вызывающий код сможет решить проблему. Поэтому panic
используется только в исключительных обстоятельствах, когда продолжение работы вашего кода или кого-либо, интегрирующего ваш код, невозможно.
Решение не включать исключения в Go является примером его простоты и ортогональности. Используя несколько возвращаемых значений и простую конвенцию, Go решает проблему информирования программистов о том, что что-то пошло не так, и оставляет панику для действительно исключительных случаев.
November, 2014
Пересмотрев свой пост об обработке ошибок и исключениях, написанный задолго до выхода Go 1.0, я с удовлетворением отметил, что он выдержал испытание временем.
Java всесторонне продемонстрировала, что проверяемые исключения (фактически, наличие как проверяемых, так и непроверяемых исключений) стали катастрофой для развития языка.
Проверенные исключения наложили удушающее ярмо обратной совместимости на архитекторов, пытающихся модернизировать дизайн Java десятилетней давности.
Я не вижу, чтобы в будущем разработчики языка приняли такое же решение, каким бы благим намерением они ни руководствовались, как разработчики Java в 1995 году.
Исключения в C++ по-прежнему так же трудно использовать безопасно, как и три десятилетия назад. Когда любая часть стека вызовов может взорваться без предупреждения. Неудивительно, что многие разработчики C++ предписывают не использовать исключения.
Что же остается делать в Go, с его иногда длинными, но всегда предсказуемыми значениями ошибок?
Первый - это наблюдение Роба Пайка, сделанное им на GopherCon 2014.
Значения ошибок в Go не являются чем-то особенным, это просто значения, как и любые другие, и таким образом в вашем распоряжении оказывается весь язык.
Я думаю, что это настолько фундаментальная вещь, что она ускользает от внимания большинства программистов на Go.
Второе, на что я наткнулся почти через год после своего первого сообщения, - это презентация Андрея Александреску, в которой он отмечает (примерно на 11-й минуте):
... код с исключениями безнадежно последовательный. В полёте есть только одно исключение, как это ни причудливо. В любой момент полёта может быть только одно исключение. ... [они] требуют немедленного и исключительного внимания. [Исключение] выходит на первый план, вы должны справиться с ним прямо сейчас".
Для меня это аргумент, который решает дело в пользу ошибок, а не исключений.
Рассмотрим этот простой пример, который использует содержимое io.Reader
.
func ReadAll(r io.Reader) ([]byte, error) {
var buf = make([]byte, 1024)
var result []byte
for {
n, err := r.Read(buf)
result = append(result, buf[:n]...)
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
}
}
В Go работа с любыми возвращаемыми данными, а также с ошибками, является второй натурой. Я не могу представить себе, как можно было бы так же просто работать с этим в сценарии, основанном на исключениях.
Всё, что я написал тогда, я считаю верным и сегодня. Так что в заключение, украду строчку у Черчилля,
Возврат значений ошибок - это худшая форма обработки ошибок, не считая всех остальных, которые были испробованы.
January, 2015
В предыдущей заметке я удвоил свое утверждение о том, что стратегия обработки ошибок в Go, по большому счёту, является наилучшей.
В этой заметке я хочу пойти дальше и доказать, что множественные возвраты и значения ошибок являются лучшими,
Когда я говорю "лучшая", я, конечно же, имею в виду, что из всего набора вариантов, доступных программистам, пишущим реальные программы - потому что реальные программы должны справляться с тем, что идёт не так.
Я буду использовать только тот язык Go, который мы имеем сегодня, а не любую версию языка, которая может появиться в будущем - просто непрактично задерживать дыхание на такой срок. Как я покажу, такие дополнения к языку, как, осмелюсь сказать, исключения, не изменят результата.
Начнём обсуждение с придуманной, но очень простой функции, которая демонстрирует необходимость обработки ошибок.
package main
import "fmt"
// Positive returns true if the number is positive, false if it is negative.
func Positive(n int) bool {
return n > -1
}
func Check(n int) {
if Positive(n) {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}
func main() {
Check(1)
Check(0)
Check(-1)
}
Если выполнить этот код, то получим следующий результат
1 is positive
0 is positive
-1 is negative
что неверно.
Как эта однострочная функция может быть ошибочной? Она неверна, потому что ноль не является ни положительным, ни отрицательным, и это не может быть точно отражено возвращаемым значением булевой функции Positive
.
Это надуманный пример, но, надеюсь, он может быть адаптирован для обсуждения затрат и преимуществ различных методов обработки ошибок.
Независимо от того, какое решение будет признано наилучшим, в Positive
необходимо будет добавить проверку на ненулевое предусловие. Приведем пример с добавлением предусловия
// Positive returns true if the number is positive, false if it is negative.
// The second return value indicates if the result is valid, which in the case
// of n == 0, is not valid.
func Positive(n int) (bool, bool) {
if n == 0 {
return false, false
}
return n > -1, true
}
func Check(n int) {
pos, ok := Positive(n)
if !ok {
fmt.Println(n, "is neither")
return
}
if pos {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}
Запустив эту программу, мы видим, что ошибка исправлена,
1 is positive
0 is neither
-1 is negative
хотя и в неприглядном виде. Для тех, кому интересно, я также попробовал версию с использованием переключателя, которая была более сложной для чтения ради экономии одной строки кода.
Это базовый вариант для сравнения с другими решениями.
Возврат булевых значений - редкость, гораздо чаще возвращается значение ошибки, даже если набор ошибок исправлен. Для полноты картины, а также потому, что этот простой пример должен работать и в более сложных условиях, приведем пример, использующий значение, соответствующее интерфейсу ошибок.
// Positive returns true if the number is positive, false if it is negative.
func Positive(n int) (bool, error) {
if n == 0 {
return false, errors.New("undefined")
}
return n > -1, nil
}
func Check(n int) {
pos, err := Positive(n)
if err != nil {
fmt.Println(n, err)
return
}
if pos {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}
В результате получается функция, которая выполняет то же самое, и вызывающая сторона должна проверить результат практически идентичным способом.
Это лишний раз подчеркивает гибкость методологии "ошибки - значения" в Go. Когда возникает ошибка, указывающая только на успех или неудачу (вспомните форму поиска карты с двумя результатами), вместо интерфейсного значения можно подставить булево, что устраняет путаницу, возникающую из-за типизированных nils
и nilness
интерфейсных значений.
Приведем пример, позволяющий возвращать три состояния: true
, false
и nil
(у тех, кто знаком с теорией множеств или SQL, на этом месте передергивает).
// If the result not nil, the result is true if the number is
// positive, false if it is negative.
func Positive(n int) *bool {
if n == 0 {
return nil
}
r := n > -1
return &r
}
func Check(n int) {
pos := Positive(n)
if pos == nil {
fmt.Println(n, "is neither")
return
}
if *pos {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}
В Positive
появилась ещё одна строка, поскольку требуется перехватить адрес результата сравнения.
Хуже того, теперь, прежде чем возвращённое значение может быть использовано где-либо, оно должно быть проверено на то, что указывает на корректный адрес. Именно с такой ситуацией постоянно сталкиваются Java-разработчики, что приводит к глубокой ненависти к nil
(с полным основанием). Очевидно, что такое решение не является жизнеспособным.
Для полноты картины рассмотрим версию этого кода, которая пытается имитировать исключения с помощью panic
.
// Positive returns true if the number is positive, false if it is negative.
// In the case that n is 0, Positive will panic.
func Positive(n int) bool {
if n == 0 {
panic("undefined")
}
return n > -1
}
func Check(n int) {
defer func() {
if recover() != nil {
fmt.Println("is neither")
}
}()
if Positive(n) {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}
... всё становится только хуже.
Для действительно исключительных случаев - тех, которые представляют собой либо неустранимые ошибки программирования, например выход индекса за пределы границ, либо неустранимую проблему окружения, например исчерпание стека, - у нас есть panic
.
Для всех остальных случаев любые состояния ошибки, с которыми вы столкнётесь в программе на Go, по определению не являются исключительными - вы ожидаете их, потому что независимо от того, возвращается ли булево значение или ошибка, это результат проверки в вашем коде.
Я считаю, что аргумент о том, что разработчики забывают проверять коды ошибок, опровергается противоположным аргументом - разработчики забывают обрабатывать исключения. И то, и другое может быть верно, в зависимости от языка, на котором вы основываете свой аргумент, но ни то, ни другое не является выигрышной позицией.
С учетом сказанного, проверять значение ошибки нужно только в том случае, если вам важен результат.
Знать разницу между тем, какие ошибки игнорировать, а какие проверять, - вот за что нам платят как профессионалам.
В статье я показал, что множественные возвраты и значения ошибок являются наиболее простыми и надёжными в использовании. Проще, чем любая другая форма обработки ошибок, включая те, которых в Go в его нынешнем виде даже не существует.
Итак, это лучшая демонстрация, которую я смог придумать, но я ожидаю, что другие смогут сделать это лучше, особенно там, где используется монадический стиль. Я с нетерпением жду ваших отзывов.
Go 2 стремится улучшить накладные расходы на обработку ошибок, но знаете ли вы, что лучше, чем улучшенный синтаксис для обработки ошибок? Отсутствие необходимости обрабатывать ошибки вообще. Я не говорю "удалить код обработки ошибок", я предлагаю изменить код таким образом, чтобы в нём не было такого количества ошибок, которые нужно обрабатывать.
Эта заметка написана на основе главы из книги Джона Оустерхаута "Философия проектирования программного обеспечения", озаглавленной "Избавление от ошибок" (Define Errors Out of Existence). Я попытаюсь применить его совет к Go.
Вот функция для подсчёта количества строк в файле,
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)
for {
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}
Мы создаём bufio.Reader
, затем в цикле вызываем метод ReadString
, увеличиваем счётчик до конца файла, после чего возвращаем количество прочитанных строк. Именно такой код мы и хотели написать, но вместо этого CountLines
усложняется обработкой ошибок. Например, есть такая странная конструкция:
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
Мы увеличиваем количество строк перед проверкой ошибки - это выглядит странно. Причина, по которой мы должны написать это таким образом, заключается в том, что ReadString
вернёт ошибку, если встретит конец файла - io.EOF
- до того, как попадёт на символ новой строки. Это может произойти, если в конце файла нет новой строки.
Чтобы решить эту проблему, мы изменим логику таким образом, чтобы увеличить счётчик строк, а затем посмотреть, нужно ли выходить из цикла. (Эта логика все ещё не верна, можете ли вы найти ошибку?)
Но мы ещё не закончили проверку ошибок. ReadString
вернёт io.EOF
, когда достигнет конца файла. Это ожидаемо, ReadString
нужно как-то сообщить, что хватит, читать больше нечего. Поэтому перед тем, как вернуть ошибку вызывающему CountLine
, нам нужно проверить, не была ли ошибка io.EOF
, и в этом случае распространить её вверх, иначе мы вернём nil
, чтобы сказать, что всё прошло нормально. Именно поэтому заключительная строка функции не просто
return lines, err
Я думаю, что это хороший пример замечания Расса Кокса о том, что обработка ошибок может затушевать работу функции. Рассмотрим улучшенную версию.
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
В этой улучшенной версии мы перешли от использования bufio.Reader
к bufio.Scanner
. Под капотом bufio.Scanner
использует bufio.Reader
, добавляя уровень абстракции, который позволяет устранить обработку ошибок, затруднявшую работу нашей предыдущей версии CountLines
(bufio.Scanner
может сканировать любой шаблон, по умолчанию он ищет новые строки).
Метод sc.Scan()
возвращает true
, если сканер нашел строку текста и не встретил ошибки. Таким образом, тело нашего цикла for
будет вызываться только тогда, когда в буфере сканера есть строка текста. Это означает, что наша модификация CountLines
корректно обрабатывает случай отсутствия новой строки в конце файла, а также корректно обрабатывает случай, когда файл пуст.
Во-вторых, поскольку sc.Scan
возвращает false
при возникновении ошибки, наш цикл for
завершится при достижении конца файла или при возникновении ошибки. Тип bufio.Scanner
запоминает первую встретившуюся ошибку, и мы восстанавливаем её после выхода из цикла с помощью метода sc.Err()
.
Наконец, buffo.Scanner
позаботится об обработке io.EOF
и преобразует его в nil, если конец файла был достигнут без повторной ошибки.
Мой второй пример вдохновлен статьей Роба Пайка в блоге "Errors are values".
При работе с открытием, записью и закрытием файлов обработка ошибок присутствует, но не является чрезмерной, поскольку эти операции могут быть инкапсулированы в такие помощники, как ioutil.ReadFile
и ioutil.WriteFile
. Однако при работе с низкоуровневыми сетевыми протоколами часто возникает необходимость строить ответ непосредственно с использованием примитивов ввода-вывода, в результате чего обработка ошибок может стать повторяющейся. Рассмотрим фрагмент HTTP-сервера, который строит ответ HTTP/1.1.
type Header struct {
Key, Value string
}
type Status struct {
Code int
Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return err
}
_, err = io.Copy(w, body)
return err
}
Сначала мы строим строку состояния, используя fmt.Fprintf
, и проверяем ошибку. Затем для каждого заголовка записываем ключ и значение заголовка, каждый раз проверяя ошибку. Наконец, мы завершаем секцию заголовков дополнительным \r\n
, проверяем ошибку и копируем тело ответа клиенту. И наконец, хотя нам не нужно проверять ошибку из io.Copy
, нам нужно перевести её из формы с двумя возвращаемыми значениями, которую возвращает io.Copy
, в форму с одним возвращаемым значением, которую ожидает WriteResponse
.
Мало того, что это много повторяющейся работы, так ещё и каждая операция - в первую очередь запись байтов в io.Writer
- имеет свою форму обработки ошибок. Но мы можем облегчить себе задачу, введя небольшой тип-обертку.
type errWriter struct {
io.Writer
err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}
var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}
errWriter
соответствует контракту io.Writer
, поэтому его можно использовать для обертывания существующего io.Writer
. errWriter
передаёт записи своему базовому писателю до тех пор, пока не будет обнаружена ошибка. С этого момента он отбрасывает все записи и возвращает предыдущую ошибку.
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
for _, h := range headers {
fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
}
fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)
return ew.err
}
Применение errWriter
к WriteResponse
значительно повышает ясность кода. Каждая из операций больше не должна заключаться в скобки с проверкой на ошибку. Сообщение об ошибке переносится в конец функции путём проверки поля ew.err
, что позволяет избежать раздражающей трансляции из возвращаемых значений io.Copy
.
Когда вы сталкиваетесь с чрезмерно сложной обработкой ошибок, попробуйте вынести часть операций во вспомогательный тип.