Статья посвящена лучшим практикам написания кода Go. Она составлен в стиле презентации, но без обычных слайдов. Постараемся кратко и чётко пройтись по каждому пункту.
Для начала следует договориться, что значит лучшие практики для языка программирования. Здесь можно вспомнить слова Расса Кокса, технического руководителя Go:
Программная инженерия — то, что происходит с программированием, если добавить фактор времени и других программистов.
Таким образом, Расс различает понятия программирования и программной инженерии. В первом случае вы пишете программу для себя, во втором создаёте продукт, над которым со временем будут работать и другие программисты. Инженеры приходят и уходят. Команды растут или сокращаются. Добавляются новые функции и исправляются ошибки. Такова природа разработки программного обеспечения.
Содержание
- Содержание
- 1. Основополагающие принципы
- 2. Идентификаторы
- 3. Комментарии
- 4. Структура пакета
- 5. Структура проекта
- 6. Структура API
- 7. Обработка ошибок
- 8. Параллелизм
1. Основополагающие принципы
Возможно, среди вас я один из первых пользователей Go, но дело не в моём личном мнении. Эти базовые принципы лежат в основе самого Go:
- Простота
- Читаемость
- Продуктивность
Примечание. Обратите внимание, я не упомянул «производительность» или «параллелизм». Есть языки быстрее Go, но определённо они не могут сравниться по простоте. Есть языки, которые главным приоритетом ставят параллелизм, но они не сравняться ни по читаемости, ни по продуктивности программирования.
Производительность и параллелизм — важные атрибуты, но не настолько важные, как простота, читаемость и продуктивность.
Простота
«Простота — необходимое условие читаемости» — Эдсгер Дейкстра
Зачем стремиться к простоте? Почему важно, чтобы программы Go были простыми?
Каждому из нас попадался непонятный код, верно? Когда боишься внести правку, потому что это сломает другую часть программы, которую вы не совсем понимаете и не знаете, как исправить. Это и есть сложность.
«Есть два способа проектирования ПО: первый — сделать его настолько простым, чтобы не было очевидных недостатков, а второй — сделать его настолько сложным, чтобы не было очевидных недостатков. Первое гораздо труднее» — Ч. Э. Р. Хоар
Сложность превращает надёжное ПО в ненадежное. Сложность — то, что убивает программные проекты. Поэтому простота — высшая цель Go. Какие бы программы мы ни писали, они должны быть простыми.
1.2. Удобочитаемость
«Читаемость — неотъемлемая часть ремонтопригодности» — Марк Рейнхольд, конференция по JVM, 2018
Почему важно, чтобы код был читаемым? Почему мы должны стремиться к читабельности?
«Программы следует писать для людей, а машины их всего лишь выполняют» — Хэл Абельсон и Джеральд Сассман, «Структура и интерпретация компьютерных программ»
Не только программы Go, но вообще всё программное обеспечение пишется людьми для людей. Тот факт, что машины тоже обрабатывают код, вторичен.
Однажды написанный код будет многократно прочитан людьми: сотни, а то и тысячи раз.
«Самый важный навык для программиста — умение эффективно передавать идеи» — Гастон Хоркера
Читаемость является ключом к пониманию того, что делает программа. Если вы не можете понять код, как его поддерживать? Если программное обеспечение невозможно поддерживать, оно будет переписано; и это может быть последний раз, когда ваша компания использует Go.
Если вы пишете программу для себя, делайте то, что работает для вас. Но если это часть совместного проекта или программа будет использоваться достаточно долго, чтобы изменились требования, функции или среда, в которой она работает, то ваша цель состоит в том, чтобы программа стала ремонтопригодной.
Первый шаг к написанию поддерживаемого ПО — убедиться, что код понятен.
1.3. Продуктивность
«Дизайн — это искусство такой организации кода, чтобы он работал сегодня, но всегда поддерживал изменения» — Сэнди Мец
В качестве последнего базового принципа хочу назвать продуктивность разработчика. Это большая тема, но она сводится к соотношению: сколько времени вы тратите на полезную работу, а сколько — на ожидание ответа от инструментов или безнадёжные блуждания в непонятной кодовой базе. Программисты на Go должны чувствовать, что способны осилить большой объём работы.
Ходит шутка, что язык Go разработали, пока компилировалась программа на C++. Быстрая компиляция — ключевая особенность Go и ключевой фактор привлечения новых разработчиков. Хотя компиляторы совершенствуются, но в целом минутные компиляции на других языках проходят за несколько секунд на Go. Так разработчики Go чувствуют себя столь же продуктивными, как программисты на динамических языках, но без проблем с надёжностью тех языков.
Если фундаментально говорить о продуктивности разработчиков, то программисты Go понимают, что чтение кода по сути важнее его написания. В этой логике Go доходит даже до того, что с помощью инструментов обеспечивает форматирование всего кода в определённом стиле. Это устраняет малейшие трудности в изучении специфического диалекта конкретного проекта и помогает выявлять ошибки, потому что они просто выглядят неправильно по сравнению с обычным кодом.
Программисты Go не тратят дни на отладку странных ошибок компиляции, сложные скрипты сборки или развёртывание кода в рабочей среде. И самое главное, они не тратят время, пытаясь понять, что написал коллега.
Когда разработчики Go говорят о масштабируемости языка, они имеют в виду именно продуктивность.
2. Идентификаторы
Первая тема, которую мы обсудим — идентификаторы, это синоним имён: названия переменных, функций, методов, типов, пакетов и так далее.
«Плохое имя — симптом плохого дизайна» — Дэйв Чейни
Учитывая ограниченный синтаксис Go, имена объектов оказывают огромное влияние на читаемость программ. Читаемость — ключевой фактор хорошего кода, поэтому выбор хороших имён имеет решающее значение.
2.1. Именуйте идентификаторы исходя из ясности, а не краткости
«Важно, чтобы код был очевидным. То, что можно сделать в одной строке, вы должны сделать в трёх» — Укия Смит
Go не оптимизирован для хитрых однострочников или минимального количества строк в программе. Мы не оптимизируем ни размер исходного кода на диске, ни время, необходимое для набора программы в редакторе.
«Хорошее название как хорошая шутка. Если тебе нужно объяснять её, то уже не смешно» — Дэйв Чейни
Ключ к максимальной ясности — это имена, которые мы выбираем для идентификации программ. Какие качества присущи хорошему имени?
- Хорошее имя лаконично. Оно не обязательно должно быть самым коротким, но не содержит лишнего. У него высокое отношение сигнал/шум.
- Хорошее имя является описательным. Оно описывает применение переменной или константы, а не содержимое. Хорошее имя описывает результат функции или поведение метода, а не реализацию. Назначение пакета, а не его содержимое. Чем точнее имя описывает вещь, которую идентифицирует, тем лучше.
- Хорошее имя предсказуемо. По одному названию вы должны понимать, как будет использоваться объект. Названия должны быть описательными, но также важно следовать традиции. Вот что имеют в виду программисты Go, когда говорят «идиоматический».
Рассмотрим подробнее каждое из этих свойств.
2.2. Длина идентификатора
Иногда стиль Go критикуют за короткие имена переменных. Как сказал Роб Пайк, «программисты Go хотят идентификаторы правильной длины».
Эндрю Джерранд предлагает более длинными идентификаторами указывать на важность.
«Чем больше расстояние между объявлением имени и использованием объекта, тем длиннее должно быть имя» — Эндрю Джерранд
Таким образом, можно составить некоторые рекомендации:
- Краткие названия переменных хороши, если расстояние между объявлением и последним использованием невелико.
- Длинные имена переменных должны оправдывать себя; чем они длиннее, тем большее значение должны представлять. Многословные названия содержат мало сигнала по отношению к своему весу на странице.
- Не включайте в имя переменной название типа.
- Названия констант должны описывать внутреннее значение, а не то, как используется это значение.
- Предпочитайте однобуквенные переменные для циклов и ветвей, отдельные слова для параметров и возвращаемых значений, несколько слов для функций и объявлений на уровне пакета.
- Предпочитайте отдельные слова для методов, интерфейсов и пакетов.
- Помните, что имя пакета является частью имени, которое использует вызывающий объект для ссылки.
Рассмотрим пример.
type Person struct {
Name string
Age int
}
// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}
В десятой строке объявляется переменная диапазона p
, и она вызывается лишь единожды из следующей строки. То есть переменная живёт на странице очень короткое время. Если читателя интересует роль p
в программе, ему достаточно прочитать всего две строки.
Для сравнения, people
объявляется в параметрах функции и живёт семь строк. То же самое относится к sum
и count
, поэтому они оправдывают свои более длинные имена. Читателю нужно просканировать больше кода, чтобы их найти: это оправдывает более отличительные имена.
Можно выбрать s
для sum
и c
(или n
) для count
, но это сведёт важность всех переменных в программе к одному уровню. Можно заменить people
на p
, но возникнет проблема, как назвать переменную итерации for ... range
. Единственный person
будет выглядеть странно, потому что у короткоживущей переменной итерации получается более длинное название, чем у нескольких значений, из которых она выводится.
Совет. Разделяйте пустыми строками поток функции, как пустые строки между абзацами разбивают поток текста. В
AverageAge
у нас три последовательные операции. Сначала проверка деления на ноль, затем вывод общего возраста и количества людей, и последнее — вычисление среднего возраста.
2.2.1. Главное — контекст
Важно понимать, что большинство советов по именованию зависят от контекста. Мне нравится говорить, что это принцип, а не правило.
В чём разница между идентификаторами i
и index
? Например, нельзя однозначно сказать, что такой код
for index := 0; index < len(s); index++ {
//
}
принципиально более читаемый, чем
for i := 0; i < len(s); i++ {
//
}
Я считаю, что второй вариант не хуже, потому что в данном случае область i
или index
ограничена телом цикла for
, а дополнительная многословность мало что добавляет к пониманию программы.
А вот из этих функций какая более читабельна?
func (s *SNMP) Fetch(oid []int, index int) (int, error)
или
func (s *SNMP) Fetch(o []int, i int) (int, error)
В этом примере oid
является аббревиатурой SNMP Object ID, а дополнительное сокращение до o
заставляет при чтении кода перейти от документированной нотации к более короткой нотации в коде. Аналогично и сокращение index
до i
затрудняет понимание сути, поскольку в сообщениях SNMP значение sub каждого OID называется индексом.
Совет. Не комбинируйте длинные и короткие формальные параметры в одном объявлении.
2.3. Не называйте переменные по их типам
Вы же не назовёте своих питомцев «собака» и «кошка», верно? По той же причине не следует включать имя типа в имя переменной. Оно должно описывать содержимое, а не его тип. Рассмотрим пример:
var usersMap map[string]*User
Что хорошего в этом объявлении? Мы видим, что это карта, и она имеет какое-то отношение к типу *User
: вероятно, это хорошо. Но usersMap
— действительно карта, а Go как статически типизированный язык не позволит случайно использовать такое название там, где требуется скалярная переменная, поэтому суффикс Map
избыточен.
Рассмотрим ситуацию, когда добавляются другие переменные:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
Теперь у нас три переменные типа map: usersMap
, companiesMap
и productsMap
, а все строки сопоставляются с разными типами. Мы знаем, что это карты, и мы также знаем, что компилятор выдаст ошибку, если мы попытаемся использовать companiesMap
там, где код ожидает map[string]*User
. В этой ситуации ясно, что суффикс Map
не улучшает ясность кода, это просто лишние символы.
Предлагаю избегать любых суффиксов, которые напоминают тип переменной.
Совет. Если название
users
недостаточно ясно описывает суть, тогдаusersMap
тоже.
Этот совет также относится к параметрам функции. Например:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
Название config
для параметра *Config
избыточно. Мы и так знаем, что это *Config
, тут же рядом написано.
В этом случае рассмотрим conf
или c
, если время жизни переменной достаточно короткое.
Если в какой-то момент в нашей области более одного *Config
, то названия conf1
и conf2
менее содержательны, чем original
и updated
, так как последние труднее перепутать.
Примечание. Не позволяйте названиям пакетов украсть хорошие названия переменных.
Имя импортируемого идентификатора содержит название пакета. Например, тип
Context
в пакетеcontext
будет называтьсяcontext.Context
. Это делает невозможным использование в вашем пакете переменной или типаcontext
.func WriteLog(context context.Context, message string)
Такое не скомпилируется. Вот почему при локальном объявлении типов
context.Context
, например, традиционно используются имена вродеctx
.func WriteLog(ctx context.Context, message string)
2.4. Используйте единый стиль именования
Ещё одно свойство хорошего имени — оно должно быть предсказуемым. Читатель должен сразу его понять. Если это общее название, то читатель имеет право предположить, что оно не изменило значения с предыдущего раза.
Например, если код проходит вокруг дескриптора базы данных, каждый раз при отображении параметра у него должно быть то же имя. Вместо всяческих сочетаний типа d *sql.DB
, dbase *sql.DB
, DB *sql.DB
и database *sql.DB
лучше использовать что-то одно:
db *sql.DB
Так проще понять код. Если вы видите db
, то знаете, что это *sql.DB
и она объявляется локально или предоставлена вызывающей стороной.
Аналогичный совет относительно получателей метода; используйте одинаковое название получателя на каждый метод этого типа. Так читателю будет проще усвоить использование получателя среди разных методов этого типа.
Примечание. Соглашение о коротких именах получателей в Go противоречит ранее озвученным рекомендациям. Это один из тех случаев, когда сделанный на раннем этапе выбор становится стандартным стилем, как использование
CamelCase
вместоsnake_case
.
Совет. Стиль Go указывает на однобуквенные имена или аббревиатуры для получателей, производные от их типа. Может оказаться, что имя получателя иногда конфликтует с именем параметра в методе. В этом случае рекомендуется сделать имя параметра немного длиннее и не забывать последовательно его использовать.
Наконец, некоторые однобуквенные переменные традиционно ассоциируются с циклами и подсчётом. Например, i
, j
и k
обычно являются индуктивными переменными в циклах for
, n
обычно ассоциируется со счётчиком или накапливающим сумматором, v
является типичным сокращением value в кодирующей функции, k
обычно используется для ключа карты, а s
часто используется как сокращение для параметров типа string
.
Как и в примере с db
выше, программисты ожидают, что i
является индуктивной переменной. Если они видят её в коде, то ожидают скоро встретить цикл.
Совет. Если у вас настолько много вложенных циклов, что вы исчерпали запас переменных
i
,j
иk
, то может следует разбить функцию на более мелкие единицы.
2.5. Используйте единый стиль деклараций
В Go есть минимум шесть разных способов объявления переменной
-
var x int = 1
-
var x = 1
-
var x int; x = 1
-
var x = int(1)
-
x := 1
Уверен, я ещё не все вспомнил. Разработчики Go, наверное, считают это ошибкой, но уже слишком поздно что-то менять. При таком выборе как обеспечить единообразный стиль?
Хочу предложить такой стиль объявления переменных, какой я сам стараюсь использовать везде, где возможно.
- При объявлении переменной без инициализации используйте
var
.var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing)
var
действует как подсказка, что эта переменная намеренно объявлена как нулевое значение указанного типа. Это согласуется с требованием объявлять переменные на уровне пакета с помощьюvar
в отличие от синтаксиса короткого объявления, хотя позже я приведу аргументы, что переменные уровня пакета вообще не следует использовать. - При объявлении c инициализацией используйте
:=
. Это даёт понять читателю, что переменная слева от:=
намеренно инициализируется.Чтобы объяснить почему, давайте рассмотрим предыдущий пример, но на этот раз специально инициализируем каждую переменную:
var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing)
Поскольку в Go нет автоматических преобразований из одного типа в другой, в первом и третьем примерах тип на левой стороне оператора присваивания должен быть идентичен типу на правой стороне. Компилятор может вывести тип объявляемой переменной из типа справа, так что пример можно написать лаконичнее:
var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
Здесь players
явно инициализируются в 0
, что является избыточным, потому что начальное значение players
в любом случае равно нулю. Поэтому лучше явно дать понять, что мы хотим использовать нулевое значение:
var players int
Что насчёт второго оператора? Мы не можем определить тип и написать
var things = nil
Потому что у nil
нет типа. Вместо этого у нас выбор: или мы используем нулевое значение для среза…
var things []Thing
… или создаём срез с нулевым количеством элементов?
var things = make([]Thing, 0)
Во втором случае значение для среза ненулевое, и мы даём понять это читателю, используя короткую форму объявления:
things := make([]Thing, 0)
Это говорит читателю, что мы решили явно инициализировать things
.
Так мы подходим к третьей декларации:
var thing = new(Thing)
Здесь одновременно и явная инициализация переменной, и введение «уникального» ключевого слова new
, что не нравится некоторым программистам Go. Если применить рекомендованный короткий синтаксис, то получается
thing := new(Thing)
Это даёт понять, что thing
явно инициализируется в результат new(Thing)
, но по-прежнему оставляет нетипичное new
. Проблему можно было бы решить с помощью литерала:
thing := &Thing{}
Что аналогично new(Thing)
, а такое дублирование огорчает некоторых программистов Go. Однако это означает, что мы явно инициализируем thing
с указателем на Thing{}
и нулевым значением Thing
.
Но лучше учесть тот факт, что thing
объявляется с нулевым значением, и использовать адрес оператора для передачи адреса thing
в json.Unmarshall
:
var thing Thing
json.Unmarshall(reader, &thing)
Примечание. Конечно, из любого правила есть исключения. Например, иногда две переменные тесно связаны между собой, так что будет странно написать
var min int max := 1000
Более читаемая декларация:
min, max := 0, 1000
Подведём итог:
- При объявлении переменной без инициализации используйте синтаксис
var
. - При объявлении и явной инициализации переменной используйте
:=
.
Совет. Явно указывайте на сложные вещи.
var length uint32 = 0x80
Здесь
length
может использоваться с библиотекой, что требует определённого числового типа, и такой вариант более явно указывает, что тип length специально выбран как uint32, чем в короткой декларации:length := uint32(0x80)
В первом примере я намеренно нарушаю своё правило, используя декларацию var при явной инициализации. Отход от стандарта даёт читателю понять, что происходит нечто необычное.
2.6. Работайте на коллектив
Я уже говорил, что суть разработки ПО — создание читаемого, поддерживаемого кода. Вероятно, бóльшую часть карьеры вы будете работать над совместными проектами. Мой совет в этой ситуации: следовать стилю, принятому в коллективе.
Изменение стилей посреди файла вызывает раздражение. Важно единообразие, пусть и в ущерб личным предпочтениям. Мое эмпирическое правило: если код подходит через gofmt
, то обычно проблема не стоит обсуждения.
Совет. Если вы хотите сделать переименование по всей базе кода, не смешивайте это с другими изменениями. Если кто-то использует git bisect, ему не понравится пробираться через тысячи переименований, чтобы найти другой изменённый код.
3. Комментарии
Прежде чем мы перейдем к более важным пунктам, хочу уделить пару минут комментариям.
«У хорошего кода множество комментариев, а плохой код требует множества комментариев» — Дэйв Томас и Эндрю Хант, «Прагматичный программист»
Комментарии очень важны для читаемости программы. Каждый комментарий должен делать одну — и только одну — из трёх вещей:
- Объяснить, что делает код.
- Объяснить, как он это делает.
- Объяснить, почему.
Первая форма идеально подходит для комментариев к общедоступным символам:
// Open открывает указанный файл для чтения.
// В случае успеха на возвращаемом файле можно использовать методы для чтения.
Второе идеально для комментариев внутри метода:
// очередь всех зависимых действий
var results []chan error
for _, dep := range a.Deps {
results = append(results, execute(seen, dep))
}
Третья форма («почему») уникальна тем, что она не вытесняет и не заменяет первые две. Такие комментарии объясняют внешние факторы, которые привели к написанию кода в нынешнем виде. Часто без этого контекста трудно понять, почему код написан именно таким образом.
return &v2.Cluster_CommonLbConfig{
// Отключаем HealthyPanicThreshold
HealthyPanicThreshold: &envoy_type.Percent{
Value: 0,
},
}
В этом примере сразу может быть непонятно, что происходит при установке HealthyPanicThreshold на ноль процентов. Комментарий призван уточнить, что значение 0 отключает порог паники.
3.1. Комментарии в переменных и константах должны описывать их содержимое, а не предназначение
Ранее я говорил, что имя переменной или константы должно описывать её назначение. Но комментарий к переменной или константе должен описывать именно содержимое, а не назначение.
const randomNumber = 6 // выводится из случайной матрицы
В этом примере комментарий описывает, почему randomNumber
присвоено значение 6 и откуда оно получено. Комментарий не описывает, где будет использоваться randomNumber
. Вот ещё несколько примеров:
const (
StatusContinue = 100 // RFC 7231, 6.2.1
StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
StatusProcessing = 102 // RFC 2518, 10.1
StatusOK = 200 // RFC 7231, 6.3.1
В контексте HTTP число 100
известно как StatusContinue
, что определено в RFC 7231, раздел 6.2.1.
Совет. Для переменных без начального значения комментарий должен описывать, кто отвечает за инициализацию этой переменной.
// sizeCalculationDisabled указывает, безопасно ли // рассчитать ширину и выравнивание типов. См. dowidth. var sizeCalculationDisabled bool
Здесь комментарий сообщает читателю, что функция
dowidth
отвечает за поддержание состоянияsizeCalculationDisabled
.
Совет. Прячьте на виду. Это совет от Кейт Грегори. Иногда лучшее имя для переменной скрывается в комментариях.
// реестр драйверов SQL var registry = make(map[string]*sql.Driver)
Комментарий добавлен автором, потому что имя
registry
недостаточно объясняет свое назначение — это реестр, но реестр чего?Если переименовать переменную в sqlDrivers, то становится ясно, что она содержит драйверы SQL.
var sqlDrivers = make(map[string]*sql.Driver)
Теперь комментарий стал избыточным и его можно удалить.
3.2. Всегда документируйте общедоступные символы
Документация вашего пакета генерируется godoc, поэтому следует добавлять комментарий к каждому общедоступному символу, объявленному в пакете: переменной, константе, функции и методу.
Вот два правила из руководства по стилю Google:
- Любая публичная функция, которая не является одновременно очевидной и краткой, должна быть прокомментирована.
- Любая функция в библиотеке должна быть прокомментирована независимо от длины или сложности
package ioutil
// ReadAll читает из r до ошибки или конца файла (EOF) и возвращает
// прочитанные.данные. Успешный вызов возвращает err == nil, not err == EOF.
// Поскольку ReadAll должна читать до конца файла, она не интерпретирует его
// как ошибку.
func ReadAll(r io.Reader) ([]byte, error)
Из этого правила есть одно исключение: не нужно документировать методы, реализующие интерфейс. Конкретно не делайте такого:
// Read реализует интерфейс io.Reader
func (r *FileReader) Read(buf []byte) (int, error)
Этот комментарий ни о чём не говорит. Он не говорит, что делает метод: хуже того, он отправляет куда-то искать документацию. В этой ситуации я предлагаю полностью удалить комментарий.
Вот пример из пакета io
.
// LimitReader возвращает Reader, который читает из r,
// но останавливается с EOF после n байт.
// Основан на *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
// LimitedReader читает из R, но ограничивает объём возвращаемых
// данных всего N байтами. Каждый вызов Read обновляет N для
// отражения новой оставшейся суммы.
// Read возвращает EOF, когда N <= 0 или когда основное R возвращает EOF.
type LimitedReader struct {
R Reader // underlying reader
N int64 // max bytes remaining
}
func (l *LimitedReader) Read(p []byte) (n int, err error) {
if l.N <= 0 {
return 0, EOF
}
if int64(len(p)) > l.N {
p = p[0:l.N]
}
n, err = l.R.Read(p)
l.N -= int64(n)
return
}
Обратите внимание, что объявлению LimitedReader
непосредственно предшествует функция, которая его использует, и объявление LimitedReader.Read
следует за декларацией самого LimitedReader
. Хотя сама LimitedReader.Read
не документирована, но можно понять, что это реализация io.Reader
.
Совет. Перед написанием функции напишите комментарий, описывающий её. Если вам трудно написать комментарий, то это признак того, что код, который вы собираетесь написать, будет трудно понять.
3.2.1. Не комментируйте плохой код, перепишите его
«Не комментируйте плохой код — перепишите его» — Брайан Керниган
Недостаточно указать в комментариях на трудность фрагмента кода. Если вы столкнулись с одним из таких комментариев, следует завести тикет с напоминанием о рефакторинге. С техническим долгом можно жить, пока известна его сумма.
В стандартной библиотеке принято оставлять комментарии в стиле TODO с именем пользователя, который заметил проблему.
// TODO(dfc) является O(N^2), нужно найти более эффективную процедуру.
Это не обязательство устранить проблему, но указанный пользователь может быть лучшим человеком, к которому следует обратиться с вопросом. Другие проекты сопровождают TODO датой или номером тикета.
3.2.2. Вместо комментирования кода выполните его рефакторинг
«Хороший код — это лучшая документация. Когда вы собираетесь добавить комментарий, задайте себе вопрос: “Как улучшить код, чтобы этот комментарий не был нужен?” Сделайте рефакторинг и оставьте комемнтарий, чтобы стало ещё понятнее» — Стив Макконнелл
Функции должны выполнять только одну задачу. Если вы хотите написать комментарий, потому что какой-то фрагмент не связан с остальной частью функции, то рассмотрите возможность извлечения его в отдельную функцию.
Меньшие функции не только понятнее, но их легче проверить отдельно друг от друга. Когда вы изолировали код в отдельную функцию, её название может заменить собой комментарий.
4. Структура пакета
«Пишите скромный код: модули, которые не показывают ничего лишнего другим модулям и которые не полагаются на реализации других модулей» — Дэйв Томас
Каждый пакет по сути является отдельной небольшой программой Go. Как реализация функции или метода не имеет значения для вызывающего объекта, также со стороны не имеют значения реализации функций, методов и типов, составляющих общедоступный API вашего пакета.
Хороший пакет Go стремится у минимальной связности с другими пакетами на уровне исходного кода, чтобы по мере роста проекта изменения в одном пакете не каскадировались по всей кодовой базе. Такие ситуации сильно тормозят программистов, работающих на этой кодовой базе.
В данном разделе поговорим о дизайне пакетов, включая его название и советы по написанию методов и функций.
4.1. Хороший пакет начинается с хорошего названия
Хороший пакет Go начинается с качественного названия. Представьте его как краткую презентацию, ограниченную только одним словом.
Также, как названия переменных в предыдущем разделе, имя пакета очень важно. Не надо думать о типах данных в этом пакете, лучше задать вопрос: «Какую услугу предоставляет этот пакет?» Обычно ответом будет не «Этот пакет предоставляет тип X», а «Этот пакет позволяет подключиться по HTTP».
Совет. Выбирайте название пакета по его функциональности, а не содержанию.
4.1.1. Хорошие имена пакетов должны быть уникальными
В проекте у каждого пакета уникальное название. Здесь не возникнет сложностей, если вы следовали совету давать имена по назначению пакетов. Если оказалось, что у двух пакетов одинаковые имена, скорее всего:
- У пакета слишком общее название.
- Пакет перекрывается другим пакетом с аналогичным названием. В этом случае следует либо просмотреть проект, либо рассмотреть возможность объединения пакетов.
4.2. Избегайте названий вроде base
, common
или util
Распространённая причина плохих названий — так называемые служебные пакеты, где со временем накапливаются различные хелперы и служебный код. Поскольку там трудно подобрать уникальное название. Это часто приводит к тому, что имя пакета становится производным от того, что он содержит: утилиты.
Названия вроде utils
или helpers
обычно встречаются в больших проектах, в которых укоренилась глубокая иерархия пакетов, а вспомогательные функции используются совместно. Если извлечь какую-то функцию в новый пакет, импорт срывается. В данном случае имя пакета отражает не назначение пакета, а только факт сбоя функции импорта из-за неправильной организации проекта.
В таких ситуациях рекомендую проанализировать, откуда вызываются пакеты utils
helpers
, и, если возможно, переместить соответствующие функции в вызывающий пакет. Даже если это подразумевает дублирование некоторого вспомогательного кода, это лучше, чем введение зависимость импорта между двумя пакетами.
«[Немного] дублирования обходится гораздо дешевле, чем неправильная абстракция» — Сэнди Мец
Если служебные функции используются во многих местах, вместо одного монолитного пакета со служебными функциями лучше сделать несколько пакетов, каждый из которых сосредоточен на одном аспекте.
Совет. Используйте для служебных пакетов множественное число. Например,
strings
для утилит обработки строк.
Пакеты с именами вроде base
или common
часто встречаются, когда в отдельный пакет сливают некую общую функциональность двух или более реализаций или общих типов для клиента и сервера. Я считаю, что в таких случаях нужно сократить количество пакетов, объединив код клиента, сервера и общий код в одном пакете с названием, соответствующим его функции.
Например, для net/http
не делали отдельных пакетов client
и server
, а вместо этого есть файлы client.go
и server.go
с соответствующими типами данных, а также transport.go
для общего транспорта.
Совет. Важно помнить, что имя идентификатора включает название пакета.
- Функция
Get
из пакетаnet/http
становитсяhttp.Get
при ссылке из другого пакета.- Тип
Reader
из пакетаstrings
при импорте в другие пакеты превращается вstrings.Reader
.- Интерфейс
Error
из пакетаnet
явно связан с сетевыми ошибками.
4.3. Быстро возвращайтесь, не погружаясь вглубь
Поскольку Go не использует исключений в потоке управления, нет необходимости глубоко врезаться в код, чтобы обеспечить структуру верхнего уровня для блоков try
и catch
. Вместо многоуровневой иерархии код Go по мере продвижения функции идёт вниз по экрану. Мой друг Мэт Райер называет такую практику «лучом зрения».
Это достигается с помощью граничных операторов: условных блоков с предусловием на входе в функцию. Вот пример из пакета bytes
:
func (b *Buffer) UnreadRune() error {
if b.lastRead <= opInvalid {
return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}
if b.off >= int(b.lastRead) {
b.off -= int(b.lastRead)
}
b.lastRead = opInvalid
return nil
}
При входе в функцию UnreadRune
проверяется состояние b.lastRead
и если предыдущая операция не была ReadRune
, то немедленно возвращается ошибка. Остальная часть функции работает исходя из того, что b.lastRead
больше, чем opInvalid
.
Сравните с той же функцией, но без граничного оператора:
func (b *Buffer) UnreadRune() error {
if b.lastRead > opInvalid {
if b.off >= int(b.lastRead) {
b.off -= int(b.lastRead)
}
b.lastRead = opInvalid
return nil
}
return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}
Тело более вероятной успешной ветки вложено в первое условие if
, а условие успешного выхода return nil
должно быть обнаружено путем тщательного сопоставления закрывающих скобок. Последняя строка функции теперь возвращает ошибку, и нужно отследить выполнение функции до соответствующей открывающей скобки, чтобы узнать, как дойти до этого пункта.
Такой вариант труднее читать, что ухудшает качество программирования и поддержки кода, поэтому Go предпочитает использовать граничные операторы и возвращать ошибки на ранней стадии.
4.4. Сделайте полезным нулевое значение
Каждое объявление переменной, предполагающее отсутствие явного инициализатора, будет автоматически инициализировано значением, соответствующим содержимому обнулённой памяти, то есть нулём. Тип значения определяет один из вариантов: для числовых типов — ноль, для типов указателей — nil, то же самое для срезов, карт и каналов.
Свойство всегда устанавливать известное значение по умолчанию важно для безопасности и корректности вашей программы и может сделать ваши программы Go проще и компактнее. Это то, что имеют в виду программисты Go, когда говорят: «Дайте структурам полезное нулевое значение».
Рассмотрим тип sync.Mutex
, который содержит два целочисленных поля, представляющих внутреннее состояние мьютекса. Эти поля автоматически принимают нулевое значение при любом объявлении sync.Mutex
. В коде учитывается данный факт, так что тип пригоден для использования без явной инициализации.
type MyInt struct {
mu sync.Mutex
val int
}
func main() {
var i MyInt
// i.mu is usable without explicit initialisation.
i.mu.Lock()
i.val++
i.mu.Unlock()
}
Другой пример типа с полезным нулевым значением — bytes.Buffer
. Можно объявить и начать запись в него без явной инициализации.
func main() {
var b bytes.Buffer
b.WriteString("Hello, world!n")
io.Copy(os.Stdout, &b)
}
Нулевое значение этой структуры означает, что len
и cap
равны 0
, а у array
, указателя на память с содержимым резервного массива среза, значение nil
. Это означает, что вам не нужно явно делать срез, вы можете просто объявить его.
func main() {
// s := make([]string, 0)
// s := []string{}
var s []string
s = append(s, "Hello")
s = append(s, "world")
fmt.Println(strings.Join(s, " "))
}
Примечание.
var s []string
похож на две закомментированные строки сверху, но не идентичен им. Есть разница между значением среза, равным nil, и значением среза, имеющим нулевую длину. Следующий код выведет false.func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) }
Полезным, хотя и неожиданным свойством неинициализированных переменных указателя — указателей nil — является возможность вызова методов для типов, имеющих значение nil. Это можно использовать для простого предоставления значений по умолчанию.
type Config struct {
path string
}
func (c *Config) Path() string {
if c == nil {
return "/usr/home"
}
return c.path
}
func main() {
var c1 *Config
var c2 = &Config{
path: "/export",
}
fmt.Println(c1.Path(), c2.Path())
}
4.5. Избегайте состояния уровня пакета
Ключ к написанию удобных для поддержки программ в слабой связанности — изменение одного пакета должно иметь низкую вероятность влияния на другой пакет, который напрямую не зависит от первого.
Есть два отличных способа, чтобы достичь слабой связанности в Go:
- Используйте интерфейсы для описания поведения, необходимого функциям или методам.
- Избегайте глобального состояния.
В Go мы можем объявлять переменные в области функции или метода, а также в области пакета. Когда переменная общедоступна, с идентификатором с заглавной буквы, то её область действия фактически глобальна для всей программы: любой пакет в любое время видит тип и содержимое этой переменной.
Изменяемое глобальное состояние обеспечивает тесную связь между независимыми частями программы, так как глобальные переменные становятся невидимым параметром для каждой функции в программе! Любая функция, которая полагается на глобальную переменную, может быть нарушена при изменении типа этой переменной. Любая функция, зависящая от состояния глобальной переменной, может быть нарушена, если другая часть программы изменит эту переменную.
Как уменьшить связанность, которую создаёт глобальная переменная:
- Переместите соответствующие переменные в качестве полей в структуры, которые в них нуждаются.
- Используйте интерфейсы для уменьшения связи между поведением и реализацией этого поведения.
5. Структура проекта
Поговорим о том, как пакеты объединяются в проект. Обычно это единый репозиторий Git.
Как и у пакета, у каждого проекта должна быть чёткая цель. Если это библиотека, она должна делать одну вещь, например, парсинг XML или журналирование. Не следует объединять в одном проекте несколько целей, это поможет избежать страшной библиотеки common
.
Совет. По моему опыту, репозиторий
common
в конечном итоге тесно связывается с крупнейшим консюмером, а это затрудняет внесение исправлений в предыдущие версии (back-port fixes) без обновления какcommon
, так и консюмера на этапе блокировки, что приводит к множеству несвязанных изменений, плюс по дороге ломаются API.
Если у вас приложение (веб-приложение, контроллер Kubernetes и т. д.), в проекте может быть один или несколько основных пакетов. Например, в моём контроллере Kubernetes один пакет cmd/contour
, который служит как сервером, развёрнутым в кластере Kubernetes, так и клиентом для отладки.
5.1. Меньше пакетов, но более крупные
В код-ревью я заметил одну из типичных ошибок программистов, которые перешли на Go с других языков: они склонны злоупотреблять пакетами.
Go не предоставляет продуманной системы видимости: языку не хватает модификаторов доступа, как в Java (public
, protected
, private
и неявный default
). Нет и аналога дружественных классов из С++.
В Go у нас только два модификатора доступа: это общедоступный и приватный идентификаторы, что обозначается первой буквой идентификатора (заглавная/строчная). Если идентификатор общедоступный, его имя начинается с заглавной буквы, на него может ссылаться любой другой пакет Go.
Примечание. Вы могли слышать слова «экспортирован» или «не экспортирован» как синонимы public и private.
Учитывая ограниченные возможности управления доступом, какие применять методы, чтобы избежать чрезмерно сложных иерархий пакетов?
Совет. В каждом пакете кроме
cmd/
иinternal/
должен присутствовать исходный код.
Я неоднократно повторял, что лучше предпочесть меньшее количество пакетов большего размера. Ваша позиция по умолчанию должна состоять в том, чтобы не создавать новый пакет. Это приводит к тому, что слишком много типов становятся общедоступными, создавая широкую и мелкую область доступного API. Ниже более подробно рассматривается этот тезис.
Совет. Пришли с Java?
Если вы пришли из мира Java или C#, то помните негласное правило: пакет Java эквивалентен одному исходному файлу
.go
. Пакет Go эквивалентен целому модулю Maven или сборке .NET.
5.1.1. Упорядочивание кода по файлам с помощью инструкций импорта
Если вы упорядочиваете пакеты по сервисам, следует ли сделать то же самое для файлов в пакете? Как узнать, когда разбить один файл .go
на несколько? Как узнать, что вы зашли слишком далеко и нужно подумать о слиянии файлов?
Вот рекомендации, которые я использую:
- Начинайте каждый пакет с одного файла
.go
. Присвойте этому файлу то же имя, что у каталога. Например, пакетhttp
должен быть в файлеhttp.go
в каталогеhttp
. - По мере роста пакета можете разделить различные функции на несколько файлов. Например, файл
messages.go
будет содержать типыRequest
иResponse
, файлclient.go
— типClient
, файлserver.go
— тип сервера. - Если у файлов оказались похожие декларации импорта, подумайте об их объединении. Как вариант, можно проанализировать наборы импорта и переместить их.
- Разные файлы должны отвечать за разные области пакета. Так,
messages.go
может отвечать за сортировку HTTP-запросов и ответов в сети и вне сети,http.go
может содержать низкоуровневую логику обработки сети,client.go
иserver.go
— логику построения запроса HTTP или маршрутизации и так далее.
Совет. Предпочитайте существительные для названия исходных файлов.
Примечание. Компилятор Go компилирует каждый пакет параллельно. Внутри пакета параллельно компилируется каждая функция (методы — это просто причудливые функции в Go). Изменение макета кода в пакете не должно повлиять на время компиляции.
5.1.2. Предпочитайте внутренние тесты внешним
Инструмент go
поддерживает пакет testing
в двух местах. Если у вас пакет http2
, вы можете написать файл http2_test.go
и использовать декларацию пакета http2
. Это скомпилирует код http2_test.go
, как будто он часть пакета http2
. В разговорной речи такой тест называют внутренним.
Инструмент go
также поддерживает специальное объявление пакета, которое заканчивается на test, то есть http_test
. Это позволяет тестовым файлам жить в одном пакете с кодом, но когда такие тесты компилируются, они не являются частью кода вашего пакета, а живут в собственном пакете. Это позволяет писать тесты так, словно другой пакет вызывает ваш код. Такие тесты называют внешними.
Я рекомендую использовать внутренние тесты для модульных тестов пакета. Это позволяет тестировать каждую функцию или метод напрямую, избегая бюрократии внешнего тестирования.
Но обязательно нужно поместить во внешний тестовый файл примеры тестовых функций (Example
). Это гарантирует, что при просмотре в godoc примеры получат соответствующий префикс пакета и могут быть легко скопированы.
Совет. Избегайте сложных иерархий пакетов, не поддавайтесь желанию применять таксономию.
За одним исключением, о котором поговорим ниже, иерархия пакетов Go не имеет значения для инструмента
go
. Например, пакетnet/http
не является дочерним или вложенным пакетомnet
.Если у вас в проекте появились промежуточные каталоги без файлов
.go
, возможно, вы ослушались этого совета.
5.1.3. Используйте внутренние пакеты, чтобы уменьшить область общедоступного API
Если в вашем проекте несколько пакетов, вы можете обнаружить экспортированные функции, которые предназначены для использования другими пакетами, но не для общедоступного API. В такой ситуации инструмент go
распознаёт специальное имя папки internal/
, которое можно использовать для размещения кода, открытого для вашего проекта, но закрытого для других.
Чтобы создать такой пакет, поместите его в каталог с именем internal/
или в его подкаталог. Когда команда go
увидит импорт пакета с путём internal
, то проверяет местонахождение самого вызывающего пакета в каталоге или подкаталоге internal/
.
Например, пакет .../a/b/c/internal/d/e/f
может импортировать только пакет из дерева каталогов .../a/b/c
, но никак не .../a/b/g
или любого другого репозитория (см. документацию).
5.2. Основной пакет минимально возможного размера
У функции main
и пакета main
должна быть минимальная функциональность, потому что main.main
действует как синглтон: в программе может быть только одна функция main
, включая тесты.
Поскольку main.main
является синглтоном, то на вызываемые объекты накладывается много ограничений: они вызываются только во время main.main
или main.init
, и только один раз. Это затрудняет написание тестов для кода main.main
. Таким образом, нужно стремиться вывести как можно больше логики из основной функции и, в идеале, из основного пакета.
Совет.
func main()
должна анализировать флаги, открывать соединения с базами данных, логгерами и т. д., а затем передавать выполнение объекту высокого уровня.
6. Структура API
Последний совет по дизайну проекта я считаю самым важным.
Все предыдущие предложения, в принципе, необязательны к исполнению. Это просто рекомендации на основе личного опыта. Я не слишком проталкиваю эти рекомендации в код-ревью.
Другое дело API, здесь к ошибкам более серьёзное отношение, потому что всё остальное можно исправить, не нарушая обратную совместимость: по большей части, это просто детали реализации.
Когда дело доходит до публичного API, стоит с самого начала серьёзно продумать структуру, потому что последующие изменения станут разрушительными для пользователей.
6.1. Проектируйте API, которыми трудно злоупотребить по дизайну
«API должны быть простыми для правильного использования и трудными для неправильного» — Джош Блох
Совет Джоша Блоха — пожалуй, самое ценное в этой статье. Если API трудно использовать для простых вещей, то каждый вызов API сложнее, чем нужно. Когда вызов API сложный и неочевидный, то он, скорее всего, будет упущен из виду.
6.1.1. Будьте осторожны с функциями, которые принимают несколько параметров одного типа
Хороший пример простого на первый взгляд, но сложного в использовании API, это когда он требует двух или более параметров одного типа. Сравним две сигнатуры функций:
func Max(a, b int) int
func CopyFile(to, from string) error
В чём разница между этими двумя функциями? Очевидно, что одна возвращает максимум два числа, а другая копирует файл. Но это не главное.
Max(8, 10) // 10
Max(10, 8) // 10
Max коммутативен: порядок параметров не имеет значения. Максимум от восьми и десяти — это десять, независимо от того, сравниваются восемь и десять или десять и восемь.
Но в случае CopyFile это не так.
CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
Какой из этих операторов сделает резервную копию вашей презентации, а какой перезапишет её версией прошлой недели? Вы не можете сказать, пока не проверите в документации. В ходе код-ревью непонятно, здесь правильный порядок аргументов или нет. Опять же, надо смотреть в документации.
Одним из возможных решений является введение вспомогательного типа, отвечающего за правильный вызов CopyFile
.
type Source string
func (src Source) CopyTo(dest string) error {
return CopyFile(dest, string(src))
}
func main() {
var from Source = "presentation.md"
from.CopyTo("/tmp/backup")
}
Здесь CopyFile
всегда вызывается правильно — это можно утверждать с помощью модульного теста — и может быть сделано private, что еще больше снижает вероятность некорректного использования.
Совет. API с несколькими параметрами одного типа трудно использовать правильно.
6.2. Проектируйте API для основного варианта использования
Несколько лет назад я выступил с докладом об использовании функциональных опций, чтобы сделать API проще по умолчанию.
Суть выступления заключалась в том, что следует разрабатывать API для основного варианта использования. Иначе говоря, API не должны требовать от пользователя предоставлять лишние параметры, которые его не интересуют.
6.2.1. Не рекомендуется использовать nil в качестве параметра
Я начал с того, что не следует заставлять пользователя предоставлять API параметры, которые его не интересуют. Это и значит проектировать API-интерфейсы для основного варианта использования (вариант по умолчанию).
Вот пример из пакета net/http.
package http
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
ListenAndServe
принимает два параметра: TCP-адрес для прослушивания входящих подключений и http.Handler
для обработки входящего HTTP-запроса. Serve
позволяет второму параметру быть nil
. В комментариях отмечается, что обычно вызывающий объект действительно передаст nil
, что указывает на желание использовать http.DefaultServeMux
в качестве неявного параметра.
Теперь у вызывающего Serve
есть два способа сделать то же самое.
http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
Оба варианта делают одно и то же.
Это применение nil
распространяется как вирус. У пакета http
есть ещё хелпер http.Serve
, так что можете себе представить структуру функции ListenAndServe
:
func ListenAndServe(addr string, handler Handler) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer l.Close()
return Serve(l, handler)
}
Поскольку ListenAndServe
позволяет вызывающему передать nil
для второго параметра, http.Serve
тоже поддерживает такое поведение. На самом деле, именно в http.Serve
реализована логика «если обработчик равен nil
, используйте DefaultServeMux
». Принятие nil
для одного параметра может привести вызывающего к мысли, что можно передать nil
для обоих параметров. Но такой Serve
http.Serve(nil, nil)
приводит к ужасной панике.
Совет. Не смешивайте в одной сигнатуре функции параметры
nil
и неnil
.
Автор http.ListenAndServe
пытался упростить жизнь пользователям API для дефолтного случая, но пострадала безопасность.
В присутствии nil
нет разницы в количестве строк между явным и косвенным использовании DefaultServeMux
.
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)
по сравнению с
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
Стоило ли вносить такую путаницу ради сохранения одной строки?
const root = http.Dir("/htdocs")
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)
Совет. Серьёзно подумайте, сколько времени сэкономят программисту вспомогательные функции. Ясность лучше, чем краткость.
Совет. Избегайте предоставления API-интерфейсов со значениями, которые отличаются только областью тестирования. Вместо этого используйте общедоступные оболочки, чтобы скрыть эти параметры, используйте хелперы, чтобы задать свойство в области тестирования.
6.2.2. Используйте аргументы переменной длины вместо параметров []T
Очень часто функция или метод принимает срез значений.
func ShutdownVMs(ids []string) error
Это просто выдуманный пример, но подобное встречается очень часто. Проблема в том, что эти сигнатуры предполагают, что их будут вызывать с более чем одной записью. Как показывает опыт, часто их вызывают только с одним аргументом, который должен быть «упакован» внутри среза, чтобы соответствовать требованиям сигнатуры функций.
Кроме того, поскольку параметр ids
является срезом, можно передать в функцию пустой срез или ноль, и компилятор будет доволен. Это добавляет дополнительную тестовую нагрузку, поскольку тестирование должно охватить такие случаи.
Чтобы привести пример такого класса API, недавно я проводил рефакторинг логики, которая требовала установки некоторых дополнительных полей, если хотя бы один из параметров ненулевой. Логика выглядела примерно так:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}
Поскольку оператор if
становился очень длинным, я хотел вытащить логику проверки в отдельную функцию. Вот что я придумал:
// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
for _, v := range values {
if v > 0 {
return true
}
}
return false
}
Это позволило ясно прописать условие, при котором будет выполняться внутренний блок:
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
// apply the non zero parameters
}
Однако есть проблема с anyPositive
, кто-то мог случайно вызвать его так:
if anyPositive() { ... }
В таком случае anyPositive
вернет false
. Это не самый худший вариант. Хуже, если бы anyPositive
возвращал true
в отсутствие аргументов.
Тем не менее, лучше бы иметь возможность изменить сигнатуру anyPositive, чтобы обеспечить передачу вызывающим хотя бы одного аргумента. Это можно сделать путём объединения параметров для нормальных аргументов и аргументов переменной длины (varargs):
// anyPostive indicates if any value is greater than zero.
func anyPositive(first int, rest ...int) bool {
if first > 0 {
return true
}
for _, v := range rest {
if v > 0 {
return true
}
}
return false
}
Теперь anyPositive
нельзя вызвать менее чем с одним аргументом.
6.3. Пусть функции определяют требуемое поведение
Допустим, мне дали задание написать функцию, которая сохраняет структуру Document
на диске.
// Save записывает содержимое документа в файл f.
func Save(f *os.File, doc *Document) error
Я мог бы написать функцию Save
, которая записывает Document
в файл *os.File
. Но есть несколько проблем.
Сигнатура Save
исключает возможность записи данных по сети. Если такое требование появится в будущем, сигнатуру функции придётся изменить, что повлияет на все вызывающие объекты.
Save
также неприятно тестировать, так как она работает непосредственно с файлами на диске. Таким образом, чтобы проверить её работу, тест должен прочитать содержимое файла после записи.
И я должен убедиться, что f
записывается во временную папку и впоследствии удаляется.
*os.File
также определяет множество методов, которые не имеют отношения к Save
, например, чтение каталогов и проверка, является ли путь символической ссылкой. Хорошо, если бы сигнатура Save
описывала только релевантные части *os.File
.
Что можно сделать?
// Save записывает содержимое документа в предоставленный
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
С помощью io.ReadWriteCloser
можно применить принцип разделения интерфейса — и переопределить Save
на интерфейс, который описывает более общие свойства файла.
После такого изменения любой тип, который реализует интерфейс io.ReadWriteCloser
, можно заменить на предыдущий *os.File
.
Это одновременно и расширяет сферу применения Save
, и разъясняет вызывающему объекту, какие методы типа *os.File
имеют отношение к его работе.
И автор Save
больше не может вызывать эти несвязанные методы для *os.File
, поскольку он скрыт за интерфейсом io.ReadWriteCloser
.
Но мы принцип разделения интерфейса можно распространить ещё дальше.
Во-первых, если Save
следует принципу единой ответственности, то вряд ли он прочитает файл, который только что написал, чтобы проверить его содержимое — это должен делать другой код.
// Save записывает содержимое документа в предоставленный
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
Поэтому можно сузить спецификации интерфейса для Save
только записью и закрытием.
Во-вторых, механизм закрытия потока у Save
является наследием того времени, когда он работал с файлом. Возникает вопрос, при каких обстоятельствах wc
будет закрыт.
То ли Save
вызовет Close
безоговорочно, то ли в случае успеха.
Это представляет проблему для вызывающего объекта, поскольку он может захотеть добавить данные к потоку уже после записи документа.
// Save записывает содержимое документа в предоставленный
// Writer.
func Save(w io.Writer, doc *Document) error
Лучший вариант — переопределить Save на работу только с io.Writer
, избавив оператор от всей остальной функциональности, кроме записи данных в поток.
После применения принципа разделения интерфейса функция одновременно стала и более конкретной с точки зрения требований (ей нужен только объект, куда можно записывать), и более общей с точки зрения функциональности, поскольку теперь мы можем использовать Save
для сохранения данных куда угодно, где реализован io.Writer
.
7. Обработка ошибок
Я давал несколько презентаций и много писал на эту тему в блоге, поэтому не буду повторяться.
Вместо этого я хочу охватить две другие области, связанные с обработкой ошибок.
7.1. Устраните необходимость обработки ошибок, убрав сами ошибки
Я высказал много предложений по улучшению синтаксиса обработки ошибок, но самый лучший вариант — вообще их не обрабатывать.
Примечание. Я не говорю «удалить обработку ошибок». Я предлагаю изменить код, чтобы не было ошибок для обработки.
На такое предложение меня вдохновила недавно вышедшая книга Джона Остерхаута «Философия разработки программного обеспечения». Одна из глав называется «Исключить ошибки из реальности». Попытаемся применить этот совет.
7.1.1. Подсчёт строк
Напишем функцию для подсчёта количества строк в файле.
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)
for {
_, err = br.ReadString('n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}
Поскольку мы следуем советам из предыдущих разделов, CountLines
принимает io.Reader
, а не *os.File
; это уже задача вызывающей стороны предоставить io.Reader
, чьё содержание мы хотим сосчитать.
Мы создаём bufio.Reader
, а затем в цикле вызываем метод ReadString
, увеличивая счётчик, пока не дойдём до конца файла, тогда возвращаем количество прочитанных строк.
По крайней мере, такой код мы хотим написать, но функция обременяется обработкой ошибок. Например, есть такая странная конструкция:
_, err = br.ReadString('n')
lines++
if err != nil {
break
}
Мы увеличиваем количество строк перед проверкой ошибки — это выглядит странно.
Причина, по которой мы должны написать её таким образом, в том, что ReadString
вернёт ошибку, если встретит конец файла раньше, чем символ новой строки. Это может произойти, если в конце файла нет новой строки.
Чтобы попытаться это исправить, изменим логику счётчика строк, а затем посмотрим, нужно ли выходить из цикла.
Примечание. Эта логика всё еще не идеальна, сможете найти ошибку?
Но мы ещё не закончили проверять ошибки. ReadString
вернёт io.EOF
, когда встретит конец файла. Это ожидаемая ситуация, так что для ReadString
нужно сделать какой-то способ сказать «стоп, больше нечего читать». Поэтому, прежде чем вернуть ошибку вызывающему объекту CountLine
, нужно проверить, что ошибка не связана с io.EOF
, и тогда передать её дальше, в противном случае мы возвращаем nil
и говорим, что всё нормально.
Думаю, это хороший пример тезиса Расса Кокса о том, как обработка ошибок может скрыть работу функции. Посмотрим на улучшенную версию.
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
Эта улучшенная версия использует bufio.Scanner
вместо bufio.Reader
.
Под капотом bufio.Scanner
использует bufio.Reader
, но добавляет хороший уровень абстракции, который помогает удалить обработку ошибок.
Примечание.
bufio.Scanner
может сканировать любой шаблон, но по умолчанию ищет новые строки.
Метод sc.Scan()
возвращает значение true
, если сканер встретил строку и не обнаружил ошибки. Таким образом, тело цикла for
вызывается только при наличии строки текста в буфере сканера. Это означает, что новый CountLines
правильно обрабатывает случаи, когда нет новой строки или когда файл пуст.
Во-вторых, поскольку sc.Scan
возвращает false
при обнаружении ошибки, цикл for
завершается при достижении конца файла или обнаружении ошибки. Тип bufio.Scanner
запоминает первую ошибку, с которой столкнулся, и с помощью метода sc.Err()
мы можем восстановить ту ошибку, как только вышли из цикла.
Наконец, sc.Err()
заботится об обработке io.EOF
и преобразует его в nil
, если конец файла достигнут без ошибок.
Совет. Если столкнётесь с чрезмерной обработкой ошибок, попробуйте извлечь некоторые операции во вспомогательный тип.
7.1.2. WriteResponse
Мой второй пример вдохновлён постом «Ошибки — это значения».
Ранее мы видели примеры, как файл открывается, записывается и закрывается. Обработка ошибок есть, но её не слишком много, поскольку операции можно инкапсулировать в хелперы, такие как ioutil.ReadFile
и ioutil.WriteFile
. Но при работе с низкоуровневыми сетевыми протоколами возникает необходимость построения ответа непосредственно с помощью примитивов ввода-вывода. В этом случае обработка ошибок может стать назойливой. Рассмотрим такой фрагмент HTTP-сервера, который создаёт HTTP-ответ.
type Header struct {
Key, Value string
}
type Status struct {
Code int
Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %srn", st.Code, st.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %srn", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "rn"); err != nil {
return err
}
_, err = io.Copy(w, body)
return err
}
Сначала строим строку состояния с помощью fmt.Fprintf
и проверяем ошибку. Затем для каждого заголовка пишем ключ и значение заголовка, каждый раз проверяя ошибку. Наконец, завершаем раздел заголовка дополнительным rn
, проверяем ошибку и копируем тело ответа клиенту. Наконец, хотя нам не нужно проверять ошибку от io.Copy
, нужно перевести его из двух возвращаемых значений в единственное, которое возвращает WriteResponse
.
Это много монотонной работы. Но можно облегчить себе задачу, применив небольшой тип обёртки errWriter
.
errWriter
удовлетворяет контракту io.Writer
, поэтому его можно использовать как обёртку. errWriter
пропускает записи через функцию до обнаружения ошибки. В этом случае он отвергает записи и возвращает предыдущую ошибку.
type errWriter struct {
io.Writer
err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}
var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %srn", st.Code, st.Reason)
for _, h := range headers {
fmt.Fprintf(ew, "%s: %srn", h.Key, h.Value)
}
fmt.Fprint(ew, "rn")
io.Copy(ew, body)
return ew.err
}
Если применить errWriter
к WriteResponse
, то ясность кода значительно улучшается. Больше не нужно проверять ошибку в каждой отдельной операции. Сообщение об ошибке перемещается в конец функции как проверка поля ew.err
, избегая раздражающего перевода из возвращаемых значений io.Copy.
7.2. Обрабатывайте ошибку только единожды
Наконец, я хочу отметить, что ошибки следует обрабатывать только один раз. Обработка означает проверку значения ошибки и принятие единственного решения.
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
Если вы принимаете менее одного решения, вы игнорируете ошибку. Как мы видим здесь, ошибка от w.WriteAll
игнорируется.
Но принятие более одного решения в ответ на одну ошибку тоже неправильно. Ниже код, с которым я часто сталкиваюсь.
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
log.Println("unable to write:", err) // annotated error goes to log file
return err // unannotated error returned to caller
}
return nil
}
В данном примере, если ошибка происходит во время w.Write
, то строка записывается в лог, а также возвращается вызывающему объекту, который, возможно, тоже занесёт её в лог и передаст дальше, вплоть до верхнего уровня программы.
Скорее всего, вызывающая сторона делает то же самое:
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
return err
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
Таким образом, в логе создаётся стек повторяющихся строк.
unable to write: io.EOF
could not write config: io.EOF
Но в верхней части программы вы получаете оригинальную ошибку без какого-то контекста.
err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
Хочу разобрать эту тему чуть подробнее, потому что не считаю проблему с одновременным возвращением ошибки и занесением в лог вопросом личных предпочтений.
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
// oops, forgot to return
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
Я часто встречаюсь с проблемой, что программист забывает вернуться из ошибки. Как мы уже говорили ранее, стиль Go заключается в использовании граничных операторов, проверке предварительных условий по мере выполнения функции и раннем возвращении.
В этом примере автор проверил ошибку, зарегистрировал её, но забыл вернуться. Из-за этого возникает тонкая проблема.
В контракте на обработку ошибок Go говорится, что в присутствии ошибки нельзя делать никаких предположений о содержимом других возвращаемых значений. Поскольку сортировка JSON не удалась, содержимое buf
неизвестно: возможно, он ничего не содержит, но хуже, что он может содержать наполовину записанный фрагмент JSON.
Поскольку программист забыл вернуться после проверки и регистрации ошибки, повреждённый буфер будет передан WriteAll
. Вероятно, операция пройдёт успешно, и поэтому файл конфигурации будет записан неправильно. Однако функция нормально завершается, и единственным признаком того, что произошла проблема — строка в логе, где указан сбой сортировки JSON, а не сбой записи конфигурации.
7.2.1. Добавление контекста к ошибкам
Ошибка произошла, потому что автор пытался добавить контекст в сообщение об ошибке. Он пытался оставить след, чтобы указать на источник ошибки.
Давайте рассмотрим другой способ сделать то же самое через fmt.Errorf
.
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
return fmt.Errorf("could not marshal config: %v", err)
}
if err := WriteAll(w, buf); err != nil {
return fmt.Errorf("could not write config: %v", err)
}
return nil
}
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
return fmt.Errorf("write failed: %v", err)
}
return nil
}
Если совместить запись ошибки с возвратом на одну строку, то сложнее забыть вернуться и избежать случайного продолжения.
Если при записи файла возникает ошибка ввода-вывода, метод Error()
выдаст что-то такое:
could not write config: write failed: input/output error
7.2.2. Обёртывание ошибок с github.com/pkg/errors
Шаблон fmt.Errorf
хорошо работает для записи сообщения об ошибке, но тип ошибки уходит на второй план. Я утверждал, что обработка ошибок как непрозрачных значений важна для слабо связанных проектов, поэтому тип исходной ошибки не должен иметь значения, если нам нужно работать только с её значением:
- Убедиться, что она не равна нулю.
- Вывести её на экран или занести в журнал.
Однако бывает, что нужно восстановить исходную ошибку. Для аннотирования таких ошибок можете использовать что-то вроде моего пакета errors
:
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.WithMessage(err, "could not read config")
}
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Теперь сообщение становится приятной ошибкой в стиле K&D:
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
а её значение содержит ссылку на первоначальную причину.
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Printf("original error: %T %vn", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:n%+vn", err)
os.Exit(1)
}
}
Таким образом, можно восстановить исходную ошибку и вывести трассировку стека:
original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
/Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
/Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
/Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
/Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config
Пакет errors
позволяет добавить контекст к значениям ошибок в удобном формате и для человека, и для машины. На недавней презентации я рассказывал, что в предстоящем релизе Go такая обёртка появится в стандартной библиотеке.
8. Параллелизм
Язык Go часто выбирают из-за его возможностей параллелизма. Разработчики многое сделали, чтобы увеличить его эффективность (с точки зрения аппаратных ресурсов) и производительность, но функции параллелизма Go можно использовать для написания такого кода, который не будет ни производительным, ни надёжным. В окончание статьи хочу дать пару советов, как избежать некоторых подводных камней функций параллелизма Go.
Первоклассная поддержка параллелизма в Go обеспечивается каналами, а также инструкциями select
и go
. Если вы изучали теорию Go по учебникам или в университете, то могли заметить, что раздел параллелизма всегда является одним из последних в курсе. Наша статья ничем не отличается: я решил рассказать о параллелизме в конце, как о чём-то дополнительном к обычным навыкам, которые должен освоить программист Go.
Здесь есть некая дихотомия, ведь главная особенность Go — наша простая, лёгкая модель параллелизма. Как продукт, наш язык продаёт себя за счёт практически одной этой функции. С другой стороны, параллелизм на самом деле не так прост в использовании, иначе авторы не сделали бы его последней главой в своих книгах, и мы не смотрели бы с сожалением на свой код.
В этом разделе рассматриваются некоторые подводные камни наивного использования функций параллелизма Go.
8.1. Постоянно выполняйте какую-то работу
В чём проблема с этой программой?
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
for {
}
}
Программа делает то, что мы задумали: обслуживает простой веб-сервер. В то же время она тратит время процессора в бесконечном цикле, потому что for{}
в последней строке main
блокирует горутину main, не выполняя никаких операций ввода-вывода, нет ожидания блокировки, отправки или получения сообщений или какой-то связи с шедулером.
Поскольку среда выполнения Go обычно обслуживается шедулером, эта программа будет бессмысленно крутиться на процессоре и в конечном итоге может оказаться в активной блокировке (live-lock).
Как это исправить? Вот один вариант.
package main
import (
"fmt"
"log"
"net/http"
"runtime"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
for {
runtime.Gosched()
}
}
Это может выглядеть глупо, но таково распространённое решение, которое мне попадается в реальной жизни. Это симптом непонимания основной проблемы.
Если вы немного более опытны с Go, можете написать что-то вроде такого.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
go func() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}()
select {}
}
Пустой оператор select
блокируется навсегда. Это полезно, потому что теперь мы не крутим весь процессор только для вызова runtime.GoSched()
. Однако мы лечим только симптом, а не причину.
Хочу показать вам ещё одно решение, которое, надеюсь, уже пришло вам в голову. Вместо того, чтобы запускать http.ListenAndServe
в горутине, оставляя проблему горутины main, просто запустите http.ListenAndServe
в основной горутине.
Совет. Если выйти из функции
main.main
, то программа Go безоговорочно завершается независимо от того, что делают другие горутины, запущенные в ходе выполнения программы.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, GopherCon SG")
})
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
Так что это мой первый совет: если горутина не может добиться прогресса, пока не получит результат от другой, то зачастую проще выполнить работу самому, а не делегировать её.
Это часто устраняет много отслеживаний состояния и манипуляций каналом, необходимых для передачи результата обратно от горутины к инициатору процесса.
Совет. Многие программисты Go злоупотребляют горутинами, особенно поначалу. Как и во всём в жизни, ключ к успеху в умеренности.
8.2. Оставьте параллелизм вызывающей стороне
В чём разница между этими двумя API?
// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)
// ListDirectory returns a channel over which
// directory entries will be published. When the list
// of entries is exhausted, the channel will be closed.
func ListDirectory(dir string) chan string
Упомянем очевидные различия: первый пример считывает каталог в срез, а затем возвращает весь срез или ошибку, если что-то пошло не так. Это происходит синхронно, вызывающий блокирует ListDirectory
, пока не прочитаны все записи каталога. В зависимости от того, насколько велик каталог, это может занять много времени и потенциально много памяти.
Рассмотрим второй пример. Он немного больше похож на классическое программирование Go, здесь ListDirectory
возвращает канал, по которому будут передаваться записи каталога. Когда канал закрыт, это признак того, что записей каталога больше нет. Поскольку заполнение канала происходит после возвращения ListDirectory
, то можно предположить запуск горутины для заполнения канала.
Примечание. Во втором варианте необязательно фактически использовать горутину: можно выделить канал, достаточный для хранения всех записей каталога без блокировки, заполнить его, закрыть, а затем вернуть канал вызывающему абоненту. Но это маловероятно, так как в таком случае возникнут те же проблемы с использованием большого объёма памяти для буферизации в канале всех результатов.
У версии ListDirectory
с каналами есть ещё две проблемы:
- Используя закрытый канал в качестве сигнала, что больше нет элементов для обработки,
ListDirectory
не может сообщить вызывающему объекту о неполном наборе элементов из-за ошибки. У вызывающего нет никакого способа передать разницу между пустым каталогом и ошибкой. В обоих случаях, похоже, канал будет немедленно закрыт. - Вызывающий должен продолжать чтение из канала, когда тот закрыт, потому что это единственный способ понять, что горутина заполнения канала перестала работать. Это серьёзное ограничение на использование
ListDirectory
: вызывающий тратит время на чтение из канала, даже если получил все необходимые данные. Вероятно, это более эффективно с точки зрения использования памяти для средних и больших каталогов, но метод не быстрее, чем исходный метод на основе среза.
В обоих случаях решение проблемы заключается в использовании обратного вызова: функции, которая вызывается в контексте каждой записи каталога по мере выполнения.
func ListDirectory(dir string, fn func(string))
Неудивительно что функция filepath.WalkDir
работает именно так.
Совет. Если ваша функция запускает горутину, необходимо предоставить вызывающему объекту способ явно остановить эту рутину. Часто проще всего оставить асинхронный режим выполнения для вызывающего объекта.
8.3. Никогда не запускайте горутину, не зная, когда она остановится
В предыдущем примере горутина использовалась без необходимости. Но одно из главных преимуществ Go — первоклассные возможности параллелизма. Действительно, во многих случаях параллельная работа вполне уместна, и тогда необходимо использовать горутины.
Это простое приложение обслуживает http-трафик на двух разных портах: порт 8080 для трафика приложения и порт 8001 для доступа к конечной точке /debug/pprof
.
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug
http.ListenAndServe("0.0.0.0:8080", mux) // app traffic
}
Хотя программа несложная, это основа реального приложения.
У приложения в его нынешнем виде есть несколько проблем, которые будут проявляться по мере роста, поэтому давайте сразу рассмотрим некоторые из них.
func serveApp() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
http.ListenAndServe("0.0.0.0:8080", mux)
}
func serveDebug() {
http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}
func main() {
go serveDebug()
serveApp()
}
Разбив обработчики serveApp
и serveDebug
на отдельные функции, мы отделили их от main.main
. Мы также следовали предыдущим советам и убедились, что serveApp
и serveDebug
оставили задачу по обеспечению параллелизма вызывающему объекту.
Но есть некоторые проблемы с работоспособностью такой программы. Если мы выходим из serveApp
, а затем из main.main
, то программа завершает работу и будет перезапущена менеджером процессов.
Совет. Как функции в Go оставляют параллелизм вызывающему объекту, так и приложения должны оставить работу по мониторингу своего состояния и перезапуску той программе, которая их вызвала. Не делайте ваши приложения ответственными за перезапуск самих себя: эту процедуру лучше обрабатывать извне приложения.
Однако serveDebug
запускается в отдельной горутине, и в случае её выхода горутина завершается, в то время как остальная часть программы продолжается. Вашим девопсам не понравится, что нельзя получить статистику приложения, потому что обработчик /debug
давно перестал работать.
Нам нужно убедиться в закрытии приложения, если останавливается любая горутина, обслуживающая его.
func serveApp() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {
log.Fatal(err)
}
}
func serveDebug() {
if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil {
log.Fatal(err)
}
}
func main() {
go serveDebug()
go serveApp()
select {}
}
Теперь serverApp
и serveDebug
проверяют ошибки от ListenAndServe
и в случае необходимости вызывают log.Fatal
. Поскольку оба обработчика работают в горутинах, мы оформляем основную рутину в select{}
.
У такого подхода ряд проблем:
- Если
ListenAndServe
возвращается с ошибкойnil
, то не произойдёт вызоваlog.Fatal
, а служба HTTP на этом порту завершит работу без остановки приложения. log.Fatal
вызываетos.Exit
, который безоговорочно выйдет из программы; отсроченые вызовы не сработают, другие горутины не не будут уведомлены о закрытии, программа просто остановится. Это затрудняет написание тестов для данных функций.
Совет. Используйте только
log.Fatal
от функцийmain.main
илиinit
.
На самом деле мы хотим передать любую возникающую ошибку создателю горутины, чтобы он мог узнать, почему та остановилась, и чисто завершил процесс.
func serveApp() error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
return http.ListenAndServe("0.0.0.0:8080", mux)
}
func serveDebug() error {
return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}
func main() {
done := make(chan error, 2)
go func() {
done <- serveDebug()
}()
go func() {
done <- serveApp()
}()
for i := 0; i < cap(done); i++ {
if err := <-done; err != nil {
fmt.Println("error: %v", err)
}
}
}
Статус возврата горутин можно получать по каналу. Размер канала равен количеству горутин, которыми мы хотим управлять, так что отправка на канал done
не будет блокироваться, так как это заблокирует отключение горутин и вызовет утечку.
Поскольку канал done
никак нельзя безопасно закрыть, мы не можем использовать для цикла канала идиому for range
, пока не отчитались все горутины. Вместо этого мы прогоняем по циклу все запущенные горутины, что равно ёмкости канала.
Теперь у нас есть способ чистого выхода из каждой горутины и фиксации всех ошибок, с которыми они сталкиваются. Осталось только отправить сигнал о завершении работы из первой горутины всем остальным.
Тут немного задействуется обращение к http.Server
о завершении работы, поэтому я завернул эту логику во вспомогательную функцию. Хелпер serve
принимает адрес и http.Handler
, аналогично http.ListenAndServe
, а также канал stop
, который мы используем для запуска метода Shutdown
.
func serve(addr string, handler http.Handler, stop <-chan struct{}) error {
s := http.Server{
Addr: addr,
Handler: handler,
}
go func() {
<-stop // wait for stop signal
s.Shutdown(context.Background())
}()
return s.ListenAndServe()
}
func serveApp(stop <-chan struct{}) error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
fmt.Fprintln(resp, "Hello, QCon!")
})
return serve("0.0.0.0:8080", mux, stop)
}
func serveDebug(stop <-chan struct{}) error {
return serve("127.0.0.1:8001", http.DefaultServeMux, stop)
}
func main() {
done := make(chan error, 2)
stop := make(chan struct{})
go func() {
done <- serveDebug(stop)
}()
go func() {
done <- serveApp(stop)
}()
var stopped bool
for i := 0; i < cap(done); i++ {
if err := <-done; err != nil {
fmt.Println("error: %v", err)
}
if !stopped {
stopped = true
close(stop)
}
}
}
Теперь на каждое значение в канале done
мы закрываем канал stop
, что заставляет каждую горутину на этом канале закрыть свой http.Server
. В свою очередь, это приводит к возврату из всех оставшихся горутин ListenAndServe
. Когда все запущенные горутины остановились, main.main
завершается и процесс останавливается чисто.
Совет. Самостоятельно писать такую логику — это повторная работа и риск ошибиться. Посмотрите на что-нибудь вроде этого пакета, который сделает за вас бóльшую часть работы.
Автор: m1rko