Skip to content

100 Go Mistakes and How to Avoid Them - Teiva Harsanyi

Refs:

CHAPTER 1 Go: Simple to learn but hard to master

A person who never made a mistake never tried anything new.

  • Albert Einstein

Важно не количество совершенных ошибок, а наша способность учиться на них. Это утверждение относится и к программированию. Мастерство, которое мы приобретаем, — это не волшебство. Мы делаем множество ошибок и учимся на них.

1.1 Go outline

При оценке адекватности использования языка в масштабе какой-то организации используют несколько важных характеристик. К ним относятся:

  • Stability(Стабильность)
    Несмотря на то что в Go вносятся частые изменения (направленные на улучшение самого языка и устранение уязвимостей с точки зрения безопасности), он остается достаточно стабильным языком. Некоторые считают это качество одной из лучших особенностей языка.
  • Expressivity(Выразительность)
    Мы можем определить выразительность языка по тому, насколько написание и чтение кода отвечает представлениям о естественности и интуитивной понятности. Уменьшенное количество ключевых слов и ограниченные способы решения общих проблем делают Go выразительным языком для больших кодовых баз.
  • Compilation(Компиляция)
    Что может быть более раздражающим для разработчиков, чем долгое ожидание сборки для тестирования приложения? Стремление к быстрой компиляции всегда было сознательной целью разработчиков языка. А это основа высокой производительности.
  • Safety(Безопасность)
    Go — надежный язык со статической типизацией. Следовательно, у него есть строгие правила времени компиляции, которые в большинстве случаев обеспечивают безопасность типов.

1.3 100 Go mistakes

Mistakes can be classified as:

  • Bugs (баги)
  • Needless complexity (излишнюю сложность)
  • Weaker readability (плохую читаемость)
  • Suboptimal or unidiomatic organization (неоптимальную или неидиоматическую организацию)
  • Lack of API convenience (отсутствие удобства в API;)
  • Under-optimized code (неоптимизированный код)
  • Lack of productivity (недостаточную производительность)

Summary

  • Go — это современный язык программирования, который позволяет повысить производительность разработчиков, что сегодня крайне важно для большинства компаний.
  • Go прост в изучении, но нелегок в освоении. Поэтому важно углубить свои знания, чтобы использовать его наиболее эффективно.
  • Обучение на разборе ошибок и на конкретных примерах — это мощный способ овладеть языком.

CHAPTER 2 Code and project organization

2.1 #1: Unintended variable shadowing

Scope (Область видимости) переменной — это те места кода, в которых можно ссылаться на эту переменную, другими словами, та часть приложения, где действует привязка имени. В Go имя переменной, уже объявленное во внешней области видимости, может быть повторно объявлено во внутренней области видимости. Такая ситуация называется variable shadowing (затенением переменной) и может приводить к распространенным ошибкам.

2.2 #2: Unnecessary nested code

Выровняйте happy path (счастливый путь) по левому краю — так вы сможете быстро просмотреть, что происходит ниже на каком-то одном уровне и увидеть, что на нем ожидаемо выполняется.

  • Mat Ryer, эксперт, участвующий в дискуссии подкаста Go Time

Case 1

Когда происходит возврат из блока if, следует во всех случаях опускать блок else. Например, мы не должны писать:

if foo() {
    // ...
    return true
} else {
    // ...
}

Вместо этого следует опустить блок else, как показано здесь:

if foo() {
    // ...
    return true
}
// ...

Во второй версии этого фрагмента код, находившийся в блоке else, перемещается на верхний уровень, что упрощает его чтение.

Case 2

Можно следовать этой логике в случае с путем, не являющимся «счастливым»:

if s != "" {
    // ...
} else {
    return errors.New("empty string")
}

Здесь пустая переменная s определяет путь, не являющимся «счастливым». Поэтому нужно изменить это условие так:

if s == "" { Изменение условия в if
    return errors.New("empty string")
}
// ...

Написание читаемого кода — важная задача для каждого разработчика. Стремление уменьшить количество вложенных блоков, выравнивание счастливого пути по левому краю и возврат как можно раньше — это конкретные средства для улучшения читабельности кода.

2.3 #3: Misusing init functions

Иногда в приложениях Go неправильно используются функции инициализации. Потенциальные последствия — трудности в отслеживании и обработке ошибок или сложный в понимании код.

2.3.2 When to use init functions

Функции инициализации могут привести к некоторым проблемам:

  • Они могут ограничивать возможности по обработке ошибок.
  • Они могут усложнить реализацию тестов (например, понадобится устанавливать внешнюю зависимость, которая в рамках unit-тестов может и не потребоваться).
  • Если инициализация требует, чтобы мы определили какое-то состояние, то это нужно будет сделать через использование глобальных переменных.

Использовать функции инициализации нужно очень внимательно. Но они могут быть полезны в некоторых ситуациях, например при определении статической конфигурации. В противном случае, как и просто в большинстве случаев, инициализацию следует обрабатывать с помощью специальных функций.

2.4 #4: Overusing getters and setters

Инкапсуляция данных в программировании означает сокрытие значений или состояния объекта. Геттеры и сеттеры — это средства для включения инкапсуляции путем предоставления экспортированных методов поверх не экспортированных полей объектов.

В Go нет автоматической поддержки геттеров и сеттеров, как в других языках. Не считается обязательным или идиоматичным использование геттеров и сеттеров для доступа к полям структуры (struct).

С другой стороны, использование геттеров и сеттеров дает некоторые преимущества:

  • Они инкапсулируют поведение, связанное с получением данных какого-то поля или присвоением ему значения, что позволяет добавлять новые функции позднее (например, проверку поля, возврат вычисленного значения или обертывание доступа к полю вокруг mutex).
  • Они скрывают внутреннее представление, давая больше гибкости в определении того, что мы раскрываем.
  • Они дают точку перехвата при отладке, когда свойство изменяется во время исполнения, что упрощает отладку.

Если мы сталкиваемся с такими случаями или предвидим возможный вариант использования, гарантируя прямую совместимость, использование геттеров и сеттеров может принести некоторую пользу. Например, если мы используем их с полем Balance, мы должны следовать вот этим соглашениям о наименованиях:

  • Метод геттера должен называться Balance() (а не GetBalance()).
  • Метод сеттера должен называться SetBalance().

Не следует перегружать код геттерами и сеттерами в структурах, если они не приносят никакой пользы. Будьте прагматиками и ищите баланс между эффективностью и соблюдением идиом, которые в других парадигмах программирования иногда считаются непререкаемыми.

Помните, что Go — уникальный язык, созданный исходя из целей достижения многих характеристик, включая простоту. Но если возникнет потребность в геттерах и сеттерах или эта потребность предвидится в будущем, гарантируя при этом «совместимость вперед», в их использовании нет ничего плохого.

2.5 #5: Interface pollution

Interfaces (Интерфейсы) — это один из краеугольных камней языка Go при разработке и структурировании кода. Но, как и, со многими другими инструментами или концепциями, излишнее их использование становится недостатком. Interface pollution (Загрязнение интерфейса) — это перегруз кода ненужными абстракциями, затрудняющими понимание.

2.5.1 Concepts

Интерфейс предоставляет способ задать поведение объекта. Мы используем интерфейсы для создания общих абстракций, которые могут быть реализованы несколькими объектами. Интерфейсы в Go реализуются неявно. В языке нет явного ключевого слова (например, implements), которое бы показывало, что объект X реализует интерфейс Y.

При проектировании интерфейсов помните о степени детализации (то есть сколько методов содержится в интерфейсе). Известная среди Go-разработчиков присказка говорит, на-сколько большим должен быть интерфейс:

The bigger the interface, the weaker the abstraction.

  • Rob Pike

Добавление методов к интерфейсу может снизить возможности по его повторному использованию.

Everything should be made as simple as possible, but no simpler.

  • Albert Einstein

Поиск идеальной детализации интерфейса не обязательно должен быть простым процессом.

2.5.2 When to use interfaces

Когда следует создавать интерфейсы в Go? Рассмотрим три конкретных сценария, когда считается, что интерфейсы могут быть полезны. Обратите внимание, что цель состоит не в том, чтобы дать исчерпывающие рекомендации: чем больше примеров я бы добавил, тем в большей степени они зависели бы от контекста. Но эти три случая дают общее представление о вопросе:

  • Common behavior (Общее поведение)
  • Decoupling (Снижение связанности)
  • Restricting behavior (Ограничение поведения)

  • Common behavior
    Первый вариант, который мы обсудим, — это использование интерфейсов, когда несколько типов реализуют общее поведение. Тогда можно заключить это поведение внутрь какого-то интерфейса.

  • Decoupling
    Еще один важный сценарий — отделение кода от его реализации. Если мы полагаемся на абстракцию вместо конкретной реализации, сама реализация может быть заменена на другую без необходимости менять код. Это и есть Liskov Substitution Principle (принцип подстановки Лисков) в принципах SOLID Роберта Мартина.
  • Restricting behavior
    На первый взгляд может показаться контр-интуитивным. Речь идет об ограничении типа определенным поведением.

2.5.3 Interface pollution

Интерфейсы полезны для создания абстракций. И главное предостережение при знакомстве программиста с абстракциями — это помнить, что абстракции нужно открывать, а не создавать. Это означает, что мы не должны начинать создавать абстракции в коде, если для этого нет веской причины. Нужно не конструировать интерфейсы, а ждать возникновения конкретной потребности в них. Иными словами, создавайте интерфейс только тогда, когда он действительно нужен, а не тогда, когда возникает лишь ощущение, что он может понадобиться.

В чем основная проблема, связанная с чрезмерным использованием интерфейсов? Они делают поток кода менее ясным и более сложным. Добавление бесполезного косвенного уровня не приносит никакой пользы, а лишь создает бесполезную абстракцию, затрудняющую чтение, понимание и осмысление кода. Если нет веской причины для добавления интерфейса и неясно, как этот интерфейс делает код лучше, нужно поставить под сомнение цель создания такого интерфейса. Почему бы не вызвать реализацию какого-либо действия напрямую?

ПРИМЕЧАНИЕ: При вызове метода через интерфейс мы можем столкнуться с overhead производительности. Требуется поиск в структуре данных хеш-таблицы, чтобы найти конкретный тип, на который указывает интерфейс. Но это не проблема во многих контекстах, поскольку overhead минимален.

Следует быть очень осторожными при создании абстракций в коде: их следует обнаруживать, а не создавать. Для разработчиков характерно чрезмерно усложнять код в попытках угадать идеальный уровень абстракции. Этого следует избегать, поскольку в большинстве случаев в результате код «загрязняется» ненужными абстракциями и становится сложным для чтения.

Don’t design with interfaces, discover them.

  • Rob Pike

Не будем пытаться решить проблемы абстрактно, будем решать только то, что нужно сейчас. И последнее, но не менее важное: если вы не понимаете, как какой-то интерфейс улучшает код, то следует подумать о его удалении для упрощения кода.

2.6 #6: Interface on the producer side

  • Producer side(Сторона производителя) — интерфейс, определенный в том же пакете, что и конкретная реализация.
  • Consumer side (Сторона потребителя) — интерфейс, определенный во внешнем пакете, где он используется.

Интерфейсы в Go реализованы неявно, что обычно меняет правила игры по сравнению с языками с явной реализацией. В большинстве случаев подход, которому стоит следовать: абстракции следует открывать, а не создавать. Это означает, что производитель не должен навязывать определенную абстракцию всем потребителям. Вместо этого потребитель должен решить, нужна ли ему какая-либо форма абстракции, а затем определить наилучший уровень абстракции для своих нужд.

Суть состоит в том, что на consumer side теперь можно определить для своих нужд наиболее точную абстракцию. Это связано с концепцией Interface segregation principle (принципа разделения интерфейса) I в SOLID, которая гласит, что ни один потребитель не должен зависеть от неиспользуемых методов. И в этом случае лучший подход — разместить конкретную реализацию≠ на стороне производителя, дать к ней доступ и позволить потребителю решить, как ее использовать и нужна ли вообще здесь абстракция.

Не создавайте абстракцию, если вы просто думаете, что она может быть полезна в будущем, или не можете доказать, что она будет действительно нужна.

В большинстве случаев интерфейс должен жить на стороне потребителя. Но в определенных контекстах (например, когда мы твердо знаем, а не просто предвидим, что абстракция будет полезна для потребителей) можно сделать его на стороне производителя. В этом случае мы должны стремиться к тому, чтобы она была минимальной. Это увеличивало бы потенциал ее переиспользования и делало ее легко компонуемой.

2.7 #7: Returning interfaces

Возврат интерфейса, как правило, ограничивает гибкость, поскольку мы заставляем всех потребителей использовать один конкретный тип абстракции. В большинстве случаев можно черпать вдохновение из Postel’s law (Robustness principle, закона Постеля)

Be conservative in what you do, be liberal in what you accept from others.

  • Jon Postel

Если применить эту идиому к Go, то это будет означать:

  • возврат структур вместо интерфейсов;
  • допущение использования интерфейсов, если это возможно.

Конечно, есть и исключения. Разработчики знают, что правила никогда не выполняются на 100%. Самое важное из них касается типа error — интерфейса, возвращаемого многими функциями.

В большинстве случаев возвращать лучше не интерфейсы, а конкретные реализации. В противном случае дизайн будет усложненным из-за зависимостей пакетов, а гибкость — ограниченной, поскольку всем клиентам придется использовать одну и ту же абстракцию. Этот вывод аналогичен предыдущим разделам: если мы четко знаем (а не просто предполагаем), что абстракция будет полезна для потребителей, то можем подумать о возврате интерфейса. В противном случае мы не должны навязывать использование абстракций; необходимость их использования должна быть «обнаружена» клиентами. Если клиенту по какой-либо причине нужно абстрагировать реализацию, то он все равно сможет сделать это на клиентской стороне.

2.8 #8: any says nothing

В Go тип интерфейса, который определяет нулевые методы, известен как пустой интерфейс, interface{}. В Go 1.18 предварительно объявленный тип any стал чем-то вроде псевдонима для пустого интерфейса, поэтому во всех случаях interface{} может быть заменен на any. Во многих случаях any можно считать чрезмерным обобщением, и как считает Rob Pike, any не передает никаких смыслов.

Принятие или возврат типа any не передает значимой информации. Поскольку во время компиляции нет защиты, ничто не мешает вызывающей функции вызвать эти методы с любым типом данных.

Используя any, мы теряем некоторые преимущества Go как языка со статической типизацией. Следует избегать типа any и делать сигнатуры максимально явными.

any может быть полезен, если есть реальная необходимость принять или вернуть любой возможный тип, например, когда дело доходит до marshaling или форматирования. В общем, мы должны любой ценой избегать чрезмерного обобщения своего кода. Возможно, иногда небольшое дублирование кода будет приветствоваться, если это улучшает другие аспекты, например выразительность.

2.9 #9: Being confused about when to use generics

В Go 1.18 в язык добавлены generics (дженерики). Это позволяет писать код с типами, которые можно указать позже и создавать при необходимости. При этом может возникнуть путаница, когда использовать дженерики.

2.9.1 Concepts

Type parameters (Параметры типа) — это generics types (общие типы), которые можно использовать с функциями и типами. Например, аргументом следующей функции является параметр типа:

func foo[T any](t T) { // T — это параметр типа
    // ...
}

При вызове foo() мы передаем туда аргумент типа any. Передача аргумента типа называется instantiation (инстанцированием), и эта работа выполняется во время компиляции. Это позволяет сохранить безопасность типов как часть основных возможностей языка и избежать overhead во время выполнения.

Ограничение аргументов типа для соответствия определенным требованиям называется constraint (ограничение). Constraint — это тип интерфейса, который может содержать:

  • набор поведения (методов)
  • произвольные типы

Мы также можем использовать дженерики со структурами данных. Например, можно создать связанный список, содержащий значения любого типа.

Параметры типа не могут использоваться с аргументами метода, а только с аргументами функции или получателями методов. Если мы хотим использовать дженерики с методами, получатель должен быть параметром типа.

2.9.2 Common uses and misuses

Когда полезно использовать дженерики? Обсудим несколько распространенных случаев, когда это рекомендуется:

  • Структуры данных
    Мы можем использовать дженерики, чтобы выделить тип элемента, например, если реализуем двоичное дерево, связанный список или кучу.

  • Функции
    Функции работающие со срезами, картами и каналами любого типа. Например, функция объединения двух каналов будет работать с любым типом канала. Следовательно, можно использовать параметры типа, чтобы определить тип канала:

    func merge[T any](ch1, ch2 <-chan T) <-chan T {
        // ...
    }
    
  • Факторизация поведения вместо типов
    Пакет sort, например, содержит интерфейс sort.Interface, включающий в себя три метода:

    type Interface interface {
        Len() int
        Less(i, j int) bool
        Swap(i, j int)
    }
    

    Этот интерфейс используется различными функциями: sort.Ints или sort.Float64s. Используя параметры типа, можно выделить действие по сортировке. Например, определив структуру, содержащую срез, и функцию сравнения:

    type SliceFn[T any] struct { // Используется параметр типа
      S []T
      Compare func(T, T) bool // Сравниваются два элемента T
    }
    
    func (s SliceFn[T]) Len() int {
        return len(s.S)
    }
    
    func (s SliceFn[T]) Less(i, j int) bool {
        return s.Compare(s.S[i], s.S[j])
    }
    
    func (s SliceFn[T]) Swap(i, j int) {
        s.S[i], s.S[j] = s.S[j], s.S[i]
    }
    

    Поскольку структура SliceFn реализует sort.Interface, можно отсортировать предоставленный срез с помощью функции sort.Sort(sort.Interface):

    s := SliceFn[int] {
        S: []int{3, 2, 1},
        Compare: func(a, b int) bool {
            return a < b
        },
    }
    sort.Sort(s)
    fmt.Println(s.S)
    

    В результате:

    [1 2 3]
    

А когда использовать дженерики не рекомендуется?

  • При вызове метода с аргументом типа
    Рассмотрим функцию, принимающую на входе io.Writer и вызывает метод Write:

    func foo[T io.Writer](w T) {
        b := getBytes()
        _, _ = w.Write(b)
    }
    

    В этом случае использование дженериков не принесет коду никакой пользы. Нужно напрямую сделать значение аргумента w равным io.Writer.

  • Когда это делает код более сложным
    Дженерики никогда не бывают обязательными, и разработчики Go прекрасно жили без них более десяти лет. Если мы используем дженерики — универсальные функции или структуры — и обнаруживаем, что это не делает код более понятным, то следует пересмотреть свое решение для конкретного случая.

Хотя дженерики и могут быть полезны, будьте осторожны при их использовании. Принцип здесь такой же, как и при использовании интерфейсов. Дженерики вводят некоторую форму абстракции, а нам нужно помнить, что ненужные абстракции только усложняют работу.

Не будем загрязнять код ненужными абстракциями и сосредоточимся на решении конкретных задач. Это означает, что использовать параметры типа не нужно, если на то нет оснований. Подождите, когда надо будет писать шаблонный код, и только тогда рассмотрите возможность использования дженериков.

2.10 #10: Not being aware of the possible problems with type embedding

При создании структуры Go позволяет embed types (встраивать типы). Но иногда это может привести к неожиданному поведению, если мы не понимаем всех последствий такого встраивания.

В Go поле структуры называется embedded (встроенным), если оно объявлено без имени. Например,

type Foo struct {
    Bar // Встроенное поле
}

type Bar struct {
    Baz int
}

В структуре Foo тип Bar объявлен без связанного имени, следовательно, это встроенное поле.

Мы используем встраивание для продвижения (promote) полей и методов встроенного типа. Поскольку Bar содержит поле Baz, это поле продвигается в Foo. Таким образом, Baz становится доступным из Foo:

foo := Foo{}
foo.Baz = 42

Доступ к Baz возможен по двум разным путям:

  • по продвигаемому через Foo.Baz,
  • по номинальному через Bar, Foo.Bar.Baz.

Оба относятся к одному и тому же полю.


Встраивание и интерфейсы

Встраивание также используется внутри интерфейсов для объединения интерфейса с другими. В следующем примере io.ReadWriter состоит из io.Reader и io.Writer:

type ReadWriter interface {
    Reader
    Writer
}

То, что описано в этом разделе, относится только ко встроенным полям в структурах.


Встраивание и создание подклассов в ООП

Отличие встраивания от создания подклассов в ООП иногда может сбивать с толку. Основное отличие между ними связано с идентификацией получателя метода.

При встраивании встроенный тип остается получателем метода. И наоборот, при создании подкласса подкласс становится получателем метода.

Встраивание связано со структурированием, а не с наследованием.


Какой следует сделать вывод о встраивании типов? Прежде всего встраивание редко бывает по-настоящему нужно, а это значит, что независимо от сути конкретной задачи, скорее всего, можно решить эту задачу и без применения встраивания типов. В основном оно используется для удобства: для продвижения поведения.

Если же мы все-таки решаем использовать встраивание типов, то нужно помнить о двух основных ограничениях:

  • Не следует его использовать исключительно как синтаксический сахар — для упрощения доступа к полю (например, Foo.Baz() вместо Foo.Bar.Baz()). Если это единственная причина, то вместо встраивания внутреннего типа лучше использовать поле.
  • Оно не должно продвигать данные (поля) или поведение (методы), которые хочется скрыть от посторонних глаз. Например, если продвижение позволяет клиентам получить доступ к поведению блокировки, которое должно оставаться приватным для структуры.

ПРИМЕЧАНИЕ: Кто-то может возразить, что использование встраивания типов приводит к дополнительным усилиям в сопровождении в контексте экспортируемых структур. И правда, встраивание типа внутрь экспортируемой структуры означает некоторую осторожность по мере использования типа. Например, если мы добавляем во внутренний тип новый метод, то важно убедиться, что он не нарушает ограничения этого типа. Чтобы избежать дополнительных усилий, имеет смысл запретить встраивание типов в публичные структуры.

Осознанное использование встраивания типов с учетом этих ограничений поможет избежать шаблонного кода с дополнительными методами перенаправления. Но важно понимать, что мы не делаем это исключительно в «косметических» целях и не продвигаем элементы, которые должны оставаться скрытыми.

2.11 #11: Not using the functional options pattern

При разработке API может возникнуть вопрос: как быть с опциональными конфигурациями? Эффективное решение этой проблемы может улучшить удобство нашего API.

2.11.1 Config struct

Поскольку Go не поддерживает необязательные параметры в сигнатурах функций, первый возможный подход — использовать структуру конфигурации для передачи того, что обязательно, а что — опционально. Например, обязательные параметры могут задаваться как параметры функции, тогда как опциональные могут обрабатываться в структуре.

Такое решение устраняет проблему совместимости. Если мы добавим новые опции, они не будут ломаться на стороне клиента. Но этот подход не решает задачу по управлению портами в соответствии с требованиями. Следует иметь в виду, что если поле структуры не задано, его нужно инициализировать нулевым значением:

  • 0 для целочисленного параметра;
  • 0.0 для типа с плавающей точкой;
  • "" для строки;
  • nil — для срезов, карт, каналов, указателей, интерфейсов и функций.

С помощью целочисленного указателя мы семантически сможем выделить разницу между значением 0 и отсутствующим значением (нулевым указателем — nil).

Это рабочий вариант, но у него есть несколько недостатков. Прежде всего клиентам может быть неудобно задавать целочисленный указатель. Им придется создать переменную, а затем передавать указатель.

Вторым недостатком является то, что клиент, использующий нашу библиотеку с конфигурацией по умолчанию, должен будет передавать пустую структуру.

2.11.2 Builder pattern

Первоначально входивший в состав паттернов проектирования Gang of Four («Банды четырех»), builder (Строитель) обеспечивает гибкое решение различных проблем создания объектов.

2.11.3 Functional options pattern

Functional options pattern (паттерн функциональных опций) имеет разные реализации, отличающиеся друг от друга небольшими вариациями, но основная идея в следующем:

  • Неэкспортированная структура содержит конфигурацию: options.
  • Каждая из ее опций представляет собой функцию, которая возвращает ошибку одного и того же типа: type Option func(options *options)

Closure (Замыкание) — это анонимная функция, которая ссылается на переменные вне своего тела.

Functional options pattern (паттерн функциональных опций) предоставляет удобный и дружественный к API способ обработки опций. Хотя Builder (Строитель) может быть допустимым вариантом, у него есть некоторые незначительные недостатки, которые делают паттерн функциональных опций идиоматическим способом решения этой проблемы в Go. Этот паттерн используется в разных библиотеках Go, например gRPC.

2.12 #12: Project misorganization

Организация проекта на Go — непростая задача. Поскольку Go предоставляет большую свободу при создании пакетов и модулей, не так просто выделить действительно лучшие практики.

2.12.1 Project structure

В языке Go нет строгого соглашения о структурировании проекта. Но с годами появился один макет проекта.

Если проект достаточно мал (всего несколько файлов) или если компания уже создала свой стандарт, возможно, этот макет проекта не стоит использовать или переходить на него. В других случаях советуем рассмотреть такой вариант.

Основные каталоги в проекте:

  • /cmd — основные исходные файлы. Файл main.go приложения foo должен находиться в /cmd/foo/main.go.
  • /internal — закрытый (private) код: мы не хотим, чтобы другие импортировали его для своих приложений или библиотек.
  • /pkg — общедоступный (public) код, который мы хотим предоставлять другим в пользование.
  • /test — дополнительные внешние тесты и тестовые данные. Unit-тесты в Go находятся в том же пакете, что и исходные файлы. Но, например, общедоступные тесты API или интеграционные тесты должны находиться в /test.
  • /configs — файлы конфигурации.
  • /docs — проектные и пользовательские документы.
  • /examples — примеры для нашего приложения и/или общедоступной библиотеки.
  • /api — файлы контрактов API (Swagger, Protocol Buffers и т. д.).
  • /web — ресурсы, относящиеся к веб-приложению (статические файлы и т. д.).
  • /build — файлы упаковки и непрерывной интеграции (CI).
  • /scripts — скрипты для анализа, установки и т. д.
  • /vendor — зависимости приложений (например, зависимости модулей Go).

В этом списке нет каталога /src, как в некоторых других языках. Причина в том, что /src слишком общий, поэтому в этом макете отдается предпочтение /cmd, /internal или /pkg.

ПРИМЕЧАНИЕ: В 2021 году Russ Cox, один из основных maintainer Go, раскритиковал этот макет. В основном проект существует в рамках организации GitHub golang-standards, хоть и не является официальным стандартом. Имейте в виду, что в отношении структуры проекта нет обязательных соглашений. Этот макет может быть полезен вам или нет, но нерешительность — единственное неправильное решение. Поэтому согласуйте макет для поддержания единообразия в своей компании, чтобы разработчики не тратили время на переход от одного репозитория к другому.

2.12.2 Package organization

В Go нет концепции «подпакетов», но можно организовывать пакеты в поддиректории. Основное преимущество поддиректорий состоит в том, что пакеты хранятся там, где у них максимальная связность (cohesion) с другими элементами.

Что касается общей организации, то на этот счет есть разные мнения. Например, нужно ли организовать приложение, отталкиваясь от контекста или от уровней? Это зависит от ваших предпочтений. Можно использовать группировку кода по контексту (например, контекст потребителя, контекст контракта и т. д.) или следовать принципам гексагональной архитектуры и группировке по техническому уровню. Если решение соответствует варианту использования, оно не может быть неправильным, пока мы действуем последовательно.

В отношении пакетов есть несколько лучших практик, которым будет полезно следовать. Так, рекомендуется избегать преждевременной упаковки, поскольку это может привести к чрезмерному усложнению проекта. Иногда лучше использовать простую организацию и развивать проект, сохраняя четкое понимание того, что в нем содержится, чем заставлять себя создавать идеальную структуру с самого начала.

Детализация (Granularity) — еще одна важная вещь, которую следует учитывать.

  • Избегайте десятков «нанопакетов», содержащих только один или два файла. Если же подобное происходит, то, вероятно, потому, что пропущены некоторые логические связи между этими пакетами. Это затрудняет понимание проекта теми, кто будет читать код.
  • Избегайте огромных пакетов, за содержимым которых теряется смысл того, почему этот пакет назван именно так.
  • К выбору названий пакетов подходите с осторожностью. Придумывать имена сложно. Чтобы помочь клиентам понять проект Go, называйте пакеты, отталкиваясь от их возможностей, а не от их содержимого.
  • Название должно быть осмысленным.
  • Имя пакета должно быть коротким, лаконичным, выразительным, а еще состоять из одного слова в нижнем регистре.

Что касается вопроса о том, что следует экспортировать, то тут правило простое. Сводите к минимуму то, что должно быть экспортировано, чтобы уменьшить связанность (coupling) между пакетами и скрывать ненужные экспортируемые элементы. Если нет уверенности, надо ли экспортировать какой-то элемент или нет, по умолчанию его не нужно экспортировать. Позже, если обнаружится, что экспортировать его все же нужно, можно изменить код. Помните и о некоторых исключениях, например о создании экспортируемых полей, чтобы к структуре можно было применить marshaling с помощью encoding/json.

Организовывать проект непросто, но соблюдение приведенных правил упростит его поддержку. Помните, что согласованность также жизненно важна для облегчения сопровождения. Так что важно удостовериться, что в кодовой базе все консистентно.

2.13 #13: Creating utility packages

ПРИМЕЧАНИЕ: Создание в приложении десятков нанопакетов может усложнить отслеживание того, что стоит за выполнением кода. Но сама идея использования нанопакетов не всегда плохая. Если небольшая группа кода имеет высокую внутреннюю связность и не относится к чему-либо еще, приемлемо выделить ее в отдельный пакет. Строгого правила для этого нет, и задача часто состоит в том, чтобы найти баланс.

Мы достаточно часто находим служебные пакеты, которые управляют стандартными возможностями. Например, если мы решим иметь клиентский и серверный пакеты, куда нужно поместить общие типы? В этом случае одним из возможных решений будет объединение клиента, сервера и общего кода в единый пакет

  • Dave Cheney (project member for the Go programming language)

Именование пакетов — важная часть общей конструкции приложения, к этому следует подходить с вниманием и осторожностью. Как правило, создание общих пакетов без осмысленных имен — плохая практика, к ней можно отнести служебные пакеты с именами вроде utils, common или base. Именуйте пакеты, отталкиваясь от того, какие действия он производит, а не от того, что в нем содержится. Это будет эффективным способом повысить его выразительность.

2.14 #14: Ignoring package name collisions

Коллизии имен пакетов возникают, когда переменная имеет такое же имя, как и у существующего пакета, что мешает его переиспользованию.

Предположим, что какой-то квалификатор ссылается как на переменную, так и на имя пакета внутри всей функции. В этом случае тот, кто читает код, может и не понять, на что он ссылается. Как избежать такой коллизии? Первый вариант — использовать другое имя переменной. Это, пожалуй, самый простой способ. Но если по какой-то причине нужно оставить имя нашей переменной, можно поиграть с импортом пакетов. Применяя импорт пакетов, можно использовать псевдоним, чтобы изменить квалификатор для ссылки на пакет.

ПРИМЕЧАНИЕ: Один из вариантов — использование точечного импорта для доступа ко всем общедоступным элементам пакета без ссылки на квалификатор пакета. Но такой подход приводит к общему возрастанию путаницы, и в большинстве случаев его следует избегать.

Следует избегать коллизий имен переменных и встроенных функций. Нужно стремиться предотвращать коллизии имен переменных, чтобы избегать двусмысленности. Если все же по какой-то причине коллизия происходит, найдите другое имя, которое будет нести нужный смысл, либо используйте псевдоним импорта.

2.15 #15: Missing code documentation

Создание соответствующей документации — важная часть написания кода. Она упрощает клиентам использование API, а также помогает в поддержке и сопровождении проекта. Некоторые правила Go помогут сделать код идиоматичным.

Прежде всего, каждый экспортируемый элемент должен быть задокументирован. Будь то структура, интерфейс, функция или что-то еще, если элемент экспортируется, он должен быть задокументирован. Принято добавлять комментарии, начиная с имени экспортируемого элемента. Например:

// Customer — это представление потребителя.
type Customer struct{}
// ID возвращает идентификатор потребителя.
func (c Customer) ID() string { return "" }

По правилам, каждый комментарий должен быть полным предложением, заканчивающимся точкой. Также имейте в виду, что когда мы описываем функцию (или метод), то должны указывать то, что функция должна делать, а не то, как она это делает. Это относится и к ядру функции, и к комментариям, но не к документации. В идеале документация должна содержать достаточно информации, чтобы клиенту не нужно было каждый раз изучать код, чтобы понять, как использовать экспортируемый элемент.

Экспортированный элемент можно объявить устаревшим с помощью комментария // Deprecated:

// ComputePath возвращает быстрейший путь между двумя точками.
// Deprecated: Эта функция использует устаревший способ расчета быстрейшего
// пути. Вместо нее используйте ComputeFastestPath.
func ComputePath() {}

Тогда, если разработчик использует функцию ComputePath, он должен получить соответствующее предупреждение. Большинство IDE обрабатывают комментарии // Deprecated:.

Когда дело доходит до документирования переменной или константы, нас может заинтересовать передача связанных с ними двух аспектов: назначения и содержания. Первый должен быть отражен в документации, что будет полезно для внешних клиентов. Последний не обязательно делать публичным. Например:

// DefaultPermission разрешение по умолчанию, используемое движком магазина.
const DefaultPermission = 0o644 // Необходим доступ на чтение и запись.

Эта константа представляет собой разрешение по умолчанию. Документация передает ее назначение, тогда как комментарий рядом с константой описывает ее фактическое содержание (доступы для чтения и записи). Чтобы помочь клиентам и мейнтейнерам понять объем пакета, нужно документировать каждый пакет. По соглашению комментарий начинается с // Package, за которым следует имя пакета:

// Пакет math предоставляет основные константы и математические функции.
//
// Этот пакет не гарантирует битовую идентичность результатов
// в разных архитектурах.
package math

Первая строка комментария к пакету должна быть краткой, поскольку она появится в пакете. В последующих строках можно изложить остальную информацию.

Документирование пакета можно выполнить в любом из файлов Go, здесь нет никаких правил. Обычно документацию по пакету помещают в соответствующий файл с тем же именем, что и сам пакет, или в какой-то определенный файл, например doc.go. И последнее, что следует сказать о документации пакетов: комментарии, не примыкающие к объявлению, опускаются. Например, следующий комментарий об авторских правах не будет виден в создаваемой документации:

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Пакет math предоставляет основные константы и математические функции.
//
// Этот пакет не гарантирует битовую идентичность результатов
// в разных архитектурах.
package math

Каждый экспортируемый элемент должен быть задокументирован. Документирование кода не должно быть чем-то ограничено. Пользуйтесь всеми возможностями, чтобы убедиться, что оно поможет клиентам и мейнтейнерам понять назначение кода.

2.16 #16: Not using linters

Linter (Линтер) — это автоматический инструмент для анализа кода и отлова ошибок в нем. В задачи этого раздела не входит предоставление исчерпывающего списка существующих линтеров, поскольку такой список очень быстро устареет. Но нужно понимать и помнить, почему линтеры важны для большинства проектов на Go.

Еще раз подчеркиваем, что цель этого раздела — не перечисление всех доступных линтеров. Но вот список, с которым точно можно ежедневно сверяться:

Помимо линтеров, используйте code formatters (форматировщики кода) для исправления стиля написанного кода. Вот некоторые инструменты, которые стоит попробовать:

  • gofmt — стандартный форматировщик кода Go.
  • golang.org/x/tools/cmd/goimports — стандартный модуль форматирования импорта Go.

Взгляните и на golangci-lint. Это инструмент для анализа кода, который обеспечивает видимость поверх многих полезных линтеров и форматировщиков. Он позволяет запускать линтеры параллельно для повышения скорости анализа, что весьма удобно.

Линтеры и форматировщики — это мощные способы улучшить quality and consistency of codebase (качество и согласованность кода). Уделите время тому, чтобы понять, какие из них следует использовать, и убедитесь, что автоматизировали их выполнение (например, с помощью CI или с Git pre-commit hook).

Summary

  • Избегайте затенения переменных во избежание ссылок на неправильную переменную или запутывания читателей кода.
  • Избегайте использования вложенных уровней и выравнивайте «счастливый путь» по левому краю — это упрощает построение ментальной модели кода.
  • При инициализации переменных помните, что функции инициализации содержат в себе ограниченные возможности по обработке ошибок, что усложняет обработку состояний и тестирование. В большинстве случаев инициализации следует обрабатывать как специальные функции.
  • Принудительное использование геттеров и сеттеров не является в Go идиоматическим. Правильный подход заключается в том, чтобы быть прагматичным и находить должный баланс между эффективностью и следованием определенным идиомам.
  • Абстракции следует «открывать», а не создавать. Для предотвращения излишней сложности создавайте интерфейс только тогда, когда он действительно нужен, а не тогда, когда вы лишь предполагаете, что он может понадобиться в будущем, либо если можете доказать, что абстракция допустима.
  • Размещение интерфейсов на стороне потребителя позволяет избежать излишних абстракций.
  • Чтобы избавиться от ограничений с точки зрения гибкости, в большинстве случаев функции должны возвращать не интерфейсы, а конкретные реализации. И наоборот, функции должны принимать интерфейсы всегда, когда это возможно.
  • Используйте any только в том случае, если нужно принять или вернуть любой возможный тип, например json.Marshal. В противном случае any не несет значимой информации и может привести к проблемам при компиляции, позволяя вызывающей функции обращаться к методам с любым типом данных.
  • Полагаясь на дженерики и параметры типа, можно избежать написания шаблонного кода для разделения элементов или поведения. Используйте параметры типа лишь тогда, когда видите конкретную необходимость в них. В противном случае они вводят ненужные абстракции и усложняют код.
  • Использование встраивания типов также поможет избежать шаблонного кода. Но убедитесь, что это не приведет к проблемам с видимостью в тех случаях, когда некоторые поля должны оставаться скрытыми.
  • Для подходящей обработки параметров в удобной для API манере используйте паттерн функциональных опций.
  • Следование макету проекта может стать хорошим способом структурировать проект, особенно если в новом проекте вы стремитесь к соблюдению имеющихся соглашений для стандартизации.
  • Именование — важнейшая часть проектирования приложений. Создание пакетов с именами common, util или shared не имеет ценности для читателя кода. Преобразуйте имена таких пакетов во что-то более осмысленное и конкретное.
  • Чтобы избежать коллизий имен переменных и пакетов, приводящих к путанице или ошибкам, используйте уникальные имена для каждого из них. Если это невозможно, применяйте псевдоним импорта, изменяя квалификатор так, чтобы отличать имя пакета от имени переменной, или придумайте лучшие имена.
  • Чтобы клиенты и мейнтейнеры проекта лучше понимали назначение кода, документируйте экспортированные элементы.
  • Чтобы улучшить качество и внутреннюю согласованность кода, используйте линтеры и средства форматирования.

CHAPTER 5 Strings

В Go строка — это неизменяемая структура данных, содержащая:

  • указатель на неизменяемую последовательность байтов;
  • общее количество байтов в этой последовательности.

5.1 #36: Not understanding the concept of a rune

Важно понимать разницу между charset (кодировкой символов) и encoding (кодированием):

  • Charset (кодировка символов) — это просто набор символов. Например, кодировка Unicode содержит 2^21 символ.
  • Encoding (кодирование) — это перевод списка символов в двоичный код. Например, UTF-8 — это стандарт кодирования, определяющий способ того, как возможно закодировать все символы Unicode в переменном количестве байтов (от 1 до 4 байт).

Мы упомянули слово «символы», чтобы упростить определение кодировки. Но в Unicode мы используем концепцию code point (кодовой точки) для ссылки на элемент, представленный одним значением. Например, символ определяется кодовой точкой U+6C49. Используя UTF-8, кодируется тремя байтами: 0xE6, 0xB1 и 0x89. Почему это важно? Потому что в Go руна — это кодовая точка Unicode. Мы сказали, что UTF-8 кодирует символы в количестве байтов от 1 до 4 байт, следовательно, до 32 бит. Вот почему в Go руна — это псевдоним типа int32:

type rune = int32

Еще одна вещь, важная для UTF-8: некоторые считают, что строки Go всегда имеют кодировку UTF-8, но это не так.

NOTE: golang.org/x — репозиторий, предоставляющий расширения стандартной библиотеки, которая содержит пакеты для работы с UTF-16 и UTF-32.

Выводы:

  • Кодировка символов — это набор символов. Кодирование же описывает, как кодировка преобразовывается в двоичный код.
  • В Go строка ссылается на неизменяемый срез произвольных байтов.
  • Исходный код Go использует UTF-8. Все строковые литералы — строки UTF-8. Но поскольку строка может содержать какие угодно произвольные байты, если получена откуда-то еще (а не из исходного кода), то нет гарантии, что она будет основана на кодировке UTF-8.
  • Руна соответствует понятию кодовой точки Unicode, означающей элемент, представленный одним значением.
  • При использовании UTF-8 кодовая точка Unicode может быть закодирована с помощью одного, двух, трех или четырех байтов.
  • Применение функции len к строке возвращает количество байтов, а не количество рун.

5.2 #37: Inaccurate string iteration


Подсчет количества рун в строке

А что, если мы хотим получить количество рун в строке, а не количество байтов? То, как мы сможем это сделать, будет зависеть от кодировки. Для строковых литерал можно использовать пакет unicode/utf8:

fmt.Println(utf8.RuneCountInString("hêllo")) // 5

  • Если требуется выполнить итерацию по рунам строки, можно использовать цикл range напрямую по этой строке. Но следует помнить, что индекс соответствует не индексу руны, а начальному индексу последовательности байтов руны.

    for i := range s { fmt.Printf("position %d: %c\n", i, s[i]) }

  • Если мы хотим получить доступ к самой руне, нужно использовать значение элемента оператора range, а не индекс в строке, потому что руна может состоять из нескольких байтов.

    for i, r := range s { fmt.Printf("position %d: %c\n", i, r) }

  • Если нужно получить i-ю руну строки, то в большинстве случаев следует преобразовывать строку в срез рун. Это решение приводит к накладным расходам на время выполнения по сравнению с предыдущим. Действительно, преобразование строки в срез рун требует выделения места в памяти для дополнительного среза и преобразования байтов в руны: временная сложность O(n), где n — количество байтов в строке.

    runes := []rune(s) for i, r := range runes { fmt.Printf("position %d: %c\n", i, r) }


Возможная оптимизация доступа к определенной руне

Если строка состоит из однобайтовых рун, то возможен один метод оптимизации: например, когда строка содержит буквы от A до Z и от a до z. Мы можем получить доступ к i-й руне без преобразования всей строки в срез рун, обратившись к байту напрямую с помощью s[i]:

s := "hello"
fmt.Printf("%c\n", rune(s[4])) // o

5.3 #38: Misusing trim functions

Мы должны убедиться, что понимаем разницу между TrimRight/TrimLeft и TrimSuffix/TrimPrefix:

  • TrimRight/TrimLeft удаляет замыкающие/ведущие руны в наборе.
  • TrimSuffix/TrimPrefix удаляет указанный суффикс/префикс.

5.4 #39: Under-optimized string concatenation

strings.Builder — рекомендуемое решение для конкатенации списка строк. Обычно это решение следует использовать в циклах. Если просто нужно объединить несколько строк (например, имя и фамилию), использование strings.Builder не рекомендуется, так как это сделает код менее читаемым, чем использование оператора += или fmt.Sprintf.

С точки зрения производительности решение с использованием strings.Builder будет быстрее с того момента, когда нужно будет объединять более пяти строк. Несмотря на то что точное число зависит от многих факторов (например, от размера объединенных строк и от конкретного процессора), это может быть эмпирическим правилом, которое поможет понять, когда предпочесть одно решение другому. Также не стоит забывать, что если количество байтов будущей строки заранее известно, то следует использовать метод Grow для предварительного выделения места под внутренний байтовый срез.

5.5 #40: Useless string conversions

Большая часть операций ввода/вывода выполняется с помощью []byte, а не строк. Когда мы задаемся вопросом, с чем работать — со строками или с []byte, вспомним, что работа с []byte не обязательно менее удобна. Все экспортируемые функции пакета strings также имеют альтернативы в пакете bytes: Split, Count, Contains, Index и т. д. Независимо от того, выполняем ли мы ввод/вывод или нет, сначала нужно проверить, можно ли реализовать весь процесс, используя байты вместо строк, и избежать затрат на дополнительные преобразования.

5.6 #41: Substrings and memory leaks

При совершении операции с подстрокой в Go помните о двух вещах.

  • Во-первых, задаваемый интервал основан на числе байтов, а не рун.
  • Во-вторых, операция с подстрокой может привести к утечке памяти, поскольку результирующая подстрока будет использовать тот же резервный массив, что и исходная строка. Чтобы этого не произошло, можно выполнить копирование строки вручную или использовать strings.Clone из Go 1.18.

ПРИМЕЧАНИЕ: Поскольку строка, как правило, является указателем, вызов функции для передачи строки не приводит к глубокому копированию байтов. Скопированная строка по-прежнему будет ссылаться на тот же резервный массив.

Summary

  • Для правильной работы со строками в Go важно понимать, что руна соответствует концепции кодовой точки Unicode и может состоять из нескольких байтов.
  • Итерация строки с помощью range выполняет итерацию по рунам с индексом, соответствующим начальному индексу последовательности байтов руны.
  • Чтобы получить доступ к определенному индексу рун (например, к третьей руне), преобразуйте строку в []rune.
  • strings.TrimRight/strings.TrimLeft удаляет все последующие/ведущие руны, содержащиеся в заданном множестве, тогда как strings.TrimSuffix/ strings.TrimPrefix возвращает строку без указанного суффикса/префикса.
  • Конкатенация списка строк должна выполняться с помощью strings.Builder, чтобы предотвратить резервирование места в памяти для новой строки во время каждой итерации.
  • Помните, что пакет bytes позволяет совершать те же операции, что и пакет strings, это поможет избежать лишних преобразований байт/строка.
  • Использование копий вместо подстрок может предотвратить утечку памяти, поскольку строка, возвращаемая операцией над подстрокой, будет поддерживаться тем же самым массивом байтов.

CHAPTER 7 Error management

Error management (Обработка ошибок) — это фундаментальный аспект создания robust (надежных) и observable (наблюдаемых) приложений, и этот аспект должен быть столь же важным, как и любая другая часть кода. В Go, в отличие от большинства языков программирования, обработка ошибок не основывается на традиционном механизме try/catch. В Go ошибки обрабатываются с помощью возвращения значения ошибки вместе с другими значениями из функции.

7.1 #48: Panicking

Начинающие Go-разработчики часто путаются в обработке ошибок. В Go ошибки обычно обрабатываются функциями или методами, которые в качестве своего последнего параметра возвращают тип error. Но некоторым разработчикам такой подход может показаться неожиданным, и у них возникнет соблазн обработать ошибки с помощью panic и recover — так же, как это делается в Java или Python.

Освежим представления о концепции паники и обсудим, когда паниковать считается уместным.

В Go panic — это встроенная функция, которая останавливает обычный поток:

func main() {
    fmt.Println("a")
    panic("foo")
    fmt.Println("b")
}

Этот код выводит a, а затем останавливается перед выводом b:

a
panic: foo

goroutine 1 [running]:
main.main()
        main.go:7 +0xb3

После запуска паники она продолжается вверх по стеку вызовов до тех пор, пока либо не произойдет возврат из текущей горутины, либо panic не будет перехвачен с помощью recovery:

func main() {
    defer func() { // Вызовы восстанавливаются внутри отложенного замыкания
        if r := recover(); r != nil {
        fmt.Println("recover", r)
    }
    }()
    f() //Вызов f, которая запускает панику. Паника отлавливается предыдущим восстановлением
}

func f() {
    fmt.Println("a")
    panic("foo")
    fmt.Println("b")
}

Когда вызывается panic, текущее выполнение функции f останавливается и функция поднимается по стеку вызовов: в main.

Поскольку паника перехватывается с помощью recover, в main она не останавливает выполнение горутины:

a
recover foo

Вызов функции recovery() для перехвата паники горутины полезен только внутри функции defer; в противном случае функция просто вернет nil и более ни на что не будет влиять. Это связано с тем, что функции defer также выполняются, когда окружающая функция вызывает панику.

Теперь подумаем, а когда уместно вызывать панику? В Go panic используется для обозначения по-настоящему исключительных ситуаций, таких как ошибка программиста.

Панику в Go следует использовать с осторожностью. Мы рассмотрели два важных случая:

  • в одном из них должен возникнуть сигнал об ошибке программиста,
  • в другом приложение не может создать обязательную зависимость, то есть когда налицо появление неких исключительных ситуаций, которые должны заставить нас остановить приложение.

В большинстве других случаев управление ошибками должно выполняться с помощью функции, которая возвращает правильный тип error в качестве последнего возвращаемого аргумента.

7.2 #49: Ignoring when to wrap an error

Начиная с Go 1.13, директива %w позволяет удобно оборачивать ошибки. Но не всегда понятно, когда это нужно делать, а когда нет.

Error wrapping (Оборачивание) — это упаковка ошибки внутри контейнера-обертки, который делает доступной и исходную ошибку. Есть два основных сценария использования оборачивания ошибок:

  • добавление дополнительного контекста к ошибке;
  • маркировка ошибки как специфической.

Обратите внимание, что иногда есть смысл использовать оба подхода: и добавлять контекст, и маркировать ошибку.

До версии Go 1.13 для оборачивания ошибки единственным вариантом, без применения внешней библиотеки, было создание пользовательского типа ошибки:

type BarError struct {
    Err error
}

func (b BarError) Error() string {
    return "bar failed:" + b.Err.Error()
}

Затем вместо прямого возврата err мы обернули эту ошибку в BarError:

if err != nil {
    return BarError{Err: err}
}

Преимущество такого варианта в гибкости. Поскольку BarError — это пользовательская структура, то при необходимости мы можем добавить в нее любой дополнительный контекст. Но если потребуется повторить эту операцию, то необходимость создавать специфический тип ошибки может загрязнить код.

Для решения этой проблемы в Go 1.13 появилась директива %w:

if err != nil {
    return fmt.Errorf("bar failed: %w", err)
}

Этот код оборачивает исходную ошибку, чтобы можно было добавлять дополнительный контекст без необходимости создания другого типа ошибки.

Поскольку исходная ошибка остается доступной, клиент может развернуть родительскую ошибку, а затем проверить, относится ли исходная ошибка к какому-либо специфическому типу или значению.

Последний вариант, использование директивы %v:

if err != nil {
    return fmt.Errorf("bar failed: %v", err)
}

Отличие заключается в том, что сама ошибка не обернутая. Мы преобразуем ее в другую ошибку, чтобы добавить контекст, и исходная ошибка становится недоступной.

Информация об источнике проблемы остается доступной. Но вызывающая сторона не может развернуть эту ошибку и проверить, была ли источником всех неприятностей bar error. Так что в некотором смысле эта опция носит более ограничительный характер, чем %w. Нужно ли предотвращать такие ситуации, поскольку стала доступная директива %w? Не обязательно.

Оборачивание ошибки делает исходную ошибку доступной для вызывающей стороны. Следовательно, это означает введение их потенциальной связки. Представьте, что мы используем обертку, и вызывающая Foo сторона проверяет, является ли bar error исходной ошибкой. А что, если мы изменим код и воспользуемся другой функцией, которая будет возвращать другой тип ошибки? Это будет нарушать процедуру проверки ошибок, сделанную вызывающей стороной. Чтобы убедиться, что наши клиенты не полагаются на то, что мы считаем деталями реализации, возвращаемая ошибка должна быть преобразована, а не обернута. В таком случае вместо %w можно использовать %v.

Вариант/случай Дополнительный контекст Пометка ошибки Исходная ошибка доступна?
Возврат ошибки напрямую Нет Нет Да
Пользовательский тип ошибки Возможен (например, если тип ошибки содержит строковое поле) Да Возможно (если исходная ошибка экспортируется или доступна через метод)
fmt.Errorf с %w Да Нет Да
fmt.Errorf с %v Да Нет Нет

7.3 #50: Checking an error type inaccurately

При оборачивании ошибок с помощью директивы %w важно изменить и способ проверки типа ошибки на его специфичность, иначе обработка ошибок может оказаться неточной.

Именно для этой цели в Go 1.13 появилась директива для оборачивания ошибок и способ проверки того, относится ли обернутая ошибка к некоторому определенному типу, с помощью errors.As. Эта функция рекурсивно разворачивает ошибку и возвращает true, если какая-то ошибка в цепочке соответствует ожидаемому типу.

7.4 #51: Checking an error value inaccurately

Sentinel error (Сигнальная ошибка) — это ошибка, определенная как глобальная переменная:

import "errors"
var ErrFoo = errors.New("foo")

В общем случае принято начинать с Err, за которым следует тип ошибки: здесь ErrFoo. Sentinel error сообщает об expected (ожидаемой) ошибке.

И наоборот, такие ситуации, как проблемы с сетью и ошибки при подключении и опросе соединения, являются unexpected (непредвиденными) ошибками. Это означает не то, что мы не хотим обрабатывать непредвиденные ошибки, а то, что семантически эти ошибки несут в себе разные смыслы.

Это общий принцип, стоящий за сигнальными ошибками. Они сообщают об ошибках, которые можно ожидать заранее. И наличие таких ошибок, как предполагается, клиенты будут проверять. Поэтому в качестве общих указаний:

  • Ожидаемые ошибки должны быть сделаны в виде значений (сигнальных ошибок): var ErrFoo = errors.New("foo").
  • Непредвиденные ошибки должны быть оформлены как типы ошибок: type BarError struct { ... }, где BarError реализует интерфейс error.

Если в приложении мы используем обертку ошибок с помощью директивы %w и fmt.Errorf, проверка ошибки на ее равенство определенному значению должна выполняться с использованием errors.Is, а не ==. И даже если сигнальная ошибка обернута, errors.Is может рекурсивно ее развернуть и сравнивать каждую ошибку в цепочке с заданным значением.

7.5 #52: Handling an error twice

Многократная обработка программных сбоев — это оплошность, которую часто допускают разработчики, и это не какая-то особенность Go.

Почему наличие двух строк, относящихся к одной ошибке, — проблема? Это усложняет отладку. Например, если рассматриваемая функция вызывается несколько раз в режиме конкурентного выполнения, эти два сообщения могут не следовать в журналах одно за другим, что усложнит процесс отладки.

Ошибка должна быть обработана только один раз. Logging (Регистрация) — это такая же обработка ошибки, как и возврат ошибки. Следовательно, мы должны либо регистрировать, либо возвращать ошибку, но никогда не делать и то и другое вместе.

Делая это, мы упрощаем код и достигаем лучшего понимания ситуаций с ошибками. Использование обертки ошибок — наиболее удобный подход, поскольку позволяет указывать на исходную ошибку и добавлять к ошибке контекст.

7.6 #53: Not handling an error

В некоторых случаях требуется проигнорировать ошибку, возвращаемую функцией. В Go делать это нужно только одним способом.

Поэтому когда мы хотим игнорировать ошибку в Go, есть только один способ отобразить это намерение в коде:

_ = notify()

Вместо того чтобы вообще не присваивать значение ошибки какой-либо переменной, мы присваиваем ее пустому идентификатору. С точки зрения компиляции и времени выполнения такой подход ничего не меняет по сравнению с первым вариантом. Но эта новая версия ясно показывает всем, что в данном случае ошибка нас никак не интересует.

Такой код также может сопровождаться комментарием, но не таким, как в этом примере:

// Игнорировать ошибку
_ = notify()

Этот комментарий дублирует то, что делает код, — этого следует избегать. Хорошей практикой будет написание комментария, указывающего на причину, по которой ошибка игнорируется, например:

// Доставка не более одного раза.
// Поэтому в случае ошибок некоторые из них допустимо просто пропускать.
_ = notify()

Ситуации игнорирования ошибок в Go должны быть исключительными. Во многих случаях лучше предпочесть их logging (регистрацию), пусть даже на низком уровне журнала. Но если вы уверены, что ошибку можно и нужно игнорировать, то делайте это явно, присвоив ее пустому идентификатору. Так читатель кода поймет, что ошибку проигнорировали намеренно.

7.7 #54: Not handling defer errors

Отказ от обработки ошибок в операторах defer — это оплошность, которую часто допускают разработчики.

В предыдущем разделе я говорил, что ошибки всегда должны обрабатываться. Но в этом случае ошибка, возвращаемая вызовом defer, игнорируется:

defer rows.Close()

Если мы не хотим обрабатывать ошибку, нужно проигнорировать ее в явном виде, используя пустой идентификатор:

defer func() { 
    _ = rows.Close()
}()

Эта версия более многословна, но с точки зрения удобства сопровождения она лучше, поскольку мы явно указываем, что игнорируем ошибку.

Однако в данном случае, вместо того чтобы слепо игнорировать все ошибки от отложенных вызовов, следует задуматься, нет ли другого, более правильного подхода. Здесь вызов Close() возвращает ошибку, если не удается освободить соединение с БД из пула. Следовательно, игнорирование этой ошибки — не лучший вариант. Более грамотным подходом будет регистрация сообщения:

defer func() {
    err := rows.Close()
    if err != nil {
        log.Printf("failed to close rows: %v", err)
    }
}()

Ошибки всегда должны обрабатываться. В случае ошибок, возвращаемых defer, как минимум нужно явно их проигнорировать. Если этого недостаточно, следует обработать ошибку напрямую, зарегистрировав ее в журнале или передав вызывающей стороне.

Summary

  • Использование паники в Go — это вариант борьбы с ошибками. Но такой вариант следует применять с осторожностью только в самых крайних случаях: например, чтобы сигнализировать об ошибке программиста или когда невозможно загрузить обязательную зависимость.
  • Оборачивание ошибки позволяет пометить ошибку и/или дополнить ее каким-то контекстом. Однако это создает потенциальную связанность, поскольку делает исходную ошибку доступной для вызывающей функции. Если вы хотите предотвратить это, не используйте оборачивание ошибок.
  • Если вы используете оборачивание ошибок с помощью директивы %wи fmt.Errorf, сравнение ошибки с типом или значением должно выполняться с помощью errors.As или errors.Is соответственно. В противном случае, если возвращаемая ошибка, которую вы хотите проверить, обернута, она не пройдет проверку.
  • Чтобы передать информацию об expected (ожидаемой) ошибке, используйте сигнальные ошибки (значения ошибок). Unexpected (непредвиденная) ошибка должна быть определенного типа.
  • В большинстве ситуаций ошибку следует обрабатывать только один раз. Регистрация ошибки — это тоже обработка. Поэтому выбирайте между ведением журнала или возвратом ошибки. Во многих случаях оборачивание ошибок — это хорошее решение, поскольку позволяет снабдить ошибку дополнительным контекстом, а также вернуть исходную ошибку.
  • Игнорирование ошибки, будь то во время вызова какой-либо функции или в функции defer, должно выполняться в явном виде с использованием пустого идентификатора. Иначе читатели вашего кода запутаются и не поймут, игнорируете вы ошибку намеренно или случайно.
  • Не игнорируйте ошибки, возвращаемые функцией defer. Лучше обработать их напрямую либо передать вызывающей функции — в зависимости от контекста. Если вы все же хотите проигнорировать ошибку, используйте пустой идентификатор.

CHAPTER 8 Concurrency: Foundations

8.1 #55: Mixing up concurrency and parallelism

В отличие от parallelism (параллелизма), смысл которого в том, что одни и те же действия совершаются одновременно, concurrency (конкурентность) связана со структурой.

Конкурентность обеспечивает возможность параллелизма. Конкурентность подталкивает к созданию структуры для решения всей проблемы через ее разбиение на части, действия внутри которых можно распараллелить.

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

  • Rob Pike

Конкурентность — это о работе с большим количеством вещей одновременно. Параллелизм — это о выполнении множества дел одновременно.

  • Rob Pike

Итак, конкурентность и параллелизм — это разные вещи.

  • Конкурентность связана со структурой
    Мы можем превратить последовательную реализацию в параллельную, вводя различные шаги, которые могут совершаться в рамках отдельных параллельно выполняемых потоков.
  • Параллелизм касается выполнения
    Можно использовать его на уровне шагов, добавляя больше параллельных потоков. Понимание этих двух концепций очень важно для качественной разработки на Go.

8.2 #56: Thinking concurrency is always faster

Многие разработчики ошибочно полагают, что конкурентное решение всегда быстрее, чем последовательное. И это в корне неверно. Общая производительность выбранного решения зависит от многих факторов, таких как эффективность структуры (конкурентность), от того, какие части могут выполняться параллельно, и от уровня конкуренции между вычислительными блоками.

8.2.1 Go scheduling

Thread (Поток) — это наименьшая единица обработки, которую может выполнять операционная система (ОС). Если process (процесс) хочет выполнить несколько действий одновременно, он запускает несколько потоков. Потоки могут быть:

  • Concurrent (конкурентными)
    два или более потока могут запускаться, выполняться и завершаться в перекрывающиеся периоды времени;
  • Parallel (параллельными)
    одна и та же задача может выполняться несколько раз одновременно.

ОС отвечает за оптимальное планирование процессов потоков, чтобы:

  • все потоки могли использовать процессорное время, не слишком долго ожидая своей очереди;
  • рабочая нагрузка распределялась между различными ядрами процессора максимально равномерно.

ПРИМЕЧАНИЕ: Термин поток (thread) на уровне CPU может иметь другое значение. Каждое физическое ядро может состоять из нескольких логических ядер (концепция гиперпоточности/hyperthreading), которые также называются потоками (thread). В этом разделе слово «поток» используется тогда, когда имеется в виду единица обработки, а не логическое ядро.

Ядро процессора выполняет различные потоки. Когда оно переключается с одного потока на другой, выполняется операция под названием context switching (переключение контекста). Активный поток, потребляющий циклы процессора и находящийся в состоянии выполнения (executing state), переходит в состояние готовности выполнения (runnable state), это означает, что он готов к запуску и ожидает доступное ядро. Переключение контекста считается дорогостоящей операцией, поскольку ОС нужно сохранить текущее состояние выполнения потока, перед переключением (например, текущие значения регистров).

В Go нельзя создавать потоки напрямую, но есть возможность создавать горутины, которые можно рассматривать как потоки на уровне приложения. При этом если поток ОС управляется самой ОС, то горутина управляется Go runtime (средой выполнения Go). Кроме того, по сравнению с потоком ОС горутина занимает меньше места в памяти: 2 KB из Go 1.4. Размер потока ОС зависит от ОС, но, например, в Linux/x86-32 размер по умолчанию составляет 2 MB (см. http://mng.bz/DgMw). Меньший размер делает переключение контекста более быстрым.

ПРИМЕЧАНИЕ: Переключение контекста горутины по сравнению с таковым для потока происходит примерно на 80–90 % быстрее в зависимости от архитектуры.

Теперь обсудим, как работает планировщик Go, и поймем, как обрабатываются горутины. Здесь используется следующая терминология (см. http://mng.bz/N611):

  • Gгорутина
  • Mпоток ОС (M означает machine, машина)
  • Pядро CPU (P означает processor, процессор)

Каждый поток ОС (M) назначается ядру CPU (P) планировщиком ОС. Затем каждая горутина (G) запускается на M. Переменная GOMAXPROCS определяет предел количества потоков M, отвечающих за одновременное исполнение кода пользовательского уровня. Но если поток заблокирован в системном вызове (например, ввода/вывода), планировщик может запустить больше потоков M. Начиная с Go 1.5, GOMAXPROCS по умолчанию равен количеству доступных ядер CPU.

Горутина имеет более простой жизненный цикл, чем поток ОС. Она может совершать одно из следующих действий:

  • исполнение (executing) — на M назначено исполнение горутины, и входящие в нее инструкции выполняются;
  • готовность к выполнению (runnable) — горутина ожидает перехода в состояние выполнения;
  • ожидание (waiting) — горутина остановлена и ожидает завершения чего-либо, например системного вызова или операции синхронизации (например, получения mutex).

Остался последний шаг к пониманию того, как в Go реализуется планирование: что происходит, когда горутина создана, но еще не может быть выполнена, например, все остальные M уже выполняют G. Что тогда будет делать среда выполнения Go? Ответ: поставит в очередь. Среда выполнения Go обрабатывает два типа очередей: по одной локальной очереди для каждого P и глобальная очередь, которая ориентирована на выполнение на всех P.

При необходимости среда выполнения Go может создать больше потоков ОС, чем значение GOMAXPROCS.

Вот реализация планирования в псевдокоде (см. http://mng.bz/lxY8):

runtime.schedule() {
    // Только 1/61 от всего времени, проверка глобальной очереди выполнения на наличие G.
    // Если ничего не найдено, проверка локальной очереди.
    // Если ничего не найдено,
    //    попытка украсть у других P.
    //    Если опять ничего не найдено, проверка глобальной очереди готовых к выполнению.
    //    Если ничего не найдено, опрос сети.
}

При каждом шестьдесят первом выполнении планировщик Go будет проверять, доступны ли горутины из глобальной очереди. Если нет, он проверит свою локальную очередь. Если же и глобальная и локальная очереди пусты, планировщик может перехватить горутины из других локальных очередей. Этот принцип в планировании называется кражей задач (work stealing), и он позволяет недостаточно загруженному процессору активно искать горутины, ожидающие своего выполнения на другом процессоре, и украсть некоторые из них.

Обратите внимание: до версии Go 1.14 планировщик был кооперативным, что означало, что горутина могла быть контекстно отключена от потока только в определенных случаях блокировки (например, отправка или получение канала, операции ввода/вывода, ожидание получения mutex). Начиная с Go 1.14, планировщик стал вытесняющим (preemptible): когда горутина выполняется в течение некоторого заданного отрезка времени (10 мс), она будет помечена как вытесняемая и может быть контекстно отключена и заменена другой горутиной. Это позволяет использовать процессор в тот период, когда выполняется какое-то длительное задание, а также для выполнения и других задач.

8.2.2 Parallel merge sort

Развертывание механизма горутин для обработки незначительных рабочих нагрузок (на примере слияния относительно небольшого набора элементов) сводит на нет преимущества, которые мы могли бы получить от параллелизма.

Каков вывод? Помните, что конкурентность не всегда быстрее и ее не следует по умолчанию рассматривать как способ решения всех проблем. Во-первых, она все усложняет. Во-вторых, современные CPU стали невероятно эффективны при выполнении последовательного и предсказуемого кода. Например, суперскалярный процессор может с высокой эффективностью распараллелить выполнение инструкций на одном ядре.

Означает ли это, что не следует использовать конкурентность? Конечно нет. Но важно помнить о вышеприведенных рассуждениях. Если мы не уверены, что параллельная версия будет быстрее, то правильным подходом будет начать с простой последовательной версии и отталкиваться от нее, используя, например, профилирование и бенчмарки. Это может быть единственным способом убедиться, что конкурентность того стоит.

8.3 #57: Being puzzled about when to use channels or mutexes

Channel (Каналы) — это механизм коммуникации. Внутри себя канал — это некий трубопровод, который мы можем использовать для отправки и получения значений. Он позволяет соединять конкурентные горутины.

Канал может быть:

  • Unbuffered (небуферизованным): отправляющая горутина блокируется до тех пор, пока получающая горутина не будет готова;
  • Buffered (буферизованным): отправляющая горутина блокируется только тогда, когда буфер оказывается полностью заполненным.

Как правило, параллельные горутины должны синхронизироваться, например, когда им нужно получить доступ или изменить общий ресурс, такой как срез. Синхронизация производится с помощью мьютексов, но не с любыми типами каналов (только не с буферизованными каналами). Следовательно, в общем случае синхронизация между параллельными горутинами должна достигаться с помощью мьютексов.

И наоборот, в общем случае конкурентные горутины должны координировать и оркестровать свои действия.

Мьютексы и каналы имеют разную семантику. Всякий раз, когда нужно разделить состояние или получить доступ к общему ресурсу, мьютексы обеспечивают эксклюзивный доступ к этому ресурсу. И наоборот, канал — это механизм для передачи сигналов с данными или без них (chan struct{} или нет). Координация или передача права собственности должна осуществляться по каналам. Важно знать, являются ли горутины параллельными или конкурентными, потому что обычно для параллельных горутин нужны мьютексы, а для конкурентных каналы.

8.4 #58: Not understanding race problems

Race problems (Проблемы гонки) — это одни из самых сложных и коварных ошибок, с которыми сталкиваются программисты.

Мы должны понимать некоторые важные вопросы:

  • data races (гонка данных) и race conditions (состояние гонки)
  • их возможные последствия
  • способы их избежать

8.4.1 Data races vs. race conditions

Сосредоточимся на гонке данных (data race), которая происходит, когда две или более горутины одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из них выполняет запись в эту ячейку.

Если две горутины одновременно обращаются к одной и той же ячейке памяти и хотя бы одна записывает в нее данные, результат может быть опасным. Хуже того, в некоторых ситуациях в ячейке памяти может оказаться значение, содержащее бессмысленную комбинацию битов.

Как предотвращать гонки данных? Рассмотрим несколько разных методик. Я не буду перечислять все возможные варианты (например, опустим atomic.Value), а покажу основные из них.

  • Первый вариант — сделать операцию инкремента атомарной, то есть выполняемой целиком за один шаг. Это предотвращает запутанное выполнение операций.

Атомарные операции можно выполнять в Go с помощью пакета sync/atomic.

ПРИМЕЧАНИЕ: Пакет sync/atomic предоставляет примитивы для int32, int64, uint32 и uint64, но не для int.

  • Второй вариант — синхронизировать две горутины с помощью специальной структуры данных, mutex. Слово mutex (мьютекс) образовано от mutual exclusion, что означает взаимное исключение. Мьютекс обеспечивает обращение к так называемой critical section (критической секции) не более одной горутины. В пакете sync определяется тип Mutex.

Какой подход лучше? Все довольно просто. Как я говорил, пакет sync/atomic работает только с определенными типами данных. Если нужно обрабатывать данные каких-то других видов (например, срезы, карты или структуры), то мы не можем полагаться на sync/atomic.

  • Третий вариант — запретить совместное использование одного и того же места в памяти и отдать предпочтение communication (взаимодействию) между горутинами.

Подведем итоги. Гонка данных происходит, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти(например, к одной и той же переменной) и по крайней мере одна из горутин выполняет запись.

Мы рассмотрели варианты предотвращения этой проблемы с помощью трех синхронизирующих подходов:

  • использование атомарных операций;
  • защита критических секций с помощью мьютексов;
  • использование связи и каналов для обеспечения того, чтобы переменная обновлялась только одной горутиной.

Во всех этих трех случаях значение не зависит от порядка выполнения двух горутин. Но в зависимости от того, какую операцию мы хотим выполнить, обязательно ли приложение, свободное от гонки данных, означает детерминированный результат?

Race condition (состояние гонки) возникает, когда поведение зависит от последовательности или времени выполнения событий, которые невозможно контролировать.

Обеспечение определенной последовательности выполнения горутин — вопрос координации и оркестровки.

Эту проблему можно решить с помощью каналов. Координация и оркестровка также могут гарантировать, что к определенному разделу будет обращаться только одна горутина.

При работе с конкурентными приложениями важно понимать, что ситуация гонки данных отличается от ситуации состояния гонки.

Гонка данных возникает, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из них выполняет запись в эту ячейку. Гонка данных означает возможность неожиданного поведения. Тем не менее приложение, в котором обеспечено отсутствие ситуаций с гонками данных, не обязательно будет выдавать детерминированные результаты. Приложение может быть свободным от гонок данных, но по-прежнему зависеть от неконтролируемых событий (выполнение горутины, скорость распространения сообщения по каналу или длительность обращения к базе данных). В таких случаях будет наблюдаться состояние гонки. Понимание этих концепций очень важно при профессиональной разработке конкурентных приложений.

8.4.2 The Go memory model

Go memory model (Модель памяти Go) — это спецификация, определяющая условия, при которых чтение из переменной в одной горутине может гарантированно произойти только после записи в ту же переменную другой горутиной. Другими словами, она предоставляет определенные гарантии, о которых разработчики должны помнить, чтобы избежать гонки данных и обеспечить детерминированный результат.

В рамках одной горутины нет возможности несинхронизированного доступа. То, что одно действие происходит до (happens-before) другого, гарантируется порядком, заданным программой.

При работе с несколькими горутинами помните о некоторых из этих гарантий. Рассмотрим эти гарантии (некоторые из них скопированы из модели памяти Go):

  • Создание горутины happens-before выполнения этой горутины. Следовательно, чтение переменной, а затем запуск новой горутины, которая производит запись в эту переменную, не приводит к гонке данных.
  • Выход из горутины не обязательно happens-before наступления какого-либо события.
  • Отправка по каналу happens-before завершения соответствующего приема из этого канала. С помощью транзитивности мы можем обеспечить то, что доступ к переменным будет синхронизирован и, следовательно, не будет гонки данных.
  • Закрытие канала happens-before получения замыкания.
  • Прием из небуферизованного канала happens-before завершения отправки по этому каналу.

8.5 #59: Not understanding the concurrency impacts of a workload type

В зависимости от того, влияет ли какая-то рабочая нагрузка в большей степени на процессор (CPU) или на систему ввода/вывода (I/O), соответствующие проблемы будем решать по-разному. Для начала определимся с понятиями.

Время выполнения рабочей нагрузки ограничено одним из следующих факторов:

  • Тактовой частотой/скоростью работы центрального процессора
    например, это главный фактор при выполнении алгоритма сортировки слиянием. Такая рабочая нагрузка называется CPU-bound.
  • Скоростью работы системы ввода/вывода
    например, это главный фактор при выполнении вызова REST или запроса к базе данных. Рабочая нагрузка в этом случае называется I/O-bound.
  • Объемом доступной памяти
    такая рабочая нагрузка называется memory-bound.

Почему так важно классифицировать рабочую нагрузку в контексте конкурентных приложений? Разберемся с этим, рассмотрев один из паттернов конкурентности: worker pooling pattern (пул рабочих процессов).

Одним из вариантов является использование так называемого паттерна Worker Pool. Для этого необходимо создать workers (воркеры — рабочие процессы-горутины) фиксированного размера, которые опрашивают задачи из общего канала.

Наличие фиксированного количества горутин ограничивает влияние недостатков, которые мы уже обсуждали. Это сужает влияние ресурсов и предотвращает переполнение внешней системы.

А теперь встает основной вопрос: каким должно быть значение размера пула? Ответ зависит от типа рабочей нагрузки.

  • Если рабочая нагрузка — типа I/O-bound, то ответ зависит от внешней системы. С каким количеством конкурентных обращений сможет справиться система, если мы хотим максимизировать ее пропускную способность?

  • Если рабочая нагрузка — типа CPU-bound, то рекомендуется полагаться на GOMAXPROCS — переменную, которая устанавливает количество потоков ОС, выделенных для выполнения горутин. По умолчанию это значение равно количеству логических процессоров.

ПРИМЕЧАНИЕ: Мы можем использовать функцию runtime.GOMAXPROCS(int) для обновления значения GOMAXPROCS. Вызов с 0 в качестве аргумента не меняет, а просто возвращает текущее значение.

ПРИМЕЧАНИЕ: Если при каких-то особых условиях мы захотим, чтобы количество горутин было привязано к количеству ядер CPU, почему бы не положиться на функцию runtime.NumCPU(), которая возвращает количество логических ядер CPU? Как я говорил, параметр GOMAXPROCS может быть изменен и может быть меньше, чем количество ядер CPU. В случае рабочей нагрузки типа CPU-bound, если количество ядер равно четырем, но есть только три потока, нужно запустить три горутины, а не четыре. В противном случае поток будет делить время выполнения между двумя горутинами, увеличивая количество переключений контекста.

При реализации паттерна Worker Pool мы увидели, что оптимальное количество горутин в пуле зависит от типа рабочей нагрузки.

И последнее, но не менее важное: помните, что в большинстве случаев нужно проверять предположения с помощью бенчмарков. Конкурентность — не простой принцип, и отталкиваясь только от нее, можно сделать поспешные предположения, которые окажутся неверными.

8.6 #60: Misunderstanding Go contexts

Согласно официальной документации https://pkg.go.dev/context:

Контекст переносит крайний срок, сигнал отмены и другие значения через границы API.


A Context carries a deadline, a cancellation signal, and other values across API boundaries.

8.6.1 Deadline

Deadline (Крайний срок) указывает на некий момент времени, определяемый одним из следующих способов:

  • time.Duration с настоящего момента (например, через 250 мс);
  • time.Time (например, 2023-02-07 00:00:00 UTC).

Семантика deadline означает, что текущая деятельность должна быть остановлена, если этот крайний срок наступил. «Деятельность» — это, например, запрос типа ввод/вывод или горутина в состоянии ожидания получения сообщения из канала.

Функция называется context aware (контекстно-зависимая), если она принимает Context и прекращает свою деятельность после отмены контекста.

Функции context.WithTimeout, которая принимает тайм-аут и контекст. Функция context.WithTimeout возвращает две переменные:

  • созданный контекст
  • функцию отмены func(), которая отменит контекст после вызова.

8.6.2 Cancellation signals

context.WithCancel, возвращающий контекст (возвращается первая переменная), который отменяется после вызова функции cancel (возвращается вторая переменная).

8.6.3 Context values

Как и context.WithTimeout, context.WithDeadline и context.WithCancel, context.WithValue создается из родительского контекста.

Можно получить доступ к значению, используя метод Value:

ctx := context.WithValue(context.Background(), "key", "value")
fmt.Println(ctx.Value("key"))

Задаваемые ключ и значения имеют тип any. Действительно, для значений мы хотим передавать тип any. Но почему ключ должен быть еще и пустым интерфейсом, а не, например, строкой? Потому, что это может привести к коллизиям: две функции из разных пакетов могут в качестве ключа использовать одно и то же строковое значение. Следовательно, последнее из них переопределит предыдущее. Лучшей практикой при обработке контекстных ключей будет создание неэкспортируемого пользовательского типа:

package provider

import "context"

type key string

const myCustomKey key = "key"

func f(ctx context.Context) {
  ctx = context.WithValue(ctx, myCustomKey, "foo")
  // ...
}

Константа myCustomKey не экспортируется. Поэтому нет риска, что другой пакет, использующий тот же контекст, может переопределить уже заданное значение. Даже если другой пакет создает тот же myCustomKey на основе типа key, это будет другой ключ.

Так какой смысл иметь контекст, содержащий список «ключ — значение»? По-скольку контексты Go повсеместны, есть множество сценариев использования.

Например, при трассировке может потребоваться, чтобы разные подфункции использовали один и тот же идентификатор корреляции. Некоторые разработчики могут посчитать его слишком агрессивным (invasive), чтобы быть частью сигнатуры функции. В связи с этим мы могли бы также решить включить его как часть задаваемого контекста.

8.6.4 Catching a context cancellation

Тип context.Context экспортирует метод Done, который возвращает односторонний канал уведомления (то есть он может только получать): <-chan struct{}. Этот канал закрывается, когда работа, связанная с контекстом, должна быть отменена. Например:

  • Канал Done, связанный с контекстом, созданным с помощью context.WithCancel, закрывается при вызове функции cancel.
  • Канал Done, связанный с контекстом, созданным с помощью context.WithDeadline, закрывается по истечении крайнего срока.

Внутренний канал следует закрывать, когда контекст отменяется или наступает крайний срок, а не тогда, когда он получит некоторое определенное значение, потому что закрытие канала — это единственное действие с ним, сигнал о котором получат все горутины-потребители. Таким образом, все потребители будут уведомлены об отмене контекста или достижении крайнего срока.

Более того, context.Context экспортирует метод Err, возвращающий nil, если канал Done еще не закрыт. В противном случае возвращается ненулевая ошибка, объясняющая, почему канал Done был закрыт. Например:

  • ошибка context.Canceled, если канал отменен;
  • ошибка context.DeadlineExceeded, если крайний срок действия контекста прошел.
Реализация функции, получающей контекст

В функции, которая получает контекст, сообщающий о возможной отмене или тайм-ауте, действия по получению или отправке сообщения в канал не должны выполняться блокирующим образом.

Например, в следующей функции мы отправляем сообщение в канал и получаем его из другого канала:

func f(ctx context.Context) error {
    // ...
    ch1 <- struct{}{} // Отправка
    v := <-ch2 // Получение
    // ...
}

Проблема здесь в том, что если контекст отменяется или истекает время, в течение которого он актуален, то, возможно, придется ожидать, пока сообщение не будет отправлено или получено. Вместо этого мы должны использовать select либо для ожидания завершения действий канала, либо для ожидания отмены контекста:

func f(ctx context.Context) error {
    // ...
    select { // Отправка сообщения в ch1 или ожидание отмены контекста
    case <-ctx.Done():
        return ctx.Err()
    case ch1 <- struct{}{}:
    }
    select { // Получение сообщения из ch2 или ожидание отмены контекста
    case <-ctx.Done():
        return ctx.Err()
    case v := <-ch2:
      // ...
    }
}

В этой новой версии, если ctx отменяется или завершается, мы немедленно возвращаемся, не блокируя канал отправки или получения.


Опытные Go-разработчики должны понимать, что такое контекст и как его использовать. context.Context доступен в стандартной библиотеке и во всех внешних библиотеках. Как я говорил, контекст позволяет передавать deadline, cancellation signals и/или список «ключ — значение». В общем случае функция, на которую пользователи ожидают ответа, должна принимать контекст, поскольку это позволяет вызывающим сторонам решать, когда прервать вызов этой функции.

Если вы сомневаетесь, какой контекст использовать, выбирайте context.TODO() вместо передачи пустого контекста с помощью context.Background. context. TODO() возвращает пустой контекст, но семантически сообщает, что используемый контекст либо неясен, либо еще недоступен (например, еще не передан родителем).

Все доступные контексты в стандартной библиотеке безопасны для конкурентного использования несколькими горутинами.

Summary

  • Понимание фундаментальных различий между конкурентностью и параллелизмом — краеугольный камень в знаниях Go-разработчика. Конкурентность — это о структуре, тогда как параллелизм — о выполнении.
  • Конкурентность не всегда ведет к более быстрым решениям. Варианты, предусматривающие распараллеливание минимальных рабочих нагрузок, не обязательно будут быстрее, чем последовательная реализация. Сравнение бенчмарков решений с последовательной и конкурентной реализацией должно быть способом проверки допущений.
  • Знание того, как горутины взаимодействуют друг с другом, полезно, когда вы выбираете между каналами и мьютексами. Обычно параллельные горутины требуют синхронизации и, следовательно, использования мьютексов. Конкурентные горутины обычно требуют координации и оркестровки и, следовательно, использования каналов.
  • Гонка данных и состояние гонки — это разные понятия. Гонка данных происходит, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из горутин выполняет запись в эту ячейку. Отсутствие гонки данных не обязательно означает детерминированность в результате выполнения неких действий. Когда поведение зависит от последовательности или времени наступления событий, которые невозможно проконтролировать, то получается состояние гонки.
  • Понимание модели памяти в Go и лежащих в ее основе гарантий с точки зрения упорядочения и синхронизации важно для предотвращения возможной гонки данных или состояния гонки.
  • При создании нескольких горутин учитывайте тип рабочей нагрузки. Если создаются CPU-bound горутины, то это число должно ограничиваться значением переменной GOMAXPROCS, которая по умолчанию отражает количество ядер CPU на хосте. При создании I/O-bound горутин следует учитывать характеристики внешней системы.
  • Контексты в Go также очень важны для понимания конкурентности. Контекст позволяет передавать информацию о deadline, cancellation signals и/или списки «ключ — значение».

CHAPTER 11 Testing

Тестирование — важнейший аспект жизненного цикла проекта. Оно дает бесчисленные преимущества:

  • укрепляет доверие к приложению,
  • документирует код,
  • облегчает рефакторинг.

11.1 #82: Not categorizing tests

Testing pyramid (Пирамида тестирования) — это модель, которая группирует тесты по разным категориям. Unit tests (юнит-тесты или модульные тесты) находятся в основании пирамиды. Большинство тестов должны быть как unit tests:

  • они пишутся относительно просто
  • быстро выполняются
  • высокодетерминированны.

По мере продвижения вверх по пирамиде тесты становятся:

  • более сложными для написания
  • более медленными при выполнении
  • их детерминированность труднее гарантировать.

Обычный подход заключается в том, чтобы явно указать, какие из тестов следует проводить. Например, в зависимости от этапа жизненного цикла проекта требуется запустить все возможные тесты либо только unit tests. Отсутствие классификации тестов означает потенциальную потерю времени и усилий, а также потерю точности в определении объема теста.

11.1.1 Build tags

Наиболее распространенный способ классификации тестов — использование build tags (тегов сборки). Build tag (тег сборки) — это специальный комментарий в начале файла Go, за которым следует пустая строка.

Посмотрите на такой файл bar.go:

//go:build foo
package bar

Этот файл содержит тег foo. Обратите внимание, что один пакет может содержать несколько файлов с разными тегами сборки.

ПРИМЕЧАНИЕ: Начиная с версии Go 1.17, синтаксис // +build foo был заменен на //go:build foo. Сейчас (в Go 1.18) gofmt синхронизирует эти две формы, чтобы упростить миграцию.

Теги сборки используются в двух случаях.

  1. Во-первых, в качестве условной опции для сборки приложения. Например, если нужно, чтобы исходный файл был включен, только если разрешен cgo (cgo позволяет пакетам Go вызывать код C), добавьте тег //go:build cgo build.
  2. Во-вторых, если нужно классифицировать тест как интеграционный, тогда мы добавляем специальный флаг сборки, например integration.

Вот пример файла db_test.go:

//go:build integration
package db

import (
    "testing"
)

func TestInsert(t *testing.T) {
    // ...
}

Здесь есть integration в качестве тега сборки. Он указывает, что файл содержит интеграционные тесты. Преимущество использования тегов сборки заключается в том, что можно выбирать, какие виды тестов выполнять.

Если запустить go test без каких-либо параметров, он запустит только тестовые файлы без тегов сборки:

go test -v .

Если указать build tag integration:

go test --tags=integration -v .

то вместе с unit tests будут запускаться и integration tests.

Таким образом, запуск тестов с каким-то тегом включает в себя исполнение как файлов без тегов, так и файлов, соответствующих тегу. Но что, если мы хотим запускать только интеграционные тесты? Возможный способ — добавить тег отрицания в файлы unit tests. Например, использование !integration означает, что мы хотим включить тестовый файл, только если флаг integration не включен

11.1.2 Environment variables

There’s no signal when you run the tests that you’re actually missing some of them.

Peter Bourgon, member Go community

В своей статье Peter Bourgon говорит, что теги сборки имеют один главный недостаток: отсутствие сигналов о том, что тест проигнорирован.

Когда мы выполняем go test без флагов сборки, он покажет только те тесты, которые были выполнены:

go test -v .
=== RUN TestUnit
--- PASS: TestUnit (0.01s)
PASS
ok db 0.319s

Если мы не будем внимательны к тому, как обрабатываются теги, мы можем забыть о существующих тестах. По этой причине некоторые проекты предпочитают проверять категорию тестов с помощью переменных среды.

Например, можно реализовать интеграционный тест TestInsert, проверив некоторую определенную переменную среды и, возможно, пропустив какой-то подобный тест:

func TestInsert(t *testing.T) {
    if os.Getenv("INTEGRATION") != "true" {
        t.Skip("skipping integration test")
    }
    // ...
}

Одно из преимуществ использования этого подхода — явное указание на то, какие тесты пропускаются и почему. Этот метод используется не так часто, как теги сборки, но о нем стоит знать, поскольку он предоставляет некоторые преимущества.

11.1.3 Short mode

Другой подход к классификации тестов связан с их скоростью. Возможно, придется отделить тесты с коротким временем выполнения от тестов с длительным временем выполнения.

Допустим, есть набор юнит-тестов, и известно, что один из них медленный. Нужно отнести его к отдельной группе, чтобы не приходилось его запускать каждый раз (особенно если он запускается, например, после сохранения файла). Короткий режим позволяет сделать это:

func TestLongRunning(t *testing.T) {
    if testing.Short() { // Пометка теста как длительного
        t.Skip("skipping long-running test")
    }
    // ...
}

Используя testing.Short, можно узнать, был ли во время выполнения теста включен короткий режим. Затем мы используем Skip, чтобы пропустить тест.

Чтобы запустить тесты в коротком режиме, нужно послать -short:

go test -short -v .

В отличие от тегов сборки, эта опция работает для каждого теста, а не для каждого файла.

Таким образом, категоризация тестов — это лучшая практика в успешной стратегии тестирования.

В этом разделе мы рассмотрели три способа классификации тестов:

  • с использованием тегов сборки на уровне тестового файла;
  • с использованием переменных среды для пометки определенного теста;
  • на основе длительности их выполнения с использованием короткого режима.

Можно комбинировать эти подходы: например, использовать теги сборки или переменные среды для классификации теста (например, юнит- или интеграционного) и короткий режим, если проект содержит долго выполняющиеся юнит-тесты.

11.2 #83: Not enabling the -race flag

В Go есть стандартный инструмент, помогающий обнаруживать data race (гонку данных). Одна из распространенных ошибок разработчиков состоит в том, что они забывают о важности этого инструмента и не активируют его.

В Go race detector (детектор гонки) — это не инструмент статического анализа, использующийся во compile time (время компиляции). Он предназначен для поиска гонок данных, которые происходят во runtime (время выполнения). Чтобы его активировать, установите флаг -race во время компиляции или запуска теста. Например:

go test -race ./...

Как только race detector оказывается активирован, компилятор инструментирует код для обнаружения гонок данных. Понятие Instrumentation (инструментация) относится к действиям компилятора, добавляющим дополнительные инструкции: отслеживание всех обращений к памяти и запись, когда и как они происходят. Во время выполнения race detector следит за тем, не возникают ли гонки данных. Но помните о том, что это достигается ценой дополнительного расхода ресурсов из-за включенного состояния детектора гонки:

  • уровень использования памяти может увеличиться в 5–10 раз;
  • время выполнения может увеличиться от 2 до 20 раз.

Из-за такого оверхеда обычно рекомендуется включать race detector только во время локального тестирования или непрерывной интеграции. В рабочем продукте его следует избегать или использовать, например, только в случае канареечных релизов.

Если обнаружена гонка, то Go выдает предупреждение. Go всегда регистрирует следующее:

  • Причастные к гонке конкурирующие горутины
  • Где в коде происходят обращения
  • Когда были созданы эти горутины

ПРИМЕЧАНИЕ: Внутри себя детектор гонки использует vector clocks (векторные часы) — структуру данных, которую применяют для определения частичного упорядочивания событий, а также в распределенных системах, таких как базы данных. Каждое создание горутины приводит к созданию векторных часов. Инструментация обновляет векторные часы при каждом действии по доступу к памяти и акте синхронизации. Затем она сравнивает векторные часы, чтобы обнаружить потенциальную гонку данных.

Детектор гонки не может отлавливать false positive (ложноположительные) срабатывания, то есть ситуации, выглядящие как гонки данных, но не являющиеся ими. Если мы получим от него какое-то предупреждение, то это означает, что код действительно содержит гонку данных. И наоборот, иногда он дает false negative (ложноотрицательные) результаты, то есть пропуск фактически имеющих место гонок данных.

Отмечу две вещи, касающиеся тестирования.

  1. Во-первых, детектор гонки может быть хорош лишь настолько, насколько хороши наши тесты. Важно убедиться, что конкурентный код тщательно тестируется на предмет гонок данных.
  2. Во-вторых, учитывая возможные ложноотрицательные результаты, логику теста для проверки гонки данных можно поместить в цикл. Это увеличивает шансы на отлавливание возможных гонок данных:

Кроме того, если какой-то конкретный файл содержит тесты, которые приводят к гонке данных, мы можем исключить его из обнаружения гонок с помощью тега сборки !race:

//go:build !race
package main

import (
    "testing"
)

func TestFoo(t *testing.T) {
    // ...
}

func TestBar(t *testing.T) {
    // ...
}

Этот файл подлежит сборке только в том случае, если отключен детектор гонки. В противном случае весь файл не будет собран и тесты выполняться не будут.

11.3 #84: Not using test execution modes

Во время выполнения тестов команда go может воспринимать набор флагов, влияющих на выполнение тестов. Типичная недоработка — не знать эти флаги и упускать при этом возможности для более быстрого выполнения кода или более эффективного обнаружения ошибок.

11.3.1 The parallel flag

Режим параллельного выполнения позволяет запускать определенные тесты параллельно, что может быть очень полезно, например, для ускорения проведения длительных тестов. Можно поставить метку о том, что тест должен выполняться параллельно, вызвав t.Parallel:

func TestFoo(t *testing.T) {
    t.Parallel()
    // ...
}

Когда мы помечаем тест с помощью t.Parallel, он выполняется одновременно со всеми другими параллельными тестами. Но с точки зрения организации исполнения кода Go сначала запускает один за другим все последовательные тесты. После завершения последовательных тестов выполняются параллельные тесты.

По умолчанию максимальное количество одновременно выполняемых тестов равно значению GOMAXPROCS. Для сериализации тестов или, например, увеличения этого числа в контексте длительных тестов, выполняющих много операций ввода/вывода, можно изменить это значение с помощью флага -parallel:

go test -parallel 16 .

Здесь максимальное количество параллельно выполняемых тестов равно 16.

11.3.2 The -shuffle flag

Начиная с версии Go 1.17, можно задать случайный порядок выполнения тестов и бенчмарков. Когда это нужно? Лучшая практика при написании тестов — делать их изолированными. Например, они не должны зависеть от порядка их выполнения или от каких-либо общих для них переменных. Наличие скрытых зависимостей может приводить к ошибкам теста или, что еще хуже, к ошибкам, которые не будут обнаружены во время тестирования. Используем флаг -shuffleдля рандомизации тестов, то есть их выполнения в случайном порядке. Можно задать состояние этого флага on или off, чтобы включить или отключить режим перетасовки тестов (по умолчанию он отключен):

go test -shuffle=on -v .

Иногда нужно повторно запустить тесты в том же порядке. Например, если тесты не проходят во время CI, потребуется воспроизвести ошибку локально. В этом случае вместо установки флага -shuffle в состояние on требуется передать значение стартового числа (seed) для рандомизации тестов. Мы можем получить это значение как один из результатов выполнения тестов в случайном порядке, включив подробный режим (-v):

go test -shuffle=on -v .

В итого получим:

-test.shuffle 1636399552801504000 Значение seed
=== RUN TestBar
--- PASS: TestBar (0.00s)
=== RUN TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok teivah 0.129s

Здесь тесты выполнились случайным образом, но при этом go test вывел значение стартового числа: 1636399552801504000. Чтобы тесты выполнялись в том же порядке, мы передаем в shuffle это значение:

go test -shuffle=1636399552801504000 -v .
-test.shuffle 1636399552801504000
=== RUN TestBar
--- PASS: TestBar (0.00s)
=== RUN TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok teivah 0.129s

Тесты были выполнены в том же порядке: сначала TestBar, а затем TestFoo.

С существующими тестовыми флагами следует работать осторожно. Также нужно знать, какие новые функции имеются в последних версиях Go. Параллельное выполнение тестов может быть отличным способом сократить общее время полного тестирования. А режим shuffle поможет обнаружить скрытые зависимости, которые приводят к ошибкам тестирования или даже к пропуску ошибок при выполнении тестов в одном и том же порядке.

11.4 #85: Not using table-driven tests

Table-driven tests (Табличные тесты) — это эффективный метод написания компактных тестов, позволяющий сократить избыточный код и сосредоточиться на самой логике тестирования, то есть на том, что действительно важно.

Чем больше обычных тестов (т.е. отдельный тест под каждый case), тем сложнее поддержка кода.

В таких случаях можно использовать table-driven tests (табличные тесты): их логика пишется только один раз. Table-driven tests основаны на subtests (подтестах), а одна тестовая функция может включать в себя несколько таких subtests.

Мы также можем запустить только один тест, используя для этого флаг -run и объединив имя родительского теста с подтестом. Например:

go test -run=TestFoo/subtest_1 -v

Как использовать подтесты, чтобы предотвратить дублирование кода, который содержит в себе логику тестирования. Основная идея состоит в том, чтобы создавать для каждого case (случая) свой подтест. Есть разные варианты, но мы обсудим структуру данных в виде карты, где ключ соответствует имени теста, а значение представляет собой тестовые данные (входные, ожидаемые).

В табличных тестах мы избегаем использования шаблонного кода при помощи структуры данных, содержащей тестовые данные вместе с подтестами.

Table-driven tests устраняют два недостатка:

  • Имя каждого теста теперь представляет собой строку, а не имя функции в стиле PascalCase, что упрощает чтение.
  • Логика теста записывается только один раз и используется для всех разнообразных случаев. Изменение структуры тестирования или добавление нового теста требуют минимальных усилий.

В табличных тестах может быть еще один источник ошибок: как уже ранее говорилось, мы можем пометить какой-то тест как выполняющийся параллельно, вызвав t.Parallel. Мы также можем сделать это в подтестах внутри замыкания, предоставляемого t.Run:

for name, tt := range tests {
    t.Run(name, func(t *testing.T) {
        t.Parallel() // Пометка подтеста как выполняющегося параллельно
        // Использование tt
    })
}

Но это замыкание использует переменную цикла. Чтобы предотвратить проблему неосторожно обращаться с горутинами и переменными цикла, которая может привести к тому, что замыкания будут использовать неправильное значение переменной tt, мы должны создать другую переменную или теневую копию (shadow copy) tt:

for name, tt := range tests {
    tt := tt Создание теневой копии tt, что делает переменную локальной для цикла
    t.Run(name, func(t *testing.T) {
        t.Parallel()
        // Использование tt
    })
}

Таким образом, каждое замыкание будет обращаться к своей собственной переменной tt.

Если у нескольких юнит-тестов схожая структура, мы можем объединить их, используя возможности табличных тестов. Поскольку этот метод предотвращает дублирование, он упрощает изменение логики тестирования и добавление новых опций.

11.5 #86: Sleeping in unit tests

Flaky test (нестабильный тест) может как пройти, так и не пройти без каких-либо изменений в коде. Наличие нестабильных тестов — это одна из самых больших проблем в тестировании, поскольку они дорогие в отладке и подрывают уверенность в точности тестирования. Вызов time.Sleep в тесте может сигнализировать о возможной нестабильности. Например, конкурентный код часто тестируется с помощью задержек (sleeps).

Как улучшить юнит-тест имеющий time.Sleep? Прежде всего периодически проверять заданное условие, используя retries (повторные попытки). Например, написать функцию, которая принимает утверждения в качестве аргумента, а также максимальное количество попыток и время ожидания, и вызывается периодически, чтобы избежать занятого цикла:

func assert(
    t *testing.T,
    assertion func() bool,
    maxRetry int,
    waitTime time.Duration
) {
    for i := 0; i < maxRetry; i++ {
        if assertion() {
            return
    }
    time.Sleep(waitTime)
}
t.Fail() }

Эта функция проверяет предоставленное assertion (утверждение) и выдает сбой после совершения определенного количества попыток. Мы также используем time.Sleep, но в этом коде могли бы использовать и более короткую задержку.

Retry strategy (стратегия повторных попыток) — это предпочтительный подход по сравнению с использованием пассивных задержек.

ПРИМЕЧАНИЕ: Некоторые библиотеки тестирования, например testify, предлагают функции, использующие «повторные попытки». В testify есть функция Eventually, реализующая утверждения, которые в конечном итоге должны оказаться правильными, а также другие функции, например настройку сообщения об ошибке.

Другая стратегия заключается в использовании каналов для синхронизации горутины, публикующей структуры Foo, и горутины тестирования.

Что выбрать: повторы или синхронизацию? Синхронизация сокращает время ожидания до минимума и делает тест полностью детерминированным, если он хорошо спроектирован.

Но если использовать синхронизацию по какой-то причине нельзя, то следует пересмотреть дизайн кода, поскольку в нем могут таиться проблемы. Если синхронизация действительно невозможна, то мы должны — для устранения не детерминированности результатов тестов — использовать опцию «повторных попыток», которая является лучшим выбором, чем использование пассивных задержек.

11.6 #87: Not dealing with the time API efficiently

Некоторые функции должны полагаться на API времени, например, для получения текущего времени. В таком случае легко получить brittle unit tests (хрупкие юнит-тесты), которые в какой-то момент могут провалиться.

Первый подход заключается в том, чтобы сделать способ получения текущего времени зависимостью от структуры. В продакшене мы внедрили бы настоящую реализацию, а в юнит-тестах, например, передали бы stub (заглушку/стаб).

Есть различные методы создания такой зависимости, например интерфейс или тип функции.


Использование глобальной переменной

Вместо использования поля можно получить данные о времени через глобальную переменную:

var now = time.Now // Определение новой глобальной переменной

В целом мы должны стараться избегать глобального состояния, в котором могут изменяться какие-то общие параметры и структуры. В нашем случае это привело бы как минимум к одной конкретной проблеме: тесты больше не были бы изолированы, поскольку зависели бы от одной общей переменной. В частности, поэтому тесты нельзя было запускать параллельно. При возможности следует обрабатывать такие случаи как часть структурных зависимостей, способствуя изоляции тестов.


Второй подход заключается в том, что бы вместо обработки времени как не экспортируемой зависимости, запросить текущее время у клиентского кода.

К тестированию кода, использующего API времени, следует подходить с большой осторожностью, так как оно может быть причиной нестабильных тестов.

Мы рассмотрели два способа справиться с этим.

  • Первый — сохранить взаимодействия time как часть зависимости, которую можно сымитировать в юнит-тестах, используя наши собственные реализации или полагаясь на внешние библиотеки.
  • Второй — переработать API и запрашивать у клиентов необходимую информацию, например текущее время (этот метод проще, но подразумевает большие ограничения).

11.7 #88: Not using testing utility packages

Стандартная библиотека предоставляет пакеты утилит для тестирования. И когда разработчик не знает о них, то может полагаться либо на другие решения, которые не так удобны, либо вообще заниматься велосипедостроением.

11.7.1 The httptest package

Пакет httptest предлагает утилиты для тестирования как HTTP-клиентов, так и HTTP-серверов.

Тестирование обработчика с помощью httptest не проверяет транспорт (часть HTTP). В центре внимания теста находится прямой вызов обработчика с запросом и способ записи ответа.

А если надо протестировать этот клиент? Один из вариантов — использовать Docker и запустить фиктивный сервер для возврата некоторых предварительно зарегистрированных ответов. Но такой подход замедляет выполнение теста. Другой вариант — использовать httptest.NewServer для создания локального HTTP-сервера на основе обработчика, который мы предоставим.

По сравнению с тестированием обработчика тест клиента выполняет фактический HTTP-вызов, но делает это всего за несколько миллисекунд.

Можно также запустить новый сервер, применив TLS с помощью httptest.NewTLSServer, и создать, но пока не запускать сервер с помощью httptest.NewUnstartedServer, чтобы его можно было запустить лениво.

11.7.2 The iotest package

В пакете iotest реализованы утилиты для тестирования Reader и Writer.

При реализации собственного io.Reader важно не забыть протестировать его с помощью iotest.TestReader. Эта вспомогательная функция проверяет, правильно ли ведет себя Reader: возвращает ли он число прочитанных байтов точно, заполняет ли заданный срез и т.д. Она тестирует также различные варианты поведения, если предоставленный модуль чтения реализует такие интерфейсы, как io.ReaderAt.

Еще один вариант использования пакета iotest — убедиться, что приложение, использующее Reader и Writer, устойчиво к ошибкам:

  • iotest.ErrReader создает io.Reader, который возвращает указанную ошибку.
  • iotest.HalfReader создает io.Reader, который читает только половину запрошенного количества байтов из другого io.Reader.
  • iotest.OneByteReader создает io.Reader, который читает по одному байту для каждого непустого чтения из другого io.Reader.
  • iotest.TimeoutReader создает io.Reader, который возвращает ошибку при втором чтении без данных. Последующие вызовы будут успешными.
  • iotest.TruncateWriter создает io.Reader, который записывает в другой io.Writer, но молча прекращает работу после записи n байт.

11.9 #90: Not exploring all the Go testing features

Разработчики должны знать о конкретных возможностях, функциях и опциях тестирования в Go, иначе оно станет менее точным и даже менее эффективным.

11.9.1 Code coverage

При разработке полезно видеть, для каких частей кода есть соответствующие тесты. Что получить эту информацию, используйте флаг -coverprofile:

go test -coverprofile=coverage.out ./...

Эта команда создает файл coverage.out, который можно открыть с помощью go tool cover:

go tool cover -html=coverage.out

Эта команда открывает браузер и показывает покрытие для каждой строки кода.

По умолчанию такой анализ проводится только для текущего тестируемого пакета. Предположим, что есть структура:

/myapp
  |_ foo
      |_ foo.go
      |_ foo_test.go
  |_ bar
      |_ bar.go
      |_ bar_test.go

Если какая-то часть foo.go тестируется только в bar_test.go, по умолчанию она не будет отображаться в отчете о покрытии каким-либо тестом. Чтобы получить эту информацию, мы должны находиться в папке myapp и использовать флаг -coverpkg:

go test -coverpkg=./... -coverprofile=coverage.out ./...

Помните о такой возможности, чтобы увидеть текущее покрытие кода и решить, какие части заслуживают большего числа тестов.

ПРИМЕЧАНИЕ: Будьте бдительны, когда речь идет о погоне за покрытием кода. Стопроцентное покрытие тестами не означает, что приложение не содержит ошибок. Правильное понимание того, что покрывают тесты, важнее любого статического порогового значения.

11.9.2 Testing from a different package

Один из подходов к написанию юнит-тестов состоит в том, чтобы сосредоточиться на поведении, а не на внутреннем устройстве. Предположим, мы предоставляем клиентам какой-то API и хотим, чтобы тесты были направлены на те его аспекты, которые видны извне, а не на детали его реализации. Таким образом, если реализация изменится (например, если мы разделим какую-то одну функцию на две), тесты останутся прежними. Их будет легче понимать, потому что они показывают, как используется наш API. Чтобы применить такой подход, нужно использовать другой пакет.

В Go все файлы в папке должны принадлежать одному и тому же пакету, за одним исключением: тестовый файл может принадлежать пакету _test. В этом случае тест реализован во внешнем пакете и не может получить доступ к внутренним компонентам. Используя подобный подход, мы гарантируем, что в тесте не будут использоваться неэкспортированные элементы. Следовательно, он сосредоточится на тестировании открытого поведения.

11.9.3 Utility functions

При написании тестов мы можем обрабатывать ошибки иначе, чем в окончательном варианте кода, который используется в продакшене.

11.9.4 Setup and teardown

В некоторых случаях может потребоваться подготовить тестовую среду. Например, в интеграционных тестах мы запускаем определенный контейнер Docker, а затем останавливаем его. Мы можем вызывать функции настройки и демонтажа для каждого теста или пакета. К счастью, в Go возможны оба варианта. Чтобы делать это для каждого теста, вызываем функцию настройки как предварительное действие, а функцию демонтажа с помощью defer:

func TestMySQLIntegration(t *testing.T) {
    setupMySQL()
    defer teardownMySQL()
    // ...
}

Также можно зарегистрировать функцию, которая будет выполняться в конце теста. Предположим, что функции TestMySQLIntegration нужно вызвать createConnection для установления подключения к базе данных. Если мы хотим, чтобы эта функция также включала часть, относящуюся к демонтажу, мы можем использовать t.Cleanup для регистрации функции очистки:

func TestMySQLIntegration(t *testing.T) {
    // ...
    db := createConnection(t, "tcp(localhost:3306)/db")
    // ...
}

func createConnection(t *testing.T, dsn string) *sql.DB {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        t.FailNow()
    }
    t.Cleanup( // Регистрация функции, которая будет выполняться в конце теста
        func() {
            _ = db.Close()
        })
    return db
}

В конце теста выполняется замыкание из t.Cleanup. Это упрощает написание будущих юнит-тестов, потому что они не будут отвечать за закрытие переменной db. Обратите внимание, что мы можем зарегистрировать несколько функций очистки. В этом случае они будут выполняться так же, как если бы мы использовали defer: пришедший последним выходит первым. Чтобы выполнять настройку и демонтаж каждого пакета, используйте функцию TestMain. Самая простая реализация TestMain выглядит так:

func TestMain(m *testing.M) {
    os.Exit(m.Run())
}

Эта функция принимает аргумент *testing.M, который предоставляет единственный метод Run для запуска всех тестов. Поэтому мы можем окружить этот вызов функциями настройки и демонтажа:

func TestMain(m *testing.M) {
    setupMySQL() // Установка MySQL
    code := m.Run() // Проведение тестов
    teardownMySQL() // Демонтаж MySQL
    os.Exit(code)
}

Этот код запускает MySQL один раз перед всеми тестами, а затем демонтирует его.

Используя эти методы для добавления функций настройки и демонтажа, можно настраивать сложную среду для проведения тестов.

Summary

  • Категоризация тестов с помощью флагов сборки, переменных среды или короткого режима делает процесс тестирования более эффективным. Создавайте категории тестов, используя флаги сборки или переменные среды (например, категории интеграционных и юнит-тестов), и разграничивайте короткие и длительные тесты, чтобы решить, какие выполнять.
  • Включение флага -race настоятельно рекомендуется при написании конкурентных приложений. Это позволит выявлять потенциальные гонки данных, которые могут приводить к ошибкам в программах.
  • Использование флага -parallel — эффективный способ ускорить тесты, особенно длительные.
  • Используйте флаг -shuffle, чтобы убедиться, что набор тестов не опирается на неверные предположения, которые скрывают ошибки.
  • Табличные тесты — эффективный способ сгруппировать набор похожих тестов. Так вы предотвратите дублирование кода и упростите работу с будущими обновлениями.
  • Избегайте задержек с помощью синхронизации — это сделает тест более стабильным и надежным. Если синхронизация невозможна, рассмотрите подход с повторными попытками.
  • Понимание того, как работать с функциями с помощью API времени, — это еще один способ сделать тест более надежным. Используйте стандартные методы: работу со временем как часть скрытой зависимости — или запрашивайте его у клиентов.
  • Пакет httptest полезен для работы с HTTP-приложениями. Он предоставляет набор утилит для тестирования как клиентов, так и серверов.
  • Пакет iotest помогает написать io.Reader и проверить, что приложение устойчиво к ошибкам.
  • Бенчмарки:
    • Используйте методы time, чтобы обеспечить точность бенчмарка.
    • Увеличение benchtime или использование таких инструментов, как benchstat, может быть полезным при работе с микробенчмарками.
    • Следует с осторожностью подходить к результатам микробенчмарков, если система, где должно работать приложение, отличается от той, на которой выполняются микробенчмарки.
    • Убедитесь, что у тестируемой функции нет каких-либо побочных эффектов, чтобы оптимизации компилятора не ввели вас в заблуждение относительно результатов бенчмарка.
    • Чтобы предотвратить эффект наблюдателя, сделайте так, чтобы бенчмарк повторно создавал данные, используемые функцией, которая интенсивно потребляет ресурсы процессора.
  • Используйте флаг -coverprofile, чтобы быстро увидеть, какая часть кода требует большего внимания.
  • Соберите юнит-тесты в другом пакете, чтобы они фокусировались на анализе и измерении параметров поведения приложения, а не на его внутреннем устройстве.
  • Обработка ошибок с помощью переменной *testing.T вместо классического if err!= nil делает код более удобочитаемым.
  • Для настройки сложной тестовой среды используйте функции настройки и демонтажа, например, в случае интеграционных тестов.