оригинал:Топ-10 самых распространенных ошибок, которые я видел в проектах Go
автор:Teiva Harsanyi
Переводчик:Simon Ma
10 самых распространенных ошибок, с которыми я сталкиваюсь при разработке на Go.Порядок не имеет значения.
неизвестное значение перечисления
Давайте рассмотрим простой пример:
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
Здесь мы создаем перечисление с помощью iota, и результат выглядит следующим образом:
StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2
Теперь предположим, что этоStatusтип является частью запроса JSON и будетmarshalled/unmarshalled.
Мы разработали следующую структуру:
type Request struct {
ID int `json:"Id"`
Timestamp int `json:"Timestamp"`
Status Status `json:"Status"`
}
Затем получите такой запрос:
{
"Id": 1234,
"Timestamp": 1563362390,
"Status": 0
}
Здесь нет ничего особенного, состояние будетunmarshalledзаStatusOpen.
Однако в качестве примера возьмем другой запрос без значения статуса:
{
"Id": 1235,
"Timestamp": 1563362390
}
В этом случае структура запросаStatusПоле будет инициализировано своим нулевым значением (дляuint32введите: 0), поэтому результат будетStatusOpenвместоStatusUnknown.
тогда лучший вариант действийУстановите неизвестное значение перечисления на 0:
type Status uint32
const (
StatusUnknown Status = iota
StatusOpen
StatusClosed
)
Если состояние не является частью запроса JSON, оно будет инициализировано какStatusUnknown, что соответствует нашим ожиданиям.
Автоматически оптимизированные тесты
Эталонное тестирование должно учитывать множество факторов, чтобы получить правильные результаты тестирования.
Распространенной ошибкой являетсяТестовый код незаметно оптимизируется компилятором.
Нижеteivah/bitvectorПример из библиотеки:
func clear(n uint64, i, j uint8) uint64 {
return (math.MaxUint64<<j | ((1 << i) - 1)) & n
}
Эта функция очищает биты в заданном диапазоне. Чтобы проверить это, это может быть сделано следующим образом:
func BenchmarkWrong(b *testing.B) {
for i := 0; i < b.N; i++ {
clear(1221892080809121, 10, 63)
}
}
В этом этапе,clearне вызывает никаких других функций, нетпобочный эффект. Таким образом, компилятор поместитclearОптимизирован во встроенную функцию. После встраивания это приведет к неточным результатам теста.
Одно решениеУстановите результат функции в глобальную переменную,Следующее:
var result uint64
func BenchmarkCorrect(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = clear(1221892080809121, 10, 63)
}
result = r
}
Таким образом, компилятор не будет знатьclearбудут ли побочные эффекты.
Следовательно, это не будетclearОптимизирован во встроенную функцию.
дальнейшее чтение
переданный указатель
При вызове функции переменная, переданная по значению, создаст копию переменной, а при передаче по указателю будет передан только адрес переменной в памяти.
Итак, будет ли передача по указателю быстрее, чем передача по значению? Пожалуйста, взглянитеэтот пример.
Я смоделировал в своей локальной среде0.3KB, а затем проверил скорость передачи по значению и по указателю соответственно.
Результаты показывают, что передача по значению более чем в 4 раза быстрее, чем передача по указателю, что противоречит здравому смыслу.
Результаты теста связаны с тем, как в Go управляется память. я не могу быть какУильям КеннедиОтличное объяснение этого, но позвольте мне попытаться подвести итог.
Примечание переводчика начинается
Автор не объяснил основной метод хранения памяти Go, добавил переводчик.
-
Вот введение из Библии языка Go:
Горутина начинает свой жизненный цикл с небольшим стеком, обычно всего 2 КБ.
Стек горутины, как и поток операционной системы, сохраняет локальные переменные своих активных или ожидающих вызовов функций, но, в отличие от потока ОС, размер стека горутины не является фиксированным; размер стека варьируется в зависимости от необходимости динамического масштабирования.
Максимальный стек горутин составляет 1 ГБ, что намного больше, чем у традиционного стека потоков фиксированного размера, хотя в целом большинству горутин такой большой стек не нужен.
-
Собственное понимание переводчика:
-
Стек: Каждый Goruntine начинается с отдельного стека для хранения данных. (Goruntine делится на main Goruntine и другие Goruntine, разница заключается в размере стартового стека)
-
Куча: данные, которые должны использоваться несколькими Goruntines, хранятся в куче.
-
Замечание переводчика
Известно, что вкучаиликучаназначить переменные.
- В стеке хранится текущий
GoroutineПеременная используется (Примечание переводчика: можно понять как локальную переменную). Как только функция возвращается, переменная выключается с стека. - хранилище кучиобщая переменная(глобальные переменные и т.д.).
Давайте рассмотрим простой пример, который возвращает одно значение:
func getFooValue() foo {
var result foo
// Do something
return result
}
При вызове функцииresultПеременная создается в текущем стеке Goruntine, и когда функция возвращается, копия значения передается получателю. а такжеresultСама переменная извлекается из текущего стека Goruntine.
Хотя он все еще существует в памяти, к нему больше нельзя получить доступ. А также могут быть стерты другими переменными данных.
Теперь рассмотрим пример, который возвращает указатель:
func getFooPointer() *foo {
var result foo
// Do something
return &result
}
При вызове функцииresultПеременная создается в текущем стеке Goruntine, и когда функция возвращается, получателю передается указатель (копия адреса переменной). еслиresultПеременная выталкивается из текущего стека Goruntine, получатель больше не сможет получить к ней доступ. (Примечание переводчика: эта ситуация называется «побег из памяти»)
В этом случае компилятор Go поместитresultПеременнаяПобегв место, где переменные могут быть разделены:куча.
Однако передача указателя — это другой случай. Например:
func main() {
p := &foo{}
f(p)
}
Потому что мы вызываем одну и ту же горутинуf,такpПеременные не нужно экранировать. Он просто помещается в стек, и подфункции могут получить к нему доступ. (Примечание переводчика: переменные, используемые другими Goruntines, не обязательно хранить в стеке)
Например,io.ReaderсерединаReadСигнатура метода получает параметр среза, считывает содержимое в срез и возвращает число прочитанных байтов. вместо возврата прочитанного фрагмента. (Примечание переводчика: если возвращается слайс, он будет экранирован в кучу.)
type Reader interface {
Read(p []byte) (n int, err error)
}
Почему стек такой быстрый? Есть две основные причины:
- Стек не нуждается в сборщике мусора. Как мы уже говорили, переменные помещаются в стек после создания и извлекаются из стека, как только функция возвращается. Нет необходимости в сложном процессе повторного использования неиспользуемых переменных.
- Хранение переменных не требует синхронизации. Куча принадлежит горутине, поэтому для хранения переменных не требуется синхронизация по сравнению с хранением переменных в куче.
Таким образом, при создании функции нашПоведение по умолчанию должно заключаться в использовании значенияа не указатели. только когда мыУказатели следует использовать только тогда, когда вы хотите совместно использовать переменные.
Если у нас есть проблемы с производительностью, мы можем использоватьgo build -gcflags "-m -m"Команда для отображения конкретной операции компилятора по экранированию переменных в кучу.
Опять же, передача по значению является наиболее подходящей для большинства случаев повседневного использования.
дальнейшее чтение
неожиданный перерыв
еслиfвозвращает true, что происходит в следующем примере?
for {
switch f() {
case true:
break
case false:
// Do something
}
}
мы позвонимbreakутверждение. Однако будетbreakвнеswitchзаявление вместоforцикл.
тот же вопрос:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break
}
}
breakа такжеselectпредложение, связанное сforЦикл не важен.
breakвнеfor/switch或for/selectОдно решениеИспользовать помеченный перерыв,Следующее:
loop:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break loop
}
}
Отсутствует контекстная ошибка
В Go по-прежнему есть возможности для улучшения обработки ошибок, настолько, что теперь обработка ошибок является наиболее ожидаемым требованием в Go2.
Текущая стандартная библиотека (до Go 1.13) предоставляет толькоerrorВ конструкторе , естественно, будет отсутствовать другая информация.
Давайте взглянемpkg/errorsИдея обработки ошибок в библиотеке:
An error should be handled only once. Logging an error is handling an error. So an error should either be logged or propagated.
(Перевод: Ошибки следует обрабатывать только один раз.logОшибки имеют дело с ошибками. Таким образом, ошибки должны быть зарегистрированы или распространены)
С текущей стандартной библиотекой это сложно сделать, потому что мы хотим добавить некоторую контекстную информацию к ошибке, чтобы придать ей иерархию.
Например: ожидаетсяRESTПример вызова, вызывающего проблему с базой данных:
unable to server HTTP POST request for customer 1234
|_ unable to insert customer contract abcd
|_ unable to commit transaction
если мы используемpkg/errors, что можно сделать так:
func postHandler(customer Customer) Status {
err := insert(customer.Contract)
if err != nil {
log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
}
return Status{ok: true}
}
func insert(contract Contract) error {
err := dbQuery(contract)
if err != nil {
return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
}
return nil
}
func dbQuery(contract Contract) error {
// Do something then fail
return errors.New("unable to commit transaction")
}
Если не начальный, возвращенный внешней библиотекойerrorможно использоватьerror.NewСоздайте. средний слойinsertДобавьте больше контекста к этой ошибке. наконец прошлоlogerror для обработки ошибок. Каждый уровень либо возвращает ошибку, либо обрабатывает ее.
Мы также можем проверить причину ошибки, чтобы интерпретировать, следует ли нам повторить попытку. Предположим, у нас есть библиотека из внешней библиотекиdbпакет для управления доступом к базе данных. Библиотека может вернутьdb.DBErrorвременная ошибка. Чтобы определить, нужна ли повторная попытка, мы должны проверить причину ошибки:
использоватьpkg/errorsпредоставлено вerrors.CauseПричину ошибки можно определить.
func postHandler(customer Customer) Status {
err := insert(customer.Contract)
if err != nil {
switch errors.Cause(err).(type) {
default:
log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
case *db.DBError:
return retry(customer)
}
}
return Status{ok: true}
}
func insert(contract Contract) error {
err := db.dbQuery(contract)
if err != nil {
return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
}
return nil
}
Распространенной ошибкой, которую я видел, является частичное использованиеpkg/errors. Например, проверьте наличие ошибок следующим образом:
switch err.(type) {
default:
log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
case *db.DBError:
return retry(customer)
}
В этом примере, еслиdb.DBErrorодеялоwrapped, он никогда не будет выполненretry.
дальнейшее чтение
Не просто проверяйте ошибки, обрабатывайте их изящно
Срезы масштабируются
Иногда мы знаем окончательную длину среза. Предположим, мы хотим положитьFooНарезать наBarslice, что означает, что обе части имеют одинаковую длину.
Я часто вижу срезы, инициализированные следующим образом:
var bars []Bar
bars := make([]Bar, 0)
Слайс — это не волшебная структура данных, которая удваивается в размере, если больше нет свободного места. В этом случае автоматически создается слайс (большей емкости) и элементы в нем копируются.
Если мы хотим разместить тысячи элементов, представьте, во сколько раз нам нужно расшириться. Хотя временная сложность вставкиO(1), но это все равно влияет на производительность.
Итак, если мы знаем окончательную длину, мы можем:
-
инициализировать его с предопределенной длиной
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } -
Или инициализируйте его длиной 0 и предопределенной емкостью:
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars }
Неопределенный контекст
context.ContextЧасто злоупотребляют. Согласно официальной документации:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
Это описание настолько общее, что некоторых людей смущает его использование.
Попробуем описать его подробно.ContextМожет содержать:
- A deadline(крайний срок). Это означает, что по истечении срока действия (через 250 мс или указанную дату) мы должны остановить выполняемую операцию (
I/Oзапрос, ожиданиеchannelввод и др.). - A cancelation signal(сигнал отмены). Как только мы получим сигнал, мы должны остановить текущую активность. Например, допустим, мы получаем два запроса: один на вставку некоторых данных и другой на отмену первого запроса. Это можно сделать, используя в первом вызове
cancelableКонтекст для достижения, как только мы получим второй запрос, этот контекст будет отменен. - Список ключей/значений основан на
interface{}Типы.
Стоит отметить, чтоКонтекст компонуется. Например, мы можем наследовать список дат истечения срока действия и ключей/значений.Context. Кроме того, несколькоgoroutinesмогу поделиться тем жеContext, отменитьContextНесколько действий могут быть остановлены.
Возвращаясь к нашей теме, позвольте мне привести пример из моего опыта.
на основеurfave/cli(Если вы не знали, это отличная библиотека для создания приложений командной строки в Go.) для создания приложения Go. После запуска программа наследует родительскуюContext. Это означает, что когда приложение остановлено, это будет использоватьсяContextОтправить сигнал отмены.
Что я испытал, так это то, что этоContextзвонитgRPCпри передаче напрямую, что я не хочу делать. Вместо этого я хочу отправить запрос на отмену, когда приложение останавливается или после 100 мс бездействия.
Для этого просто создайте комбинированныйContext. еслиparentпринадлежит родителюContextИмя(Создано urfave/cli), то комбинированная операция выглядит следующим образом:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
response, err := grpcClient.Send(ctx, request)
ContextНе сложная и, на мой взгляд, одна из лучших возможностей Go.
дальнейшее чтение
Забытый параметр гонки
Я часто вижу ошибку, когда нет-raceПротестируйте свое приложение Go без аргументов.
так какэтот отчетКак уже говорилось, хотя Go «стремится сделать параллельное программирование проще и менее подверженным ошибкам», мы все еще сталкиваемся с множеством проблем с параллелизмом.
Очевидно, что детектор гонок Go не может решить все проблемы параллелизма. Тем не менее, он по-прежнему имеет большое значение, и мы всегда должны включать его при тестировании приложений.
дальнейшее чтение
Does the Go race detector catch all data race bugs?
более совершенная упаковка
Другая распространенная ошибка — передача имени файла в функцию.
Предположим, мы реализуем функцию для подсчета количества пустых строк в файле. Первоначальная реализация выглядела так:
func count(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
if scanner.Text() == "" {
count++
}
}
return count, nil
}
filenameКак задан параметр, то открываем файл и реализуем логику для чтения пустых строк, ну проблем нет.
Предположим, мы хотим реализовать модульные тесты поверх этой функции и протестировать обычные файлы, пустые файлы, файлы с разными типами кодировки и т. д. Код может легко стать очень сложным в обслуживании.
Кроме того, если мы хотимHTTP BodyРеализуя ту же логику, пришлось бы создать для этого другую функцию.
Go разработал два отличных интерфейса:io.Readerа такжеio.Writer(Примечание переводчика: общая командная строка ввода-вывода, файл, сеть и т. д.)
Таким образом, вы можете передать абстрактный источник данныхio.Reader, вместо передачи имени файла.
Если подумать, учитываются ли только файлы? тело HTTP? байтовый буфер?
Ответ не имеет значения, важно то,ReaderКакой тип данных считывается, мы все будем использовать одинаковыеReadметод.
В нашем случае можно даже буферизовать ввод, чтобы читать его построчно (используяbufio.Readerа такжеReadLineметод):
func count(reader *bufio.Reader) (int, error) {
count := 0
for {
line, _, err := reader.ReadLine()
if err != nil {
switch err {
default:
return 0, errors.Wrapf(err, "unable to read")
case io.EOF:
return count, nil
}
}
if len(line) == 0 {
count++
}
}
}
Логика открытия файла теперь передается вызывающей сторонеcountквадратный:
file, err := os.Open(filename)
if err != nil {
return errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
count, err := count(bufio.NewReader(file))
может вызываться независимо от источника данныхcount. И модульное тестирование также будет облегчено, поскольку можно создатьbufio.Reader, что значительно повышает эффективность.
count, err := count(bufio.NewReader(strings.NewReader("input")))
Горунтины и переменные цикла
Последняя распространенная ошибка, которую я видел, — это использование горутин и переменных цикла.
Что выведет следующий пример?
ints := []int{1, 2, 3}
for _, i := range ints {
go func() {
fmt.Printf("%v\n", i)
}()
}
Выход вне очереди1 2 3? неправильный ответ.
В этом примере каждая горутина использует один и тот же экземпляр переменной, поэтому, скорее всего, она выведет3 3 3.
Есть два решения этой проблемы.
Во-первых,iВ замыкание (внутреннюю функцию) передается значение переменной:
ints := []int{1, 2, 3}
for _, i := range ints {
go func(i int) {
fmt.Printf("%v\n", i)
}(i)
}
Второй находится вforСоздайте еще одну переменную в области цикла:
ints := []int{1, 2, 3}
for _, i := range ints {
i := i
go func() {
fmt.Printf("%v\n", i)
}()
}
i := iЭто может показаться немного странным, но это полностью работает.
Поскольку нахождение в цикле означает нахождение в другой области, поэтомуi := iЭквивалентно созданию другого именованногоiэкземпляр переменной.
Конечно, для удобства чтения лучше использовать разные имена переменных.
дальнейшее чтение
Using goroutines on loop iterator variables
Есть ли другие распространенные ошибки, о которых вы хотели бы упомянуть? Не стесняйтесь делиться и поддерживать обсуждение;)