Uber Go Style Guide
Руководство по стилю Uber для Go
Оригинал: https://github.com/uber-go/guide/blob/master/style.md
- Введение
- Рекомендации
- Указатели на интерфейсы
- Проверка соответствия интерфейсу
- Получатели и интерфейсы
- Нулевые значения мьютексов допустимы
- Копируйте срезы и карты на границах
- Используйте
deferдля очистки - Размер канала — один или ноль
- Начинайте перечисления с единицы
- Используйте
"time"для работы со временем - Ошибки
- Обрабатывайте сбои утверждения типа
- Не паникуйте
- Используйте go.uber.org/atomic
- Избегайте изменяемых глобальных переменных
- Избегайте встраивания типов в публичные структуры
- Избегайте использования встроенных имён
- Избегайте
init() - Завершение программы в main
- Используйте теги полей в структурах для сериализации
- Не запускайте горутины по принципу «запустил и забыл»
- Производительность
- Стиль
- Избегайте слишком длинных строк
- Будьте последовательны
- Группируйте схожие объявления
- Порядок групп импорта
- Имена пакетов
- Имена функций
- Псевдонимы импорта
- Группировка и порядок функций
- Уменьшайте вложенность
- Избыточный Else
- Объявления переменных верхнего уровня
- Префикс _ для неэкспортируемых глобальных переменных
- Встраивание в структурах
- Объявление локальных переменных
- nil — это валидный срез
- Уменьшайте область видимости переменных
- Избегайте «голых» параметров
- Используйте сырые строковые литералы, чтобы избежать экранирования
- Инициализация структур
- Инициализация карт
- Строки формата вне Printf
- Именование функций в стиле Printf
- Паттерны
- Линтинг
Введение
Стили — это соглашения, которые регулируют наш код. Термин «стиль» немного
неудачен, поскольку эти соглашения охватывают гораздо больше, чем просто
форматирование исходных файлов — этим занимается gofmt.
Цель этого руководства — управлять этой сложностью, подробно описывая, что следует и чего не следует делать при написании кода на Go в Uber. Эти правила существуют, чтобы кодовая база оставалась управляемой, позволяя при этом инженерам эффективно использовать возможности языка Go.
Первоначально это руководство было создано Прашантом Варанаси и Саймоном Ньютоном как способ познакомить некоторых коллег с использованием Go. С годами оно было дополнено на основе отзывов других.
В этом документе описаны идиоматические соглашения в коде на Go, которым мы следуем в Uber. Многие из них являются общими рекомендациями для Go, в то время как другие расширяют внешние ресурсы:
Мы стремимся к тому, чтобы примеры кода были корректны для двух последних минорных версий релизов Go.
Весь код должен быть свободен от ошибок при проверке с помощью golint и go vet. Мы рекомендуем настроить ваш редактор так, чтобы он:
- Запускал
goimportsпри сохранении - Запускал
golintиgo vetдля проверки на ошибки
Информацию о поддержке инструментов Go в редакторах можно найти здесь: https://go.dev/wiki/IDEsAndTextEditorPlugins
Рекомендации
Указатели на интерфейсы
Почти никогда не нужен указатель на интерфейс. Следует передавать интерфейсы как значения — базовые данные при этом всё равно могут быть указателем.
Интерфейс состоит из двух полей:
- Указатель на информацию, специфичную для типа. Можно думать об этом как о «типе».
- Указатель на данные. Если хранимые данные являются указателем, они сохраняются напрямую. Если хранимые данные являются значением, то сохраняется указатель на это значение.
Если нужно, чтобы методы интерфейса изменяли базовые данные, необходимо использовать указатель.
Проверка соответствия интерфейсу
Проверяйте соответствие интерфейсу на этапе компиляции там, где это уместно. Это включает:
- Экспортируемые типы, которые должны реализовывать определённые интерфейсы как часть своего API-контракта
- Экспортируемые или неэкспортируемые типы, которые являются частью коллекции типов, реализующих один и тот же интерфейс
- Другие случаи, когда нарушение интерфейса приведёт к поломке пользователей
| Плохо | Хорошо |
|---|---|
|
|
Выражение var _ http.Handler = (*Handler)(nil) не скомпилируется, если
*Handler перестанет соответствовать интерфейсу http.Handler.
Правая часть присваивания должна быть нулевым значением утверждаемого типа. Это
nil для типов-указателей (например, *Handler), срезов и карт, и пустая
структура для типов-структур.
type LogHandler struct {
h http.Handler
log *zap.Logger
}
var _ http.Handler = LogHandler{}
func (h LogHandler) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
// ...
}
Получатели и интерфейсы
Методы со значением-получателем могут вызываться как на указателях, так и на значениях. Методы с указателем-получателем могут вызываться только на указателях или адресуемых значениях.
Например,
type S struct {
data string
}
func (s S) Read() string {
return s.data
}
func (s *S) Write(str string) {
s.data = str
}
// Мы не можем получить указатели на значения, хранящиеся в картах, потому что они не являются адресуемыми значениями.
sVals := map[int]S{1: {"A"}}
// Мы можем вызвать Read для значений, хранящихся в карте, потому что Read имеет получатель-значение, который не требует, чтобы значение было адресуемым.
sVals[1].Read()
// Мы не можем вызвать Write для значений, хранящихся в карте, потому что Write имеет получатель-указатель, и невозможно получить указатель на значение, хранящееся в карте.
//
// sVals[1].Write("test")
sPtrs := map[int]*S{1: {"A"}}
// Вы можете вызвать как Read, так и Write, если карта хранит указатели, потому что указатели по своей природе адресуемы.
sPtrs[1].Read()
sPtrs[1].Write("test")
Аналогично, интерфейс может быть удовлетворён указателем, даже если метод имеет получатель-значение.
type F interface {
f()
}
type S1 struct{}
func (s S1) f() {}
type S2 struct{}
func (s *S2) f() {}
s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}
var i F
i = s1Val
i = s1Ptr
i = s2Ptr
// Следующее не скомпилируется, так как s2Val является значением, и для f нет получателя-значения.
// i = s2Val
В Effective Go есть хорошее описание по теме Указатели против значений.
Нулевые значения мьютексов допустимы
Нулевое значение sync.Mutex и sync.RWMutex является допустимым, поэтому
почти никогда не нужен указатель на мьютекс.
| Плохо | Хорошо |
|---|---|
|
|
Если вы используете структуру по указателю, то мьютекс должен быть не указателем, а полем в ней. Не встраивайте мьютекс в структуру, даже если структура не экспортируется.
| Плохо | Хорошо |
|---|---|
|
|
|
Поле |
Мьютекс и его методы являются деталями реализации |
Копируйте срезы и карты на границах
Срезы и карты содержат указатели на базовые данные, поэтому будьте осторожны в ситуациях, когда их нужно скопировать.
Получение срезов и карт
Помните, что пользователи могут изменить карту или срез, которые вы получили в качестве аргумента, если сохраните ссылку на них.
| Плохо | Хорошо |
|---|---|
|
|
Возврат срезов и карт
Аналогично, будьте осторожны с модификациями карт или срезов, раскрывающих внутреннее состояние.
| Плохо | Хорошо |
|---|---|
|
|
Используйте defer для очистки
Используйте defer для очистки ресурсов, таких как файлы и блокировки.
| Плохо | Хорошо |
|---|---|
|
|
defer имеет крайне малые накладные расходы, и его следует избегать только если
вы можете доказать, что время выполнения вашей функции измеряется в
наносекундах. Выигрыш в читаемости от использования defer стоит той мизерной
стоимости, которую он вносит. Это особенно верно для больших методов, где
присутствуют не только простые операции доступа к памяти, а другие вычисления
более значимы, чем defer.
Размер канала — один или ноль
Каналы обычно должны иметь размер один или быть небуферизированными. По умолчанию каналы небуферизированы и имеют размер ноль. Любой другой размер должен подвергаться тщательному анализу. Подумайте, как определяется размер, что предотвращает заполнение канала под нагрузкой и блокировку писателей, и что происходит, когда это случается.
| Плохо | Хорошо |
|---|---|
|
|
Начинайте перечисления с единицы
Стандартный способ введения перечислений в Go — объявление пользовательского
типа и группы const с iota. Поскольку переменные имеют значение по умолчанию
0, обычно следует начинать перечисления с ненулевого значения.
| Плохо | Хорошо |
|---|---|
|
|
Бывают случаи, когда использование нулевого значения имеет смысл, например, когда случай с нулевым значением является желаемым поведением по умолчанию.
type LogOutput int
const (
LogToStdout LogOutput = iota
LogToFile
LogToRemote
)
// LogToStdout=0, LogToFile=1, LogToRemote=2
Используйте "time" для работы со временем
Время — это сложно. Часто делаются неверные предположения о времени, включая следующие.
- В сутках 24 часа
- В часе 60 минут
- В неделе 7 дней
- В году 365 дней
- И многое другое
Например, 1 означает, что добавление 24 часов к моменту времени не всегда даст новый календарный день.
Поэтому всегда используйте пакет "time" при работе
со временем, так как он помогает безопаснее и точнее справляться с этими
неверными предположениями.
Используйте time.Time для моментов времени
Используйте time.Time при работе с моментами
времени и методы time.Time для сравнения, добавления или вычитания времени.
| Плохо | Хорошо |
|---|---|
|
|
Используйте time.Duration для промежутков времени
Используйте time.Duration при работе с
промежутками времени.
| Плохо | Хорошо |
|---|---|
|
|
Возвращаясь к примеру добавления 24 часов к моменту времени, метод, который мы
используем для добавления времени, зависит от намерения. Если мы хотим получить
то же время суток, но на следующий календарный день, следует использовать
Time.AddDate. Однако, если мы хотим
момент времени, гарантированно наступающий через 24 часа после предыдущего,
следует использовать Time.Add.
newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */)
maybeNewDay := t.Add(24 * time.Hour)
Используйте time.Time и time.Duration с внешними системами
По возможности используйте time.Duration и time.Time при взаимодействии с
внешними системами. Например:
- Флаги командной строки:
flagподдерживаетtime.Durationчерезtime.ParseDuration - JSON:
encoding/jsonподдерживает кодированиеtime.Timeкак строки RFC 3339 через свойUnmarshalJSONметод - SQL:
database/sqlподдерживает преобразование столбцовDATETIMEилиTIMESTAMPвtime.Timeи обратно, если базовый драйвер поддерживает это. - YAML:
gopkg.in/yaml.v2поддерживаетtime.Timeкак строку RFC 3339 иtime.Durationчерезtime.ParseDuration.
Если невозможно использовать time.Duration в этих взаимодействиях, используйте
int или float64 и включайте единицу измерения в имя поля.
Например, так как encoding/json не поддерживает time.Duration, единица
измерения включается в имя поля.
| Плохо | Хорошо |
|---|---|
|
|
Если невозможно использовать time.Time в этих взаимодействиях, если не
согласована альтернатива, используйте string и форматируйте временные метки в
соответствии с RFC 3339. Этот формат
используется по умолчанию в
Time.UnmarshalText и доступен
для использования в Time.Format и time.Parse через
time.RFC3339.
Хотя на практике это обычно не проблема, имейте в виду, что пакет "time" не
поддерживает разбор временных меток с високосными секундами
(8728), а также не учитывает
високосные секунды в вычислениях
(15190). Если вы сравниваете два
момента времени, разница не будет включать високосные секунды, которые могли
произойти между этими моментами.
Ошибки
Типы ошибок
Есть несколько вариантов объявления ошибок. Рассмотрите следующее, прежде чем выбрать вариант, наиболее подходящий для вашего случая.
- Нужно ли вызывающей стороне сопоставлять ошибку, чтобы обработать её? Если
да, мы должны поддерживать функции
errors.Isилиerrors.Asпутём объявления переменной ошибки верхнего уровня или пользовательского типа. - Сообщение об ошибке — статическая строка или динамическая строка, требующая
контекстной информации? Для первого случая можно использовать
errors.New, но для второго необходимо использоватьfmt.Errorfили пользовательский тип ошибки. - Мы распространяем новую ошибку, возвращённую нижележащей функцией? Если да, см. раздел об обёртывании ошибок.
| Сопоставление ошибок? | Сообщение об ошибке | Рекомендация |
|---|---|---|
| Нет | статическое | errors.New |
| Нет | динамическое | fmt.Errorf |
| Да | статическое | переменная верхнего уровня с errors.New |
| Да | динамическое | пользовательский тип error |
Например, используйте errors.New для ошибки
со статической строкой. Экспортируйте эту ошибку как переменную, чтобы
поддерживать её сопоставление с errors.Is, если вызывающей стороне нужно
сопоставить и обработать эту ошибку.
| Без сопоставления ошибок | С сопоставлением ошибок |
|---|---|
|
|
Для ошибки с динамической строкой используйте
fmt.Errorf, если вызывающей стороне не нужно
её сопоставлять, и пользовательский error, если нужно.
| Без сопоставления ошибок | С сопоставлением ошибок |
|---|---|
|
|
Обратите внимание, что если вы экспортируете переменные или типы ошибок из пакета, они станут частью публичного API пакета.
Обёртывание ошибок
Есть три основных варианта распространения ошибок при неудачном вызове:
- вернуть исходную ошибку как есть
- добавить контекст с помощью
fmt.Errorfи глагола%w - добавить контекст с помощью
fmt.Errorfи глагола%v
Возвращайте исходную ошибку как есть, если нечего добавить к контексту. Это сохраняет исходный тип и сообщение ошибки. Это хорошо подходит для случаев, когда базовое сообщение об ошибке содержит достаточно информации для отслеживания её происхождения.
В противном случае добавляйте контекст к сообщению об ошибке, где это возможно, чтобы вместо расплывчатой ошибки вроде “connection refused” вы получали более полезные ошибки, такие как “call service foo: connection refused”.
Используйте fmt.Errorf для добавления контекста к вашим ошибкам, выбирая между
глаголами %w или %v в зависимости от того, должна ли вызывающая сторона
иметь возможность сопоставить и извлечь базовую причину.
- Используйте
%w, если вызывающая сторона должна иметь доступ к базовой ошибке. Это хороший вариант по умолчанию для большинства обёрнутых ошибок, но имейте в виду, что вызывающие стороны могут начать полагаться на это поведение. Поэтому для случаев, когда обёрнутая ошибка является известнойvarили типом, документируйте и тестируйте это как часть контракта вашей функции. - Используйте
%v, чтобы скрыть базовую ошибку. Вызывающие стороны не смогут её сопоставить, но вы сможете переключиться на%wв будущем, если потребуется.
При добавлении контекста к возвращаемым ошибкам сохраняйте контекст кратким, избегая фраз типа “failed to”, которые констатируют очевидное и накапливаются по мере всплытия ошибки по стеку:
| Плохо | Хорошо |
|---|---|
|
|
|
|
Однако, как только ошибка отправляется в другую систему, должно быть понятно,
что сообщение является ошибкой (например, тег err или префикс “Failed” в
логах).
См. также Don’t just check errors, handle them gracefully.
Именование ошибок
Для значений ошибок, хранящихся как глобальные переменные, используйте префикс
Err или err в зависимости от того, экспортируются они или нет. Это указание
заменяет Префикс _ для неэкспортируемых глобальных
переменных.
var (
// Следующие две ошибки экспортируются, чтобы пользователи этого пакета могли сопоставлять их с errors.Is.
ErrBrokenLink = errors.New("link is broken")
ErrCouldNotOpen = errors.New("could not open")
// Эта ошибка не экспортируется, потому что мы не хотим делать её частью нашего публичного API. Мы всё ещё можем использовать её внутри пакета с errors.Is.
errNotFound = errors.New("not found")
)
Для пользовательских типов ошибок используйте суффикс Error вместо этого.
// Аналогично, эта ошибка экспортируется, чтобы пользователи этого пакета могли сопоставлять её с errors.As.
type NotFoundError struct {
File string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("file %q not found", e.File)
}
// А эта ошибка не экспортируется, потому что мы не хотим делать её частью публичного API. Мы всё ещё можем использовать её внутри пакета с errors.As.
type resolveError struct {
Path string
}
func (e *resolveError) Error() string {
return fmt.Sprintf("resolve %q", e.Path)
}
Обрабатывайте ошибки один раз
Когда вызывающая сторона получает ошибку от вызываемой функции, она может обработать её различными способами в зависимости от того, что она знает об ошибке.
К ним относятся, но не ограничиваются:
- если контракт вызываемой функции определяет конкретные ошибки, сопоставить
ошибку с
errors.Isилиerrors.Asи обработать ветви по-разному - если ошибка является восстанавливаемой, залогировать ошибку и выполнить graceful degradation
- если ошибка представляет собой отказ в предметной области, вернуть чётко определённую ошибку
- вернуть ошибку, либо обёрнутую, либо как есть
Независимо от того, как вызывающая сторона обрабатывает ошибку, обычно она должна обрабатывать каждую ошибку только один раз. Вызывающая сторона не должна, например, логировать ошибку, а затем возвращать её, потому что её вызывающие стороны тоже могут обработать ошибку.
Например, рассмотрим следующие случаи:
| Описание | Код |
|---|---|
|
Плохо: Логировать ошибку и возвращать её Вызывающие стороны выше по стеку, вероятно, предпримут аналогичные действия с ошибкой. Это приведёт к большому количеству шума в логах приложения при малой пользе. |
|
|
Хорошо: Обернуть ошибку и вернуть её Вызывающие стороны выше по стеку обработают ошибку. Использование |
|
|
Хорошо: Логировать ошибку и выполнить graceful degradation Если операция не является строго необходимой, мы можем обеспечить деградировавший, но работающий опыт, восстановившись после ошибки. |
|
|
Хорошо: Сопоставить ошибку и выполнить graceful degradation Если вызываемая функция определяет конкретную ошибку в своём контракте и сбой является восстанавливаемым, сопоставьте этот случай ошибки и выполните graceful degradation. Для всех остальных случаев оберните ошибку и верните её. Вызывающие стороны выше по стеку обработают другие ошибки. |
|
Обрабатывайте сбои утверждения типа
Форма утверждения типа с одним возвращаемым значением вызовет панику при неверном типе. Поэтому всегда используйте идиому “comma ok”.
| Плохо | Хорошо |
|---|---|
|
|
Не паникуйте
Код, работающий в production, должен избегать паник. Паники являются основной причиной каскадных сбоев. Если возникает ошибка, функция должна вернуть ошибку и позволить вызывающей стороне решить, как её обработать.
| Плохо | Хорошо |
|---|---|
|
|
Panic/recover — это не стратегия обработки ошибок. Программа должна паниковать только при возникновении чего-то невосстановимого, например, разыменовании nil. Исключением является инициализация программы: проблемы при запуске программы, которые должны её завершить, могут вызывать панику.
var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))
Даже в тестах предпочитайте t.Fatal или t.FailNow вместо паник, чтобы
гарантировать, что тест будет помечен как неудачный.
| Плохо | Хорошо |
|---|---|
|
|
Используйте go.uber.org/atomic
Атомарные операции с пакетом sync/atomic
работают с базовыми типами (int32, int64 и т.д.), поэтому легко забыть
использовать атомарную операцию для чтения или изменения переменных.
go.uber.org/atomic добавляет
безопасность типов к этим операциям, скрывая базовый тип. Кроме того, он
включает удобный тип atomic.Bool.
| Плохо | Хорошо |
|---|---|
|
|
Избегайте изменяемых глобальных переменных
Избегайте изменения глобальных переменных, отдавая предпочтение внедрению зависимостей. Это относится как к указателям на функции, так и к другим видам значений.
| Плохо | Хорошо |
|---|---|
|
|
|
|
Избегайте встраивания типов в публичные структуры
Такие встроенные типы раскрывают детали реализации, препятствуют эволюции типов и затрудняют чтение документации.
Предположим, вы реализовали различные типы списков, используя общий
AbstractList. Избегайте встраивания AbstractList в ваши конкретные
реализации списков. Вместо этого напишите вручную только методы вашего
конкретного списка, которые будут делегировать вызовы абстрактному списку.
type AbstractList struct {}
// Add добавляет сущность в список.
func (l *AbstractList) Add(e Entity) {
// ...
}
// Remove удаляет сущность из списка.
func (l *AbstractList) Remove(e Entity) {
// ...
}
| Плохо | Хорошо |
|---|---|
|
|
Go позволяет встраивание типов как компромисс между наследованием и композицией. Внешний тип получает неявные копии методов встроенного типа. Эти методы по умолчанию делегируют вызовы тому же методу встроенного экземпляра.
Структура также получает поле с тем же именем, что и тип. Таким образом, если встроенный тип является публичным, поле также является публичным. Для сохранения обратной совместимости каждая будущая версия внешнего типа должна сохранять встроенный тип.
Встраивание типа редко необходимо. Это удобство, которое помогает избежать написания утомительных делегирующих методов.
Даже встраивание совместимого интерфейса AbstractList вместо структуры дало бы
разработчику больше гибкости для изменений в будущем, но всё равно раскрыло бы
деталь, что конкретные списки используют абстрактную реализацию.
| Плохо | Хорошо |
|---|---|
|
|
И в случае со встроенной структурой, и со встроенным интерфейсом встроенный тип накладывает ограничения на эволюцию типа.
- Добавление методов во встроенный интерфейс — это breaking change.
- Удаление методов из встроенной структуры — это breaking change.
- Удаление встроенного типа — это breaking change.
- Замена встроенного типа, даже на альтернативу, удовлетворяющую тому же интерфейсу, — это breaking change.
Хотя написание этих делегирующих методов утомительно, дополнительные усилия
скрывают деталь реализации, оставляют больше возможностей для изменений, а также
устраняют косвенность при обнаружении полного интерфейса List в документации.
Избегайте использования встроенных имён
Спецификация языка Go описывает несколько встроенных, предобъявленных идентификаторов, которые не должны использоваться как имена в программах на Go.
В зависимости от контекста повторное использование этих идентификаторов в качестве имён либо затеняет оригинал в текущей лексической области видимости (и любых вложенных областях), либо делает затронутый код запутанным. В лучшем случае компилятор пожалуется; в худшем — такой код может привести к скрытым, трудноуловимым ошибкам.
| Плохо | Хорошо |
|---|---|
|
|
|
|
Обратите внимание, что компилятор не будет генерировать ошибки при использовании
предобъявленных идентификаторов, но такие инструменты, как go vet, должны
корректно указывать на эти и другие случаи затенения.
Избегайте init()
Избегайте init() там, где это возможно. Когда init() неизбежен или
желателен, код должен пытаться:
- Быть полностью детерминированным, независимо от среды программы или вызова.
- Избегать зависимости от порядка или побочных эффектов других функций
init(). Хотя порядокinit()хорошо известен, код может меняться, и поэтому зависимости между функциямиinit()могут сделать код хрупким и подверженным ошибкам. - Избегать доступа или манипуляции глобальным состоянием или состоянием окружения, таким как информация о машине, переменные окружения, рабочая директория, аргументы/вводы программы и т.д.
- Избегать ввода-вывода, включая файловую систему, сеть и системные вызовы.
Код, который не может удовлетворить этим требованиям, вероятно, должен быть
вспомогательной функцией, вызываемой как часть main() (или в другом месте
жизненного цикла программы), или быть написан как часть самого main(). В
частности, библиотеки, предназначенные для использования другими программами,
должны особенно тщательно следить за полной детерминированностью и не выполнять
“init magic”.
| Плохо | Хорошо |
|---|---|
|
|
|
|
Учитывая вышесказанное, некоторые ситуации, в которых init() может быть
предпочтительнее или необходим, включают:
- Сложные выражения, которые нельзя представить в виде одиночных присваиваний.
- Подключаемые хуки, такие как диалекты
database/sql, регистры типов кодирования и т.д. - Оптимизации для Google Cloud Functions и другие формы детерминированного предварительного вычисления.
Завершение программы в main
Программы на Go используют os.Exit или
log.Fatal* для немедленного завершения.
(Паника — нехороший способ завершения программ, пожалуйста, не
паникуйте.)
Вызывайте os.Exit или log.Fatal* только в main(). Все остальные
функции должны возвращать ошибки для сигнализации о сбое.
| Плохо | Хорошо |
|---|---|
|
|
Обоснование: Программы с несколькими функциями, которые завершают выполнение, создают несколько проблем:
- Неочевидный поток управления: любая функция может завершить программу, поэтому становится трудно рассуждать о потоке управления.
- Сложность тестирования: функция, завершающая программу, также завершит тест,
который её вызывает. Это делает функцию трудной для тестирования и создаёт
риск пропуска других тестов, которые ещё не были запущены
go test. - Пропущенная очистка: когда функция завершает программу, она пропускает вызовы
функций, поставленные в очередь с операторами
defer. Это добавляет риск пропуска важных задач очистки.
Завершайте программу один раз
По возможности старайтесь вызывать os.Exit или log.Fatal не более одного
раза в вашем main(). Если есть несколько сценариев ошибок, которые
останавливают выполнение программы, поместите эту логику в отдельную функцию и
возвращайте из неё ошибки.
Это приводит к сокращению функции main() и помещению всей ключевой
бизнес-логики в отдельную, тестируемую функцию.
| Плохо | Хорошо |
|---|---|
|
|
В примере выше используется log.Fatal, но рекомендация также применима к
os.Exit или любому библиотечному коду, который вызывает os.Exit.
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Вы можете изменить сигнатуру run(), чтобы она соответствовала вашим
потребностям. Например, если ваша программа должна завершаться с определёнными
кодами выхода при сбоях, run() может возвращать код выхода вместо ошибки. Это
позволяет модульным тестам также напрямую проверять это поведение.
func main() {
os.Exit(run(args))
}
func run() (exitCode int) {
// ...
}
Более общо, обратите внимание, что функция run(), используемая в этих
примерах, не является предписывающей. Существует гибкость в имени, сигнатуре и
настройке функции run(). Среди прочего, вы можете:
- принимать неразобранные аргументы командной строки (например,
run(os.Args[1:])) - разбирать аргументы командной строки в
main()и передавать их вrun - использовать пользовательский тип ошибки для передачи кода выхода обратно в
main() - помещать бизнес-логику на другой уровень абстракции, отличный от
package main
Эта рекомендация требует только, чтобы в вашем main() было единственное место,
ответственное за фактическое завершение процесса.
Используйте теги полей в структурах для сериализации
Любое поле структуры, которое сериализуется в JSON, YAML или другие форматы, поддерживающие именование полей на основе тегов, должно быть аннотировано соответствующим тегом.
| Плохо | Хорошо |
|---|---|
|
|
Обоснование: Сериализованная форма структуры — это контракт между различными системами. Изменения в структуре сериализованной формы — включая имена полей — нарушают этот контракт. Указание имён полей внутри тегов делает контракт явным и защищает от случайного его нарушения при рефакторинге или переименовании полей.
Не запускайте горутины по принципу «запустил и забыл»
Горутины легковесны, но не бесплатны: как минимум, они требуют памяти для своего стека и процессорного времени для планирования. Хотя эти затраты малы для типичного использования горутин, они могут вызвать значительные проблемы с производительностью, если горутины создаются в больших количествах без контролируемого времени жизни. Горутины с неуправляемым временем жизни также могут вызывать другие проблемы, например, мешать сборке мусора для неиспользуемых объектов и удерживать ресурсы, которые в противном случае больше не используются.
Поэтому не допускайте утечек горутин в production коде. Используйте go.uber.org/goleak для тестирования утечек горутин внутри пакетов, которые могут их создавать.
В общем случае каждая горутина:
- должна иметь предсказуемый момент, когда она перестанет выполняться; или
- должен быть способ сигнализировать горутине, что ей следует остановиться
В обоих случаях должен быть способ заблокировать выполнение и дождаться завершения горутины.
Например:
| Плохо | Хорошо |
|---|---|
|
|
|
Нет способа остановить эту горутину. Она будет работать, пока приложение не завершится. |
Эту горутину можно остановить с помощью |
Дожидайтесь завершения горутин
Для горутины, созданной системой, должен быть способ дождаться её завершения. Есть два популярных способа сделать это:
-
Используйте
sync.WaitGroup, чтобы дождаться завершения нескольких горутин. Делайте так, если нужно ждать несколько горутин.var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Go(...) } // Чтобы дождаться завершения всех: wg.Wait() -
Добавьте ещё один
chan struct{}, который горутина закроет по завершении. Делайте так, если есть только одна горутина.done := make(chan struct{}) go func() { defer close(done) // ... }() // Чтобы дождаться завершения горутины: <-done
Не используйте горутины в init()
Функции init() не должны запускать горутины. См. также Избегайте
init().
Если пакету нужна фоновая горутина, он должен предоставлять объект,
ответственный за управление временем жизни горутины. Этот объект должен
предоставлять метод (Close, Stop, Shutdown и т.д.), который сигнализирует
фоновой горутине об остановке и ждёт её завершения.
| Плохо | Хорошо |
|---|---|
|
|
|
Создаёт фоновую горутину безусловно при экспорте этого пакета пользователем. Пользователь не имеет контроля над горутиной или способа её остановки. |
Создаёт воркера только если пользователь его запрашивает. Предоставляет способ остановки воркера, чтобы пользователь мог освободить используемые им ресурсы. Обратите внимание, что следует использовать |
Производительность
Рекомендации, специфичные для производительности, применяются только к «горячему пути» (hot path).
Предпочитайте strconv вместо fmt
При преобразовании примитивов в строки и обратно strconv быстрее, чем fmt.
| Плохо | Хорошо |
|---|---|
|
|
|
|
Избегайте повторного преобразования строк в байты
Не создавайте срезы байт из фиксированной строки повторно. Вместо этого выполните преобразование один раз и сохраните результат.
| Плохо | Хорошо |
|---|---|
|
|
|
|
Предпочитайте указание ёмкости контейнеров
По возможности указывайте ёмкость контейнеров, чтобы выделить память для контейнера заранее. Это минимизирует последующие выделения памяти (из-за копирования и изменения размера контейнера) при добавлении элементов.
Указание подсказки ёмкости для карт
По возможности предоставляйте подсказку ёмкости при инициализации карт с помощью
make().
make(map[T1]T2, hint)
Предоставление подсказки ёмкости для make() пытается правильно определить
размер карты при инициализации, что уменьшает необходимость её роста и выделений
памяти при добавлении элементов.
Обратите внимание, что в отличие от срезов, подсказки ёмкости для карт не гарантируют полного, упреждающего выделения, а используются для приблизительного определения количества необходимых корзин хэш-карты. Следовательно, выделения памяти всё ещё могут происходить при добавлении элементов в карту, даже до указанной ёмкости.
| Плохо | Хорошо |
|---|---|
|
|
|
|
|
Указание ёмкости срезов
По возможности предоставляйте подсказку ёмкости при инициализации срезов с
помощью make(), особенно при использовании append.
make([]T, length, capacity)
В отличие от карт, ёмкость среза — это не подсказка: компилятор выделит
достаточно памяти для ёмкости среза, предоставленной в make(), что означает,
что последующие операции append() не будут приводить к выделениям памяти (пока
длина среза не совпадёт с ёмкостью, после чего любые добавления потребуют
изменения размера для хранения дополнительных элементов).
| Плохо | Хорошо |
|---|---|
|
|
|
|
Стиль
Избегайте слишком длинных строк
Избегайте строк кода, которые заставляют читателей прокручивать по горизонтали или слишком сильно поворачивать голову.
Мы рекомендуем мягкое ограничение длины строки в 99 символов. Авторы должны стараться переносить строки до достижения этого предела, но это не строгое ограничение. Коду разрешено превышать этот лимит.
Будьте последовательны
Некоторые рекомендации, изложенные в этом документе, можно оценить объективно; другие ситуативны, контекстны или субъективны.
Прежде всего, будьте последовательны.
Последовательный код легче поддерживать, легче осмыслить, требует меньше когнитивных усилий и легче переносить или обновлять по мере появления новых соглашений или исправления классов ошибок.
И наоборот, наличие множества различных или конфликтующих стилей в одной кодовой базе создаёт накладные расходы на поддержку, неопределённость и когнитивный диссонанс, что напрямую может способствовать снижению скорости разработки, болезненным код-ревью и ошибкам.
При применении этих рекомендаций к кодовой базе рекомендуется вносить изменения на уровне пакета (или выше): применение на уровне подпакета нарушает указанную выше проблему, внося несколько стилей в один код.
Группируйте схожие объявления
Go поддерживает группировку схожих объявлений.
| Плохо | Хорошо |
|---|---|
|
|
Это также относится к константам, переменным и объявлениям типов.
| Плохо | Хорошо |
|---|---|
|
|
Группируйте только связанные объявления. Не группируйте несвязанные объявления.
| Плохо | Хорошо |
|---|---|
|
|
Группы не ограничены в том, где могут использоваться. Например, их можно использовать внутри функций.
| Плохо | Хорошо |
|---|---|
|
|
Исключение: Объявления переменных, особенно внутри функций, должны группироваться вместе, если они объявлены рядом с другими переменными. Делайте так для переменных, объявленных вместе, даже если они не связаны.
| Плохо | Хорошо |
|---|---|
|
|
Порядок групп импорта
Должно быть две группы импорта:
- Стандартная библиотека
- Все остальные
Это группировка, применяемая по умолчанию в goimports.
| Плохо | Хорошо |
|---|---|
|
|
Имена пакетов
При именовании пакетов выбирайте имя, которое:
- Состоит только из строчных букв. Без заглавных букв и подчёркиваний.
- Не требует переименования с использованием именованных импортов в большинстве мест вызова.
- Короткое и ёмкое. Помните, что имя полностью указывается в каждом месте вызова.
- Не во множественном числе. Например,
net/url, а неnet/urls. - Не “common”, “util”, “shared” или “lib”. Это плохие, неинформативные имена.
См. также Package Names и Style guideline for Go packages.
Имена функций
Мы следуем соглашению сообщества Go об использовании MixedCaps для имён
функций. Исключение делается для
тестовых функций, которые могут содержать подчёркивания для группировки
связанных тестовых случаев, например, TestMyFunction_WhatIsBeingTested.
Псевдонимы импорта
Псевдонимы импорта должны использоваться, если имя пакета не совпадает с последним элементом пути импорта.
import (
"net/http"
client "example.com/client-go"
trace "example.com/trace/v2"
)
Во всех остальных сценариях псевдонимы импорта следует избегать, если нет прямого конфликта между импортами.
| Плохо | Хорошо |
|---|---|
|
|
Группировка и порядок функций
- Функции должны быть отсортированы в приблизительном порядке вызовов.
- Функции в файле должны быть сгруппированы по получателю.
Следовательно, экспортируемые функции должны появляться первыми в файле после
определений struct, const, var.
newXYZ()/NewXYZ() может появиться после определения типа, но до остальных
методов получателя.
Поскольку функции группируются по получателю, простые вспомогательные функции должны появляться ближе к концу файла.
| Плохо | Хорошо |
|---|---|
|
|
Уменьшайте вложенность
Код должен уменьшать вложенность, где это возможно, обрабатывая случаи ошибок/особые условия первыми и возвращаясь рано или продолжая цикл. Уменьшайте количество кода, вложенного на несколько уровней.
| Плохо | Хорошо |
|---|---|
|
|
Избыточный Else
Если переменная устанавливается в обеих ветках if, это можно заменить одним if.
| Плохо | Хорошо |
|---|---|
|
|
Объявления переменных верхнего уровня
На верхнем уровне используйте стандартное ключевое слово var. Не указывайте
тип, если он не отличается от типа выражения.
| Плохо | Хорошо |
|---|---|
|
|
Указывайте тип, если тип выражения не совпадает точно с желаемым типом.
type myError struct{}
func (myError) Error() string { return "error" }
func F() myError { return myError{} }
var _e error = F()
// F возвращает объект типа myError, но мы хотим error.
Префикс _ для неэкспортируемых глобальных переменных
Добавляйте префикс _ к неэкспортируемым переменным и константам верхнего
уровня, чтобы было ясно, что они являются глобальными символами, когда они
используются.
Обоснование: Переменные и константы верхнего уровня имеют область видимости пакета. Использование общего имени делает лёгким случайное использование неправильного значения в другом файле.
| Плохо | Хорошо |
|---|---|
|
|
Исключение: Неэкспортируемые значения ошибок могут использовать префикс
err без подчёркивания. См. Именование ошибок.
Встраивание в структурах
Встроенные типы должны находиться в начале списка полей структуры, и должна быть пустая строка, отделяющая встроенные поля от обычных полей.
| Плохо | Хорошо |
|---|---|
|
|
Встраивание должно обеспечивать ощутимую пользу, например, добавлять или расширять функциональность семантически-уместным способом. Оно должно делать это без каких-либо негативных последствий для пользователя (см. также: Избегайте встраивания типов в публичные структуры).
Исключение: Мьютексы не должны встраиваться, даже в неэкспортируемые типы. См. также: Нулевые значения мьютексов допустимы.
Встраивание НЕ должно:
- Быть чисто косметическим или ориентированным на удобство.
- Усложнять создание или использование внешних типов.
- Влиять на нулевые значения внешних типов. Если внешний тип имеет полезное нулевое значение, он должен сохранять его после встраивания внутреннего типа.
- Раскрывать несвязанные функции или поля внешнего типа как побочный эффект встраивания внутреннего типа.
- Раскрывать неэкспортируемые типы.
- Влиять на семантику копирования внешних типов.
- Менять API или семантику типов внешних типов.
- Встраивать неканоническую форму внутреннего типа.
- Раскрывать детали реализации внешнего типа.
- Позволять пользователям наблюдать или контролировать внутренности типа.
- Менять общее поведение внутренних функций через обёртывание таким образом, который может удивить пользователей.
Проще говоря, встраивайте осознанно и преднамеренно. Хороший тест: “все ли эти экспортируемые внутренние методы/поля были бы добавлены напрямую к внешнему типу”; если ответ “некоторые” или “нет”, не встраивайте внутренний тип — используйте поле.
| Плохо | Хорошо |
|---|---|
|
|
|
|
|
|
Объявление локальных переменных
Короткое объявление переменных (:=) должно использоваться, если переменная
явно устанавливается в некоторое значение.
| Плохо | Хорошо |
|---|---|
|
|
Однако бывают случаи, когда значение по умолчанию понятнее при использовании
ключевого слова var. Объявление пустых
срезов,
например.
| Плохо | Хорошо |
|---|---|
|
|
nil — это валидный срез
nil — это валидный срез длины 0. Это означает, что:
-
Не следует явно возвращать срез длины ноль. Возвращайте
nilвместо этого.Плохо Хорошо if x == "" { return []int{} }if x == "" { return nil } -
Чтобы проверить, пуст ли срез, всегда используйте
len(s) == 0. Не проверяйте наnil.Плохо Хорошо func isEmpty(s []string) bool { return s == nil }func isEmpty(s []string) bool { return len(s) == 0 } -
Нулевое значение (срез, объявленный с
var) можно использовать сразу безmake().Плохо Хорошо nums := []int{} // или, nums := make([]int) if add1 { nums = append(nums, 1) } if add2 { nums = append(nums, 2) }var nums []int if add1 { nums = append(nums, 1) } if add2 { nums = append(nums, 2) }Помните, что хотя nil-срез является валидным срезом, он не эквивалентен выделенному срезу длины 0 — один является nil, а другой нет — и они могут обрабатываться по-разному в разных ситуациях (например, при сериализации).
Уменьшайте область видимости переменных
По возможности уменьшайте область видимости переменных и констант. Не уменьшайте область видимости, если это противоречит Уменьшению вложенности.
Плохо Хорошо err := os.WriteFile(name, data, 0644) if err != nil { return err }if err := os.WriteFile(name, data, 0644); err != nil { return err }Если вам нужен результат вызова функции вне if, то не следует пытаться уменьшить область видимости.
Плохо Хорошо if data, err := os.ReadFile(name); err == nil { err = cfg.Decode(data) if err != nil { return err } fmt.Println(cfg) return nil } else { return err }data, err := os.ReadFile(name) if err != nil { return err } if err := cfg.Decode(data); err != nil { return err } fmt.Println(cfg) return nilКонстантам не нужно быть глобальными, если они не используются в нескольких функциях или файлах или являются частью внешнего контракта пакета.
Плохо Хорошо const ( _defaultPort = 8080 _defaultUser = "user" ) func Bar() { fmt.Println("Default port", _defaultPort) }func Bar() { const ( defaultPort = 8080 defaultUser = "user" ) fmt.Println("Default port", defaultPort) }Избегайте «голых» параметров
«Голые» параметры в вызовах функций могут ухудшать читаемость. Добавляйте комментарии в стиле C (
/* ... */) для имён параметров, когда их значение неочевидно.Плохо Хорошо // func printInfo(name string, isLocal, done bool) printInfo("foo", true, true)// func printInfo(name string, isLocal, done bool) printInfo("foo", true /* isLocal */, true /* done */)Ещё лучше заменить «голые» типы
boolпользовательскими типами для более читаемого и типобезопасного кода. Это позволяет в будущем иметь более двух состояний (true/false) для этого параметра.type Region int const ( UnknownRegion Region = iota Local ) type Status int const ( StatusReady Status = iota + 1 StatusDone // Возможно, в будущем у нас будет StatusInProgress. ) func printInfo(name string, region Region, status Status)Используйте сырые строковые литералы, чтобы избежать экранирования
Go поддерживает сырые строковые литералы, которые могут занимать несколько строк и включать кавычки. Используйте их, чтобы избежать ручного экранирования строк, которое гораздо труднее читать.
Плохо Хорошо wantError := "unknown name:\"test\""wantError := `unknown error:"test"`Инициализация структур
Используйте имена полей для инициализации структур
Вы почти всегда должны указывать имена полей при инициализации структур. Теперь это обеспечивается
go vet.Плохо Хорошо k := User{"John", "Doe", true}k := User{ FirstName: "John", LastName: "Doe", Admin: true, }Исключение: Имена полей могут быть опущены в таблицах тестов, когда полей 3 или меньше.
tests := []struct{ op Operation want string }{ {Add, "add"}, {Subtract, "subtract"}, }Опускайте поля с нулевыми значениями в структурах
При инициализации структур с именами полей опускайте поля, имеющие нулевые значения, если они не предоставляют значимый контекст. В противном случае позвольте Go автоматически установить их в нулевые значения.
Плохо Хорошо user := User{ FirstName: "John", LastName: "Doe", MiddleName: "", Admin: false, }user := User{ FirstName: "John", LastName: "Doe", }Это помогает уменьшить шум для читателей, опуская значения, которые являются стандартными в данном контексте. Указываются только значимые значения.
Включайте нулевые значения, когда имена полей предоставляют значимый контекст. Например, тестовые случаи в Табличных тестах могут выиграть от указания имён полей, даже когда они имеют нулевые значения.
tests := []struct{ give string want int }{ {give: "0", want: 0}, // ... }Используйте
varдля структур с нулевыми значениямиКогда все поля структуры опущены в объявлении, используйте форму
varдля объявления структуры.Плохо Хорошо user := User{}var user UserЭто отличает структуры с нулевыми значениями от тех, у которых есть ненулевые поля, аналогично различию, создаваемому для инициализации карт, и соответствует тому, как мы предпочитаем объявлять пустые срезы.
Инициализация ссылок на структуры
Используйте
&T{}вместоnew(T)при инициализации ссылок на структуры, чтобы это было согласовано с инициализацией структур.Плохо Хорошо sval := T{Name: "foo"} // несогласованно sptr := new(T) sptr.Name = "bar"sval := T{Name: "foo"} sptr := &T{Name: "bar"}Инициализация карт
Предпочитайте
make(..)для пустых карт и карт, заполняемых программно. Это делает инициализацию карт визуально отличной от объявления и позволяет легко добавить подсказку размера позже, если она доступна.Плохо Хорошо var ( // m1 безопасна для чтения и записи; // m2 вызовет панику при записи. m1 = map[T1]T2{} m2 map[T1]T2 )var ( // m1 безопасна для чтения и записи; // m2 вызовет панику при записи. m1 = make(map[T1]T2) m2 map[T1]T2 )Объявление и инициализация визуально похожи.
Объявление и инициализация визуально различны.
По возможности предоставляйте подсказку ёмкости при инициализации карт с помощью
make(). См. Указание подсказки ёмкости для карт для получения дополнительной информации.С другой стороны, если карта содержит фиксированный список элементов, используйте литералы карт для её инициализации.
Плохо Хорошо m := make(map[T1]T2, 3) m[k1] = v1 m[k2] = v2 m[k3] = v3m := map[T1]T2{ k1: v1, k2: v2, k3: v3, }Основное правило — использовать литералы карт при добавлении фиксированного набора элементов во время инициализации, в противном случае используйте
make(и указывайте подсказку размера, если доступна).Строки формата вне Printf
Если вы объявляете строки формата для функций в стиле
Printfвне строкового литерала, сделайте их значениямиconst.Это помогает
go vetвыполнять статический анализ строки формата.Плохо Хорошо msg := "unexpected values %v, %v\n" fmt.Printf(msg, 1, 2)const msg = "unexpected values %v, %v\n" fmt.Printf(msg, 1, 2)Именование функций в стиле Printf
Когда вы объявляете функцию в стиле
Printf, убедитесь, чтоgo vetможет её обнаружить и проверить строку формата.Это означает, что следует использовать предопределённые имена функций в стиле
Printf, если это возможно.go vetпроверяет их по умолчанию. См. Printf family для получения дополнительной информации.Если использование предопределённых имён невозможно, заканчивайте выбранное вами имя на f:
Wrapf, а неWrap.go vetможно попросить проверять определённые имена в стилеPrintf, но они должны заканчиваться на f.go vet -printfuncs=wrapf,statusfСм. также go vet: Printf family check.
Паттерны
Табличные тесты
Табличные тесты с подтестами могут быть полезным паттерном для написания тестов, чтобы избежать дублирования кода, когда основная тестовая логика повторяется.
Если тестируемую систему нужно проверить на соответствие нескольким условиям, где определённые части входных и выходных данных меняются, следует использовать табличные тесты, чтобы уменьшить избыточность и улучшить читаемость.
Плохо Хорошо // func TestSplitHostPort(t *testing.T) host, port, err := net.SplitHostPort("192.0.2.0:8000") require.NoError(t, err) assert.Equal(t, "192.0.2.0", host) assert.Equal(t, "8000", port) host, port, err = net.SplitHostPort("192.0.2.0:http") require.NoError(t, err) assert.Equal(t, "192.0.2.0", host) assert.Equal(t, "http", port) host, port, err = net.SplitHostPort(":8000") require.NoError(t, err) assert.Equal(t, "", host) assert.Equal(t, "8000", port) host, port, err = net.SplitHostPort("1:8") require.NoError(t, err) assert.Equal(t, "1", host) assert.Equal(t, "8", port)// func TestSplitHostPort(t *testing.T) tests := []struct{ give string wantHost string wantPort string }{ { give: "192.0.2.0:8000", wantHost: "192.0.2.0", wantPort: "8000", }, { give: "192.0.2.0:http", wantHost: "192.0.2.0", wantPort: "http", }, { give: ":8000", wantHost: "", wantPort: "8000", }, { give: "1:8", wantHost: "1", wantPort: "8", }, } for _, tt := range tests { t.Run(tt.give, func(t *testing.T) { host, port, err := net.SplitHostPort(tt.give) require.NoError(t, err) assert.Equal(t, tt.wantHost, host) assert.Equal(t, tt.wantPort, port) }) }Табличные тесты облегчают добавление контекста к сообщениям об ошибках, уменьшают дублирование логики и позволяют добавлять новые тестовые случаи.
Мы следуем соглашению, что срез структур называется
tests, а каждый тестовый случай —tt. Кроме того, мы рекомендуем явно указывать входные и выходные значения для каждого тестового случая с префиксамиgiveиwant.tests := []struct{ give string wantHost string wantPort string }{ // ... } for _, tt := range tests { // ... }Избегайте излишней сложности в табличных тестах
Табличные тесты могут быть трудны для чтения и поддержки, если подтесты содержат условные проверки или другую разветвлённую логику. Табличные тесты НЕ ДОЛЖНЫ использоваться всякий раз, когда внутри подтестов требуется сложная или условная логика (т.е. сложная логика внутри цикла
for).Большие, сложные табличные тесты ухудшают читаемость и поддерживаемость, потому что читателям тестов может быть трудно отлаживать возникающие сбои тестов.
Такие табличные тесты следует разделить либо на несколько таблиц тестов, либо на несколько отдельных функций
Test....К некоторым идеалам, к которым стоит стремиться, относятся:
- Фокусировка на самой узкой единице поведения
- Минимизация «глубины теста» и избегание условных проверок (см. ниже)
- Обеспечение того, что все поля таблицы используются во всех тестах
- Обеспечение того, что вся тестовая логика выполняется для всех случаев таблицы
В этом контексте «глубина теста» означает «внутри данного теста, количество последовательных проверок, требующих выполнения предыдущих проверок» (аналогично цикломатической сложности). Наличие «более мелких» тестов означает меньше связей между проверками и, что более важно, что эти проверки по умолчанию менее вероятно будут условными.
Конкретно, табличные тесты могут стать запутанными и трудными для чтения, если они используют несколько ветвящихся путей (например,
shouldError,expectCallи т.д.), используют много операторовifдля специфичных ожиданий моков (например,shouldCallFoo) или размещают функции внутри таблицы (например,setupMocks func(*FooMock)).Однако при тестировании поведения, которое меняется только в зависимости от изменённых входных данных, может быть предпочтительнее группировать схожие случаи вместе в табличном тесте, чтобы лучше иллюстрировать, как поведение меняется при всех входных данных, а не разделять иначе сопоставимые единицы на отдельные тесты и делать их более трудными для сравнения и противопоставления.
Если тело теста короткое и простое, допустимо иметь единственный ветвящийся путь для случаев успеха и неудачи с полем таблицы типа
shouldErrдля указания ожиданий ошибки.Плохо Хорошо func TestComplicatedTable(t *testing.T) { tests := []struct { give string want string wantErr error shouldCallX bool shouldCallY bool giveXResponse string giveXErr error giveYResponse string giveYErr error }{ // ... } for _, tt := range tests { t.Run(tt.give, func(t *testing.T) { // настройка моков ctrl := gomock.NewController(t) xMock := xmock.NewMockX(ctrl) if tt.shouldCallX { xMock.EXPECT().Call().Return( tt.giveXResponse, tt.giveXErr, ) } yMock := ymock.NewMockY(ctrl) if tt.shouldCallY { yMock.EXPECT().Call().Return( tt.giveYResponse, tt.giveYErr, ) } got, err := DoComplexThing(tt.give, xMock, yMock) // проверка результатов if tt.wantErr != nil { require.EqualError(t, err, tt.wantErr) return } require.NoError(t, err) assert.Equal(t, want, got) }) } }func TestShouldCallX(t *testing.T) { // настройка моков ctrl := gomock.NewController(t) xMock := xmock.NewMockX(ctrl) xMock.EXPECT().Call().Return("XResponse", nil) yMock := ymock.NewMockY(ctrl) got, err := DoComplexThing("inputX", xMock, yMock) require.NoError(t, err) assert.Equal(t, "want", got) } func TestShouldCallYAndFail(t *testing.T) { // настройка моков ctrl := gomock.NewController(t) xMock := xmock.NewMockX(ctrl) yMock := ymock.NewMockY(ctrl) yMock.EXPECT().Call().Return("YResponse", nil) _, err := DoComplexThing("inputY", xMock, yMock) assert.EqualError(t, err, "Y failed") }Эта сложность делает тест более трудным для изменения, понимания и доказательства его корректности.
Хотя строгих правил нет, читаемость и поддерживаемость всегда должны быть главными при выборе между табличными тестами и отдельными тестами для множественных входов/выходов системы.
Параллельные тесты
Параллельные тесты, как и некоторые специализированные циклы (например, те, что создают горутины или захватывают ссылки как часть тела цикла), должны заботиться о явном присваивании переменных цикла внутри области видимости цикла, чтобы гарантировать, что они содержат ожидаемые значения.
tests := []struct{ give string // ... }{ // ... } for _, tt := range tests { tt := tt // для t.Parallel t.Run(tt.give, func(t *testing.T) { t.Parallel() // ... }) }В примере выше мы должны объявить переменную
ttс областью видимости итерации цикла из-за использованияt.Parallel()ниже. Если мы этого не сделаем, большинство или все тесты получат неожиданное значение дляttили значение, которое меняется во время их выполнения.Функциональные опции
Функциональные опции — это паттерн, в котором вы объявляете непрозрачный тип
Option, который записывает информацию в некоторую внутреннюю структуру. Вы принимаете переменное количество этих опций и действуете на основе полной информации, записанной опциями во внутренней структуре.Используйте этот паттерн для необязательных аргументов в конструкторах и других публичных API, которые, как вы предвидите, могут потребовать расширения, особенно если у вас уже есть три или более аргументов в этих функциях.
Плохо Хорошо // package db func Open( addr string, cache bool, logger *zap.Logger ) (*Connection, error) { // ... }// package db type Option interface { // ... } func WithCache(c bool) Option { // ... } func WithLogger(log *zap.Logger) Option { // ... } // Open создаёт соединение. func Open( addr string, opts ...Option, ) (*Connection, error) { // ... }Параметры cache и logger всегда должны предоставляться, даже если пользователь хочет использовать значения по умолчанию.
db.Open(addr, db.DefaultCache, zap.NewNop()) db.Open(addr, db.DefaultCache, log) db.Open(addr, false /* cache */, zap.NewNop()) db.Open(addr, false /* cache */, log)Опции предоставляются только при необходимости.
db.Open(addr) db.Open(addr, db.WithLogger(log)) db.Open(addr, db.WithCache(false)) db.Open( addr, db.WithCache(false), db.WithLogger(log), )Наш рекомендуемый способ реализации этого паттерна — использование интерфейса
Optionс неэкспортируемым методом, записывающим опции в неэкспортируемую структуруoptions.type options struct { cache bool logger *zap.Logger } type Option interface { apply(*options) } type cacheOption bool func (c cacheOption) apply(opts *options) { opts.cache = bool(c) } func WithCache(c bool) Option { return cacheOption(c) } type loggerOption struct { Log *zap.Logger } func (l loggerOption) apply(opts *options) { opts.logger = l.Log } func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log} } // Open создаёт соединение. func Open( addr string, opts ...Option, ) (*Connection, error) { options := options{ cache: defaultCache, logger: zap.NewNop(), } for _, o := range opts { o.apply(&options) } // ... }Обратите внимание, что существует метод реализации этого паттерна с использованием замыканий, но мы считаем, что паттерн выше предоставляет больше гибкости авторам и легче отлаживается и тестируется пользователями. В частности, он позволяет сравнивать опции друг с другом в тестах и моках, в отличие от замыканий, где это невозможно. Кроме того, он позволяет опциям реализовывать другие интерфейсы, включая
fmt.Stringer, что позволяет создавать удобочитаемые строковые представления опций.См. также,
Линтинг
Более важно, чем любой «благословленный» набор линтеров, — линтить последовательно по всей кодовой базе.
Мы рекомендуем использовать следующие линтеры как минимум, потому что считаем, что они помогают выявить наиболее распространённые проблемы, а также устанавливают высокую планку качества кода, не будучи излишне предписывающими:
- errcheck для обеспечения обработки ошибок
- goimports для форматирования кода и управления импортами
- golint для указания на распространённые стилевые ошибки
- govet для анализа кода на распространённые ошибки
- staticcheck для выполнения различных проверок статического анализа
Запускатели линтеров
Мы рекомендуем golangci-lint в качестве основного запускателя линтеров для кода на Go, во многом благодаря его производительности в больших кодовых базах и возможности настраивать и использовать многие канонические линтеры одновременно. Этот репозиторий содержит пример .golangci.yml файла конфигурации с рекомендуемыми линтерами и настройками.
golangci-lint имеет различные линтеры, доступные для использования. Вышеуказанные линтеры рекомендуются в качестве базового набора, и мы поощряем команды добавлять любые дополнительные линтеры, которые имеют смысл для их проектов.