Практический Go: практические советы по написанию программ на Go, которые легко поддерживать
Практические рекомендации Дэйва Чейни, по написанию кода на Go. Рекомендации затрагивают различные аспекты разработки на Go, от основных руководящих принципов до рекомендаций по обработке ошибок. Объёмный материал для вдумчивого чтения.
Вступление
Моя цель в течение следующих двух сессий — дать вам рекомендации по написанию кода на Go.
Это презентация в стиле семинара, я не буду использовать обычный формат презентации, и мы будем работать непосредственно с документом, который вы сегодня можете взять с собой.
1. Руководящие принципы
Если я собираюсь говорить о передовом опыте в каком-либо языке программирования, то мне нужно каким-то образом определить, что я имею в виду под словом «передовой». Если бы вы вчера пришли на мой доклад, вы бы увидели эту цитату Расса Кокса руководителя команды Go:
Программная инженерия — это то, во что превращается программирование, когда вы добавляете время и других программистов.
— Расс Кокс
Расс проводит различие между программированием при создании программного обеспечения и программной инженерией . Первое — это программа, которую вы пишете для себя, второе — продукт, над которым со временем будут работать множество людей. Инженеры будут приходить и уходить, команды будут расти и уменьшаться, требования будут меняться, будут добавляться функции и устраняться ошибки. Такова природа разработки программного обеспечения.
Я, возможно, один из первых разработчиков на Go в этой комнате, но утверждать, что мой стаж придает моим взглядам больший вес, неверно . Вместо этого совет, который я собираюсь дать сегодня, основан на том, что я считаю руководящими принципами, лежащими в основе самого Go. Они есть:
ПРИМЕЧАНИЕ. Вы заметите, что я не сказал производительность или параллелизм . Есть языки, которые немного быстрее, чем Go, но они определенно не такие простые, как Go. Есть языки, которые делают параллелизм своей высшей целью, но они не такие удобочитаемые и не такие продуктивные как Go.
Производительность и параллелизм являются важными атрибутами, но не такими важными, как простота , удобочитаемость и производительность .
1.1. Простота
Простота - необходимое условие надежности.
— Эдсгер В. Дейкстра
Почему мы должны стремиться к простоте? Почему важно, чтобы программы Go были простыми?
Мы все были в ситуации, когда вы говорите: "Я не могу понять этот код", да? Мы все работали над программами, в которых вы боитесь вносить изменения, потому что боитесь, что это нарушит другую часть программы; часть, которую вы не понимаете и не знаете, как исправить. Это сложность.
Существует два способа проектирования программного обеспечения: один способ - сделать его настолько простым, чтобы не было очевидных недостатков, а другой способ - сделать его настолько сложным, чтобы не было очевидных недостатков. Первый метод намного сложнее.
— C. A. R. Хоар
Сложность превращает надежное программное обеспечение в ненадежное программное обеспечение. Сложность - это то, что убивает программные проекты. Поэтому простота - высшая цель Go. Какие бы программы мы ни писали, мы должны быть в состоянии согласиться с тем, что они просты.
1.2. Удобочитаемость
Удобочитаемость важна для удобства обслуживания.
— Mark Reinhold Языковой саммит JVM 2018
Почему важно, чтобы код Go был читаемым? Почему мы должны стремиться к удобочитаемости?
Программы должны быть написаны для чтения людьми и только случайно для выполнения машинами.
— Хэл Абельсон и Джеральд Сассман "Структура и интерпретация компьютерных программ"
Удобочитаемость важна, потому что все программное обеспечение, а не только программы Go, написаны людьми для чтения другими людьми. Тот факт, что программное обеспечение также используется машинами, является второстепенным.
Код читается гораздо чаще, чем пишется. Один фрагмент кода за время своего существования будет прочитан сотни, может быть, тысячи раз.
Самый важный навык для программиста - это умение эффективно передавать идеи.
— Gastón Jorquera
Удобочитаемость является ключом к пониманию того, что делает программа. Если вы не можете понять, что делает программа, как вы можете надеяться на ее поддержку? Если программное обеспечение не может поддерживаться, оно будет переписано; и это может быть последний раз, когда ваша компания инвестирует в Go.
Если вы пишете программу для себя, возможно, она должна быть запущена только один раз, или вы единственный человек, который когда-либо ее увидит, тогда делайте то, что работает для вас. Но если это часть программного обеспечения, в разработку которой будут участвовать несколько человек или которая будет использоваться людьми в течение достаточно долгого времени, в течение которого меняются требования, функции или среда, в которой она выполняется, тогда ваша цель должна заключаться в том, чтобы ваша программа была поддерживаемой.
Первым шагом к написанию поддерживаемого кода является обеспечение того, чтобы код был читабельным.
1.3. Производительность
Дизайн - это искусство организации кода таким образом, чтобы он работал сегодня и был изменяемым вечно.
— Сэнди Мец
Последний основополагающий принцип, который я хочу подчеркнуть - это производительность. Производительность разработчика - обширная тема, но она сводится к следующему: сколько времени вы тратите на полезную работу, а не на ожидание инструментов или безнадежно теряетесь в чужой кодовой базе. Программисты Go должны чувствовать, что они могут многое сделать с Go.
Шутка заключается в том, что Go был разработан как следствие ожидания компиляции программы на C++. Быстрая компиляция - ключевая особенность Go и ключевой инструмент рекрутинга для привлечения новых разработчиков. В то время как скорость компиляции остается постоянным полем битвы, справедливо будет сказать, что компиляции, которые занимают минуты на других языках, занимают секунды. Это помогает разработчикам Go чувствовать себя такими же продуктивными, как и их коллеги, работающие на динамических языках, без проблем с надежностью, присущих таким языкам.
Что более фундаментально в вопросе производительности разработчиков, то что программисты Go понимают, что код пишется для чтения, и поэтому ставят процесс чтения кода выше процесса его написания. Go заходит так далеко, что с помощью инструментов и пользовательских настроек обеспечивает форматирование всего кода в определенном стиле. Это устраняет трудности, связанные с изучением диалекта конкретного проекта, и помогает выявлять ошибки, потому что они просто выглядят неправильными.
Программисты Go не тратят дни на отладку непостижимых ошибок компиляции. Они не тратят дни на сложные сценарии сборки или развертывание кода в рабочей среде. И самое главное, они не тратят свое время, пытаясь понять, что написал их коллега.
Производительность - это то, что имеет в виду команда Go, когда говорит, что язык должен масштабироваться.
2. Идентификаторы
Первая тема, которую мы собираемся обсудить - это идентификаторы. Идентификатор - это причудливое слово для обозначения имени; имя переменной, имя функции, имя метода, имя типа, имя пакета и так далее.
Плохое именование является симптомом плохого дизайна.
— Дейв Чейни
Первая тема, которую мы собираемся обсудить - это идентификаторы. Идентификатор - это причудливое слово для обозначения имени; имя переменной, имя функции, имя метода, имя типа, имя пакета и так далее.
Плохое именование является симптомом плохого дизайна.
— Дейв Чейни
Учитывая ограниченный синтаксис Go, имена, которые мы выбираем для объектов в наших программах, оказывают огромное влияние на удобочитаемость наших программ. Удобочитаемость - это определяющее качество хорошего кода, поэтому выбор хороших имен имеет решающее значение для удобочитаемости кода Go.
2.1. Выбирайте идентификаторы для наглядности, а не для краткости
Очевидный код очень важен. То, что вы можете сделать в одной строке, вы должны сделать в трех.
— Укия Смит
Go - это не тот язык, который оптимизирован для умных однострочников. Go - это не тот язык, который оптимизируется для наименьшего количества строк в программе. Мы не оптимизируем размер исходного кода на диске и время, необходимое для ввода программы в редактор.
Хорошее название - это как хорошая шутка. Если вам нужно это объяснить, то это не смешно.
— Дейв Чейни
Ключом к этой ясности являются имена, которые мы выбираем для идентификации в программах 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
объявлена в строке 10 и упоминается только один раз, в следующей строке. p
живет очень короткое время как на странице, так и во время выполнения функции. Читателю, который интересуется влиянием значений p
на программу, нужно прочитать только две строки.
Путем сравнения people
объявляется в параметрах функции и живет в течение семи строк. То же самое верно и для sum
, и count
, таким образом, они оправдывают свои более длинные названия. Читателю приходится сканировать большее количество строк, чтобы найти их, чтобы им были присвоены более характерные названия.
Я мог бы выбрать s
для sum
и c
(или, возможноn
) для count
, но это привело бы к тому, что все переменные в программе были бы сведены к одному и тому же уровню важности. Я мог бы выбрать p
вместоpeople
, но это оставило бы проблему того, как называть переменную for … range
итерации. Единственное число person
выглядело бы странно, поскольку переменная итерации цикла, которая живет в течение небольшого времени, имеет более длинное имя, чем фрагмент значений, из которого она была получена.
СОВЕТ. Используйте пустые строки, чтобы разбить поток функций так же, как вы используете абзацы, чтобы разбить поток документа. В AverageAge
у нас есть три операции, выполняемые последовательно. Первое - это предварительное условие, проверяющее, что мы не делим на ноль, если people пусто, второе - это накопление суммы и подсчет, и последнее - вычисление среднего значения.
2.2.1. Контекст является ключевым
Важно понимать, что большинство советов по именованию носят контекстуальный характер. Мне нравится говорить, что это принцип, а не правило.
В чем разница между двумя идентификаторами, i
, и index
. Мы не можем однозначно сказать, что одно лучше другого, например
for index := 0; index < len(s); index++ { // }
принципиально более удобочитаемый, чем
для 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, поэтому сокращение его до o
будет означать, что программистам придется переводить из обычных обозначений, которые они читают в документации, в более короткие обозначения в вашем коде. Аналогично, сокращение index
до i
скрывает, что i
означает, как в сообщениях SNMP, значение каждого OID называется индексом.
СОВЕТ. Не смешивайте и не сопоставляйте длинные и короткие формальные параметры в одном объявлении.
2.3. Не присваивайте своим переменным имена их типов
Вы не должны называть свои переменные по их типам по той же причине, по которой вы не называете своих домашних животных "собака" и "кошка". Вам также, вероятно, не следует включать имя вашего типа в имя имени вашей переменной по той же причине.
Имя переменной должно описывать ее содержимое, а не тип содержимого.
var usersMap map[string]*User
Что хорошего в этом заявлении? Мы видим, что это карта, и она имеет какое-то отношение к *User
типу, это, вероятно, хорошо. Но usersMap
это map, и Go, являющийся статически типизированным языком, не позволит нам случайно использовать его там, где требуется скалярная переменная, поэтому Map
суффикс является избыточным.
Теперь рассмотрим, что произойдет, если мы объявим другие переменные, такие как:
var ( companiesMap map[string]*Company productsMap map[string]*Products )
Теперь у нас есть три переменные типа map (сопоставления) в области видимости, usersMap
, companiesMap
, и productsMap
, все строки сопоставления относятся к разным типам. Мы знаем, что это карты, и мы также знаем, что их объявления map не позволяют нам использовать одно вместо другого — компилятор выдаст ошибку, если мы попытаемся использовать companiesMap
там, где код ожидает a 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
Это способствует узнаваемости; если вы видите a db
, вы знаете, что это a *sql.DB
и что оно либо объявлено локально, либо предоставлено вам вызывающим абонентом.
Аналогичный совет применим к приемникам методов; используйте одно и то же имя приемника для каждого метода этого типа. Это облегчает читателю усвоение использования приемника во всех методах этого типа.
ПРИМЕЧАНИЕ. Соглашение о коротких именах получателей в Go расходится с рекомендациями, представленными до сих пор. Это всего лишь один из вариантов, сделанных на раннем этапе, который стал предпочтительным стилем, точно так же, как использование CamelCase
вместо snake_case
.
СОВЕТ. Стиль Go диктует, что приёмники должны иметь однобуквенное имя или аббревиатуры, производные от их типа. Вы можете обнаружить, что имя вашего приёмника иногда конфликтует с именем параметра в методе. В этом случае подумайте о том, чтобы сделать имя параметра немного длиннее, и не забывайте постоянно использовать это новое имя параметра.
Наконец, некоторые однобуквенные переменные традиционно ассоциировались с циклами и подсчетом. Например, i
, j
, и k
обычно являются переменной индукции цикла для простых for
циклов. n
обычно ассоциируется со счетчиком или аккумулятором. v
является общим сокращением для значения в общей функции кодирования, k
обычно используется для ключа карты и s
часто используется в качестве сокращения для параметров типаstring
.
Как db
и в приведенном выше примере, программисты ожидают i
, что это будет переменная индукции цикла. Если вы убедитесь, что i
это всегда переменная цикла, не используемая в других контекстах вне for
цикла. Когда читатели сталкиваются с переменной, вызываемойi
, или j
, они знают, что цикл находится рядом.
СОВЕТ. Если вы столкнулись с таким количеством вложенных циклов, что исчерпали свой запас переменных i
, j
, и k
, вероятно, пришло время разбить вашу функцию на более мелкие части.
2.5. Используйте согласованный стиль объявления
Go имеет по крайней мере шесть различных способов объявления переменной
Я уверен, что есть еще много вещей, о которых я не подумал. Дизайнеры Go признают, что это, вероятно, было ошибкой, но сейчас уже слишком поздно что-то менять. Со всеми этими различными способами объявления переменной, как нам избежать того, чтобы каждый программист Go выбирал свой собственный стиль?
Я хочу представить предложения о том, как я объявляю переменные в своих программах. Это стиль, который я стараюсь использовать там, где это возможно.
При объявлении, но не инициализации переменной используйте var
. При объявлении переменной, которая будет явно инициализирована позже в функции, используйте ключевое слово 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
, а не короткой формы объявления, хотя позже я буду утверждать, что вам вообще не следует использовать переменные уровня пакета.
При объявлении и инициализации используйте :=
. При одновременном объявлении и инициализации переменной, то есть мы не позволяем переменной неявно инициализироваться до ее нулевого значения, я рекомендую использовать короткую форму объявления переменной. Это дает понять читателю, что переменная в левой части :=
инициализируется намеренно.
Чтобы объяснить почему, давайте посмотрим на предыдущий пример, но на этот раз намеренно инициализируем каждую переменную:
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
, которая является избыточной, поскольку 0
является нулевым значением для "игроков". Поэтому лучше дать понять, что мы собираемся использовать нулевое значение, вместо этого написав
Как насчет второго утверждения? Мы не можем исключить тип и написать
Потому nil
что у него нет типа. [2] Вместо этого у нас есть выбор, хотим ли мы нулевое значение для среза?
или мы хотим создать срез с нулевыми элементами?
Если мы хотели последнее, то это не нулевое значение для среза, поэтому мы должны дать понять читателю, что мы делаем этот выбор, используя форму краткого объявления:
Который сообщает читателю, что мы решили инициализировать things
явно.
Это подводит нас к третьему объявлению,
Который одновременно явно инициализирует переменную и вводит необычное использование ключевого слова new
, которое не нравится некоторым программистам Go. Если мы применим нашу краткую рекомендацию по синтаксису объявления, то утверждение станет
Что дает thing
понять , что это явно инициализируется результатом new(Thing)
- указателем на a Thing
, но все равно оставляет нас с необычным использованием new
. Мы могли бы решить эту проблему, используя форму инициализатора компактной литеральной структуры,
Который делает то же new(Thing)
самое, что и, следовательно, почему некоторые программисты Go расстроены дублированием. Однако это означает, что мы явно инициализируем thing
с помощью указателя на a Thing{}
, который является нулевым значением для a Thing
.
Вместо этого мы должны признать, что thing
это объявляется как его нулевое значение, и использовать адрес оператора для передачи адреса thing
в json.Unmarshall
var thing Thing json.Unmarshall(reader, &thing)
ПРИМЕЧАНИЕ. Конечно, из любого эмпирического правила есть исключения. Например, иногда две переменные тесно связаны, поэтому написание
var min int max : = 1000
Было бы странно. Объявление может быть более читаемым, например
min, max: = 0, 1000
- При объявлении переменной без инициализации используйте
var
синтаксис. - При объявлении и явной инициализации переменной используйте
:=
.
СОВЕТ. Сделайте очевидными сложные объявления.
Когда что-то является сложным, оно должно выглядеть сложным.
Здесь length может использоваться с библиотекой, которая требует определенного числового типа и является более явной, чем length
с явным приведением к uint32
в короткой форме объявления:
В первом примере я намеренно нарушаю свое правило использования формы var
объявления с явным инициализатором. Это решение отличаться от моей обычной формы - что является подсказкой читателю, что происходит что-то необычное.
2.6. Будьте командным игроком
Я говорил о цели разработки программного обеспечения - создавать читаемый, поддерживаемый код. Поэтому вы, скорее всего, проведете большую часть своей карьеры, работая над проектами, где вы не являетесь единственным автором. Мой совет в этой ситуации - следовать местному стилю.
Изменение стилей в середине файла вызывает раздражение. Единообразие, даже если это не ваш предпочтительный подход, более ценно для обслуживания, чем ваши личные предпочтения. Мое эмпирическое правило таково; если это подходит, gofmt
то обычно не стоит проводить проверку кода.
СОВЕТ. Если вы хотите выполнить переименование в базе кода, не смешивайте это с другим изменением. Если кто-то использует git bisect, он не хочет перебирать тысячи строк переименования, чтобы найти код, который вы также изменили.
3. Комментарии
Прежде чем мы перейдем к более крупным пунктам, я хочу потратить несколько минут на обсуждение комментариев.
Хороший код содержит много комментариев, плохой код требует много комментариев.
— Дейв Томас и Эндрю Хант "Прагматичный программист"
Комментарии очень важны для удобства чтения программы Go. Каждый комментарий должен выполнять одну — и только одну — из трех вещей:
- В комментарии должно быть объяснено, что эта штука делает.
- В комментарии должно быть объяснено, как вещь делает то, что она делает.
- В комментарии должно быть объяснено, почему это так, а не иначе.
Первая форма идеально подходит для комментариев к публичным символам:
// Open открывает указанный файл для чтения. // В случае успеха методы возвращенного файла можно использовать для чтения.
Вторая форма идеально подходит для комментариев внутри метода:
// поставить в очередь все зависимые действия var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) }
Третья форма, почему, уникальна, поскольку она не вытесняет первые две, и не является заменой что или как. Стиль комментариев "почему" существует для объяснения внешних факторов, которые повлияли на код, который вы читаете на странице. Часто эти факторы редко имеют смысл, вырванные из контекста, комментарий существует для обеспечения этого контекста.
return &v2.Cluster_CommonLbConfig{ // Disable HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, }
В этом примере может быть не сразу ясно, какой эффект будет иметь нулевое значение процента для HealthyPanicThreshold
. Комментарий необходим, чтобы уточнить, что 0 значение отключит поведение порога паники.
3.1. Комментарии к переменным и константам должны описывать их содержимое, а не их назначение
Ранее я заявлял, что имя переменной или константы должно описывать ее назначение. Когда вы добавляете комментарий к переменной или константе, этот комментарий должен описывать содержимое переменных, а не назначение переменных.
const randomNumber = 6 // determined from an unbiased die
В этом примере комментарий описывает, почемуrandomNumber
присвоено значение шесть и откуда было получено значение шесть. В комментарии не описывается, где 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
.
Скрытие на виду
Это совет от Кейт Грегори. Иногда вы можете найти лучшее имя для переменной, которое скрывается в комментарии.
// registry of SQL drivers var registry = make(map[string]*sql.Driver)
Комментарий был добавлен автором, потому registry
что недостаточно объясняет его назначение — это реестр, но реестр чего?
Переименовав переменную в sqlDrivers
its, теперь ясно, что целью этой переменной является хранение драйверов SQL.
var sqlDrivers = make(map[string]*sql.Driver)
Теперь комментарий является избыточным и может быть удален.
3.2. Всегда документируйте общедоступные символы
Поскольку godoc является документацией для вашего пакета, вы всегда должны добавлять комментарий к каждому общедоступному символу — переменной, константе, функции и методу — объявленному в вашем пакете.
Вот два правила из руководства по стилю Google
- Любая публичная функция, которая не является одновременно очевидной и краткой, должна быть прокомментирована.
- Любая функция в библиотеке должна быть прокомментирована независимо от длины или сложности
package ioutil // ReadAll читает из r до ошибки или EOF и возвращает прочитанные данные. // Успешный вызов возвращает err == nil, а не err == EOF. Поскольку ReadAll // определено для чтения из src до EOF, он не обрабатывает EOF из Read // как ошибку, о которой нужно сообщить. func ReadAll(r io.Reader) ([]byte, error)
Существует одно исключение из этого правила; вам не нужно документировать методы, реализующие интерфейс. В частности, не делайте так:
// Read реализует интерфейс io.Reader func (r *FileReader) Read(buf []byte) (int, error)
Этот комментарий ни о чем не говорит. Он не говорит вам, что делает метод, на самом деле это хуже, он говорит вам искать документацию в другом месте. В этой ситуации я предлагаю полностью удалить комментарий.
// LimitReader returns a Reader that reads from r // but stops with EOF after n bytes. // The underlying implementation is a *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // A LimitedReader reads from R but limits the amount of // data returned to just N bytes. Each call to Read // updates N to reflect the new amount remaining. // Read returns EOF when N <= 0 or when the underlying R returns 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(vasya) это O(N^2), найти более быстрый способ
Имя пользователя не является обещанием, что этот человек взял на себя обязательство устранить проблему, но, возможно, это лучший человек, которого можно спросить, когда придет время для ее решения. Другие проекты помечают задачи датой или номером проблемы.
3.2.2. Вместо того, чтобы комментировать блок кода, реорганизуйте его
Хороший код - это его собственная лучшая документация. Когда вы собираетесь добавить комментарий, спросите себя: "Как я могу улучшить код, чтобы этот комментарий не понадобился?" Улучшите код, а затем задокументируйте его, чтобы сделать его еще более понятным.
— Стив Макконнелл
Функции должны делать только что-то одно. Если вы обнаружите, что комментируете фрагмент кода, потому что он не связан с остальной частью функции, рассмотрите возможность извлечения его в отдельную функцию.
Помимо того, что их легче понять, маленькие функции легче тестировать изолированно. После того, как вы выделили ортогональный код в его собственную функцию, его имя может стать необходимой документацией.
4. Дизайн пакета
Пишите скромный код - модули, которые не раскрывают ничего ненужного другим модулям и которые не полагаются на реализации других модулей.
— Дэйв Томас
Каждый пакет Go фактически представляет собой собственную небольшую программу Go. Так же, как реализация функции или метода не важна для вызывающего, реализация функций, методов и типов, составляющих общедоступный API вашего пакета — его поведение — не имеет значения для вызывающего.
Хороший пакет Go должен стремиться к низкой степени связи на уровне исходного кода, чтобы по мере роста проекта изменения в одном пакете не распространялись каскадом по всей кодовой базе. Рефакторинг типа "останови мир", накладывает жесткие ограничения на скорость изменений в кодовой базе и, следовательно, на производительность участников, работающих с этой кодовой базой.
В этом разделе мы поговорим о разработке пакета, включая типы именования пакетов, а также советы по написанию методов и функций.
4.1. Хороший пакет начинается с его названия
Написание хорошего пакета Go начинается с имени пакета. Подумайте о названии вашего пакета как о подсказке в лифте, чтобы описать, что он делает, используя только одно слово.
Так же, как я говорил об именах переменных в предыдущем разделе, имя пакета очень важно. Эмпирическое правило, которому я следую, заключается не в том, "какие типы я должен поместить в этот пакет?". Вместо этого я задаю вопрос: "Что предоставляет пакет услуг?" Обычно ответ на этот вопрос звучит не "этот пакет предоставляет тип X", а "этот пакет позволяет вам использовать HTTP".
СОВЕТ. Назовите свой пакет как, чтобы описать то, что он предоставляет, а не то, что он содержит.
4.1.1. Хорошие имена пакетов должны быть уникальными.
В рамках вашего проекта имя каждого пакета должно быть уникальным. Реализовать это довольно легко, если вы следовали совету о том, что имя пакета должно вытекать из его назначения. Если вы обнаружите, что у вас есть два пакета, которым нужно одно и то же имя, скорее всего, либо;
- Название пакета слишком общее.
- Пакет перекрывает другой пакет с аналогичным именем. В этом случае вам следует либо пересмотреть свой дизайн, либо рассмотреть возможность объединения пакетов.
4.2. Избегайте таких имен пакетов, как base
, common
, или util
Распространенной причиной неправильных имен пакетов является то, что называется служебными пакетами. Это такие пакеты, в которых располагаются помощники и утилиты, код которых застывает со временем. Поскольку эти пакеты содержат набор несвязанных функций, их полезность трудно описать в терминах того, что предоставляет пакет. Это часто приводит к тому, что название пакета происходит от того, что содержит пакет - utils
.
Имена пакетов, подобные utils
или helpers
, обычно встречаются в более крупных проектах, в которых разработаны глубокие иерархии пакетов и необходимо совместно использовать некоторые вспомогательные функции, не сталкиваясь с проблемой цикличности при импорте. При извлечении служебных функций в новый пакет цикл импорта прерывается, но поскольку пакет является результатом проблемы проектирования в проекте, его название не отражает его назначение, а только его функцию прерывания цикла импорта.
Моя рекомендация по улучшению имени utils
или helpers
пакетов заключается в том, чтобы проанализировать, где они вызываются, и, если возможно, переместить соответствующие функции в пакет их вызывающий. Даже если это предполагает дублирование некоторого вспомогательного кода, это лучше, чем введение зависимости импорта между двумя пакетами.
[Коротко] дублирование намного дешевле, чем неправильная абстракция.
— Сэнди Мец
В случае, когда служебные функции используются во многих местах, отдавайте предпочтение нескольким пакетам, каждый из которых ориентирован на один аспект, а не на один монолитный пакет.
СОВЕТ. Используйте множественное число для именования пакетов утилит. Напримерstrings
, утилиты для обработки строк.
Пакеты с именами типа base
или common
часто встречаются, когда функциональность, общая для двух или более реализаций, или общие типы для клиента и сервера, были собраны в отдельный пакет. Я считаю, что решение этой проблемы заключается в сокращении количества пакетов, объединении клиента, сервера и общего кода в единый пакет, названный по функции которую предоставляет пакет.
Например, пакет net/http
не имеет подпакетов client
и server
, вместо этого он имеет файлы client.go и server.go, каждый из которых содержит соответствующие типы, и файл transport.go для общего кода передачи сообщений.
СОВЕТ. Имя идентификатора включает имя самого пакета.
Важно помнить, что имя идентификатора включает и имя его пакета.
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, а условие успешного выхода, возвращающее nil, можно обнаружить только путем тщательного сопоставления закрывающих фигурных скобок. Последняя строка функции теперь возвращает ошибку, и вызываемый объект должен отслеживать выполнение функции до соответствующей открывающей скобки, чтобы знать, когда управление достигнет этой точки.
Подобный код трудно читаем и более подвержен ошибкам со стороны сопровождающего его программиста, поэтому Go предпочитает использовать защитные условия и раньше возвращать ошибки.
4.4. Сделайте нулевое значение полезным
Каждое объявление переменной, при условии отсутствия явного инициализатора, будет автоматически инициализировано значением, которое соответствует содержимому обнуленной памяти. Это нулевое значение значений. Тип значения определяет какое будет нулевое значение; для числовых типов это ноль, для типов указателей это nil, так же как для срезов, карт и каналов.
Такое свойство, всегда устанавливать известное значение по умолчанию важно для безопасности и корректности вашей программы и может сделать ваши программы Go более простыми и компактными. Это то, о чем говорят программисты Go, когда говорят: «Придайте вашим структурам полезное нулевое значение».
Рассмотрим тип sync.Mutex
. Тип sync.Mutex
содержит два неэкспортируемых целочисленных поля, представляющих внутреннее состояние мьютекса. Благодаря нулевому значению для этих полей будет установлено значение 0 всякий раз, когда объявляется 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
. Вы можете объявить bytes.Buffer
и сразу начать писать в него без явной инициализации.
func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) }
Полезным свойством срезов является то, что их нулевое значение равно nil
. Это имеет смысл, если мы посмотрим на определение заголовка среза во время выполнения.
type slice struct { array *[...]T // указатель на базовый массив len int cap int }
Нулевое значение этой структуры будет означать, что 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)) }
Полезное, хотя и неожиданное свойство неинициализированных переменных-указателей — нулевых указателей — заключается в том, что вы можете вызывать методы для типов, которые имеют нулевое значение. Это можно использовать для простого предоставления значений по умолчанию.
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. В будущем разработчики Go будут использовать термины модуль и проект как взаимозаменяемые.
Как и у пакета, у каждого проекта должна быть четкая цель. Если ваш проект представляет собой библиотеку, он должен предоставлять что-то одно, скажем, синтаксический анализ XML или ведение журнала. Вам следует избегать объединения нескольких целей в один проект, это поможет избежать страшной common
библиотеки.
СОВЕТ. По моему опыту, common
репозиторий в конечном итоге будет тесно связан с его крупнейшим потребителем, что затрудняет внесение исправлений без обновления как самого common
, так и потребителя на этапе блокировки, что попутно приводит к множеству несвязанных изменений и поломке API.
Если ваш проект представляет собой приложение, такое как веб-приложение, контроллер Kubernetes и т.п., то в вашем проекте может быть один или несколько main
пакетов. Например, контроллер Kubernetes, над которым я работаю, имеет единый пакет cmd/contour
, который служит одновременно сервером, развернутым в кластере Kubernetes, и клиентом для целей отладки.
5.1. Подумайте о меньшем количестве пакетов большого размера
Одна из вещей, которую я обычно отмечаю в code review для программистов, переходящих с других языков на Go, заключается в том, что они склонны злоупотреблять пакетами.
Go не предоставляет сложных способов создания видимости. В Go отсутствуют Java public
, protected
, private
, и неявные default
модификаторы доступа. Не существует эквивалента понятия friend
классов из C++.
В Go у нас есть только два модификатора доступа, public
и private
, обозначаемые заглавными буквами первой буквы идентификатора. Если идентификатор является общедоступным, его имя начинается с заглавной буквы, и на этот идентификатор может ссылаться любой другой пакет Go.
ПРИМЕЧАНИЕ. Вы можете услышать, как люди говорят "экспортируется" и "не экспортируется" как синонимы для публичный и приватный.
Учитывая ограниченные средства управления, доступные для управления доступом к символам пакета, каким подходам должны следовать программисты Go, чтобы избежать создания чрезмерно сложных иерархий пакетов?
СОВЕТ. Каждый пакет, за исключением cmd/
и internal/
, должен содержать исходный код.
Совет, который я часто повторяю, заключается в том, чтобы отдавать предпочтение небольшому количеству больших пакетов. Ваша позиция по умолчанию должна заключаться в том, чтобы не создавать новый пакет. Это приведет к тому, что слишком много типов станут общедоступными, создавая широкую, неглубокую поверхность API для вашего пакета.
В разделах ниже это предложение рассматривается более подробно.
СОВЕТ. Перешли с Java?
Если вы работаете с Java или C#, примите во внимание это эмпирическое правило. Пакет Java эквивалентен одному исходному файлу .go. Пакет Go эквивалентен целому модулю Maven или сборке .NET.
5.1.1. Упорядочить код в файлах с помощью операторов импорта
Если вы упорядочиваете свои пакеты по тому, что они предоставляют абонентам, должны ли вы сделать то же самое для файлов в пакете Go? Как вы узнаете, когда вам следует разбить .go
файл на несколько файлов? Как вы узнаете, когда зашли слишком далеко и стоит подумать о объединении .go
файлов?
Вот рекомендации, которые я использую:
- Начинайте каждый пакет с одного
.go
файла. Дайте этому файлу то же имя, что и имя папки. например.package http
должен быть помещен в файл, вызываемыйhttp.go
в каталоге с именемhttp
. - По мере роста вашего пакета вы можете разделить различные обязанности на разные файлы. например,
messages.go
содержит типыRequest
иResponse
,client.go
содержит типClient
,server.go
содержит типServer
. - Если вы обнаружите, что ваши файлы имеют похожие объявления
import
, подумайте о том, чтобы объединить их. В качестве альтернативы, определите различия между наборами импорта и переместите их. - Разные файлы должны отвечать за разные области пакета.
messages.go
может отвечать за маршалинг HTTP-запросов и ответов в сети и за ее пределами,http.go
может содержать низкоуровневую логику обработки сетиclient.go
иserver.go
реализовывать бизнес-логику HTTP для построения или маршрутизации запросов и так далее.
СОВЕТ. Предпочитайте существительные для имен исходных файлов.
ПРИМЕЧАНИЕ. Компилятор Go компилирует каждый пакет параллельно. Внутри пакета компилятор компилирует каждую функцию (методы - это просто причудливые функции в Go) параллельно. Изменение разметки вашего кода в пакете не влияет на время компиляции.
5.1.2. Предпочитайте внутренние тесты внешним тестам
Инструмент go
поддерживает написание тестов пакета тестирования в двух местах. Предполагая, что ваш пакет называется http2
, вы можете написать файл http2_test.go
и использовать объявление пакета http2
. Это приведет к компиляции кода в http2_test.go
, как если бы он был частью пакета http2
. В просторечии это называется внутренним тестом.
Инструмент go
также поддерживает специальное объявление пакета, оканчивающееся на test, т. е. package http_test
. Это позволяет вашим тестовым файлам жить вместе с вашим кодом в одном пакете, однако, когда эти тесты скомпилированы, они не являются частью кода вашего пакета, они живут в своем собственном пакете. Это позволяет вам писать свои тесты, как если бы вы были другим пакетом, вызывающим ваш код. В просторечии внешний тест.
Я рекомендую использовать внутренние тесты при написании модульных тестов для вашего пакета. Это позволяет тестировать каждую функцию или метод напрямую, избегая бюрократии внешнего тестирования.
Тем не менее, вы должны поместить свои Example
тестовые функции во внешний тестовый файл. Это гарантирует, что при просмотре в godoc примеры будут иметь соответствующий префикс пакета и могут быть легко скопированы и вставлены.
СОВЕТ. Избегайте сложных иерархий пакетов, сопротивляйтесь желанию применять таксономию. За одним исключением, о котором мы поговорим далее, иерархия пакетов Go не имеет никакого значения для go
инструмента. Например, net/http
пакет не является дочерним или подпакетом net
пакета.
Если вы обнаружите, что создали промежуточные каталоги в своем проекте, которые не содержат .go
файлов, возможно, вы не выполнили этот совет.
5.1.3. Используйте internal
пакеты для уменьшения поверхности публичного API
Если ваш проект содержит несколько пакетов, вы можете обнаружить, что у вас есть некоторые экспортированные функции, которые предназначены для использования другими пакетами в вашем проекте, но не предназначены для того, чтобы быть частью общедоступного API вашего проекта. Если вы окажетесь в такой ситуации, инструмент go
распознает специальное имя папки — не имя пакета, internal/
которое можно использовать для размещения кода, который является общедоступным для вашего проекта, но приватным для других проектов.
Чтобы создать такой пакет, поместите его в каталог с именем 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 или main.init и вызываться только один раз. Это затрудняет написание тестов для написанного кодаmain.main
, поэтому вы должны стремиться перенести как можно больше своей бизнес-логики из вашей основной функции и, в идеале, из вашего основного пакета.
Because main.main
is a singleton there are a lot of assumptions built into the things that main.main
will call that they will only be called during main.main or main.init, and only called once. Это затрудняет написание тестов для кода, написанного в main.main
, поэтому вы должны стремиться вынести как можно больше своей бизнес-логики из функции main
и в идеале, из main
пакета.
СОВЕТ. В функции main()
следует анализировать флаги, открывать подключения к базам данных, регистраторам и тому подобное, а затем передавать выполнение объекту более высокого уровня.
6. Дизайн API
Последний совет по дизайну, который я собираюсь дать сегодня, я считаю самым важным.
Все предложения, которые я высказал до сих пор - это всего лишь предложения. Именно так я пытаюсь писать свой код на Go, но я не собираюсь сильно настаивать на них при code review.
Однако, когда дело доходит до проверки API на code review, я менее снисходителен. Это связано с тем, что все, о чем я говорил до сих пор, может быть исправлено без нарушения обратной совместимости; по большей части это детали реализации.
Когда дело доходит до общедоступного 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
, всегда вызывается правильно — это можно подтвердить с помощью модульного теста — и, возможно, может быть закрытым, что еще больше снижает вероятность неправильного использования.
СОВЕТ. API-интерфейсы с несколькими параметрами одного и того же типа трудно использовать правильно.
6.2. Разработка API для варианта использования по умолчанию
Несколько лет назад я выступил с докладом [6] об использовании функциональных опций [7], для упрощения использования API по умолчанию.
Суть этого выступления заключалась в том, что вы должны проектировать свои API для обобщённого варианта использования. Другими словами, ваш API не должен требовать от вызывающей стороны предоставления параметров, которые им не нужны.
6.2.1. Не рекомендуется использовать nil
в качестве параметра
Я начал эту главу с предложения того, что вы не должны заставлять вызывающего ваше API предоставлять вам параметры, когда на самом деле ему все равно, что эти параметры означают. Именно это я имею в виду, когда говорю о разработке API для варианта использования по умолчанию
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) }
Поскольку позволяет вызывающей стороне передавать вторым параметром, также поддерживает это поведение. Фактически, http.Serve
это тот, который реализует "если handler
естьnil
, использование DefaultServeMux`" logic. Accepting `nil
для одного параметра может заставить вызывающего абонента думать, что они могут передавать nil
оба параметра. Однако Serve
такой вызов,
Поскольку ListenAndServe
позволяет вызывающей стороне передать nil
для второго параметра, http.Serve
также поддерживает такое поведение. Фактически, http.Serve
реализует логику "если обработчик равен нулю, используйте DefaultServeMux
". Принятие nil
для одного параметра может привести вызывающую сторону к мысли, что они могут передать nil
для обоих параметров. Однако вызывая Serve
так,
http.Serve(nil, nil)
СОВЕТ. Не смешивайте нулевые и ненулевые параметры в одной и той же сигнатуре функции.
Автор http.ListenAndServe
пытался облегчить жизнь пользователю API в общем случае, но, возможно, усложнил безопасное использование пакета.
Нет никакой разницы в количестве строк между использованием DefaultServeMux
явно или неявно через nil
.
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 с параметрами только для тестирования
Избегайте предоставления API-интерфейсов со значениями, которые отличаются только областью тестирования. Вместо этого используйте общедоступные обёртки, чтобы скрыть эти параметры, используйте помощники в области тестирования, чтобы задать свойство для области тестирования.
6.2.2. Предпочтите переменные аргументы параметрам []T
Очень часто пишут функцию или метод, который принимает срез значений.
func ShutdownVMs(ids []string) error
Это всего лишь пример, который я придумал, но он характерен для большинства кода, над которым я работал. Проблема с подобными подписями заключается в том, что они предполагают, что будут вызываться с более чем с одним элементом. Однако я обнаружил, что большинстве случаев функции такого типа вызываются только с одним элементом, который должен быть «упакован» внутрь среза только для того, чтобы соответствовать требованиям сигнатуры функций.
Кроме того, поскольку ids
параметр представляет собой срез, вы можете передать функции пустой срез или nil
, и компилятор будет доволен. Это добавляет дополнительную нагрузку на тестирование, потому что вы должны учитывать эти случаи при тестировании.
Чтобы привести пример такого 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, потому что он выполнит нулевые итерации и немедленно вернет false. Это не самое худшее в мире — тоже самое было бы, если бы anyPositive
возвращал true при отсутствии аргументов.
Тем не менее, было бы лучше, если бы мы могли изменить сигнатуру , чтобы вызывающая сторона передала хотя бы один аргумент. Мы можем сделать это, объединив параметры normal и vararg следующим образом:
// 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. Позвольте функциям определять требуемое поведение
Допустим, мне дали задание написать функцию, которая сохраняет структуру документа на диске.
// Save writes the contents of doc to the file f. func Save(f *os.File, doc *Document) error
Я мог бы указать эту функцию Save , которая принимает an *os.File
в качестве места назначения для записиDocument
. Но у такого варианта есть несколько проблем
Я мог бы указать эту функцию Save, которая принимает *os.File в качестве места назначения для записи Document
. Но у такого решения несколько проблем
Сигнатура Save
исключает возможность записи данных в сетевое расположение. Предполагая, что сетевое хранилище, вероятно, станет обязательным позже, и сигнатура этой функции должна будет измениться, что повлияет на всех кто ее вызывает.
Также Save
неприятно тестировать, потому что она работает непосредственно с файлами на диске. И для того чтобы проверить его работу, тест должен будет прочитать содержимое файла после его записи.
И я должен был бы убедиться, что f
пишется во временное место и всегда удаляется после.
Также *os.File
определяет множество методов, которые не имеют отношения кSave
, например, чтение каталогов и проверка, является ли путь символической ссылкой. Было бы полезно, если бы сигнатура функции Save
могла описывать только те части*os.File
, которые имеют отношение к делу.
// Save writes the contents of doc to the supplied // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error
Используя io.ReadWriteCloser
, мы можем применить принцип разделения интерфейса, чтобы переопределить Save
, чтобы получить интерфейс, который описывает более общие вещи в форме файла.
Благодаря этому изменению любой тип, реализующий интерфейс io.ReadWriteCloser
, может заменить предыдущий *os.File
.
Это делает Save
более широким в своем применении и разъясняет вызывающей стороне Save
, какие методы типа *os.File
имеют отношение к его работе.
И, как у автораSave
, у меня больше нет возможности вызывать эти не относящиеся к делу методы *os.File
, поскольку они скрыты за io.ReadWriteCloser
интерфейсом.
Но мы можем немного расширить принцип разделения интерфейсов.
Во-первых, маловероятно, что, если Save
следует принципу единой ответственности, он будет читать только что записанный файл для того чтобы проверить его содержимое — за это должен отвечать другой фрагмент кода.
// Save writes the contents of doc to the supplied // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error
Таким образом, мы можем сузить спецификацию интерфейса, который мы передаем в Save
, до обычного записать и закрыть.
Во-вторых, предоставляя Save
механизм для закрытия его потока, который мы унаследовали в желании сделать его по-прежнему похожим на файл, возникает вопрос о том, при каких обстоятельствах wc
будет закрыт.
Возможно, Save
вызовет Close в любом случае, или, возможно, Close будет вызван только в случае успеха.
Это создает проблему для вызывающегоSave
, поскольку он может захотеть записать дополнительные данные в поток после написания документа.
// Save writes the contents of doc to the supplied // Writer. func Save(w io.Writer, doc *Document) error
Лучшим решением было бы переопределить Save
так, чтобы он принимал только io.Writer
, полностью лишая его ответственности за выполнение каких-либо действий, кроме записи данных в поток.
Применяя принцип разделения интерфейса к нашей функции , результатом одновременно стала функция, которая является наиболее специфичной с точки зрения ее требований — ей нужна только вещь, доступная для записи, — и самая общая по своей функции, которую мы теперь можем использовать Save
для сохранения наших данных во всем, что реализует .
Применяя принцип сегрегации интерфейса к нашей функции Save
, мы одновременно получили функцию, наиболее специфичную с точки зрения своих требований — ей нужно только что-то, доступное для записи, — и наиболее общую по своей функции, которую мы можем теперь использовать, чтобы сохранить наши данные во что-нибудь, что реализует io.Writer
7. Обработка ошибок
Я провел несколько презентаций об обработке ошибок и много написал об обработке ошибок в своем блоге. Я также много говорил об обработке ошибок во вчерашней сессии, поэтому я не буду повторять то, что я сказал.
- https://dave.cheney.net/2014/12/24/inspecting-errors
- https://dave.cheney.net/2016/04/07/constant-errors
Вместо этого я хочу осветить две другие области, связанные с обработкой ошибок.
7.1. Устраните обработку ошибок, устранив ошибки
Если вы были на моей вчерашней презентации, я рассказал о черновых предложениях по улучшению обработки ошибок. Но знаете ли вы, что лучше, чем улучшенный синтаксис для обработки ошибок? Нет необходимости обрабатывать ошибки вообще.
ПРИМЕЧАНИЕ. Я не говорю "удалить обработку ошибок". Я предлагаю изменить свой код, чтобы у вас не было ошибок для обработки.
Этот раздел черпает вдохновение из недавно вышедшей книги Джона Остерхаута "Философия проектирования программного обеспечения". Одна из глав в этой книге называется "Устраните ошибки из существования". Мы попытаемся применить этот совет к Go.
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требуется an io.Reader, а не a*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.Reader
на bufio.Scanner
.
Under the hood bufio.Scanner
используетbufio.Reader
, но добавляет хороший уровень абстракции, который помогает устранить обработку ошибок с помощью скрытой операцииCountLines
.
ПРИМЕЧАНИЕ. bufio.Scanner
может сканировать любой шаблон, но по умолчанию он ищет новые строки.
Метод sc.Scan()
возвращает true
результат, если сканер сопоставил строку текста и не обнаружил ошибки. Итак, тело нашего for
цикла будет вызываться только тогда, когда в буфере сканера есть строка текста. Это означает, что наш пересмотренный CountLines
вариант правильно обрабатывает случай, когда нет конечной новой строки, а также обрабатывает случай, когда файл был пуст.
Во-вторых, as sc.Scan
возвращает false
при обнаружении ошибки, наш for
цикл завершится, когда будет достигнут конец файла или возникнет ошибка. bufio.Scanner
Тип запоминает первую ошибку, с которой он столкнулся, и мы можем восстановить эту ошибку, как только выйдем из цикла, используя sc.Err()
метод.
Наконец, sc.Err()
позаботится об обработке io.EOF
и преобразует ее в anil
, если был достигнут конец файла, не столкнувшись с другой ошибкой.
СОВЕТ. Когда вы столкнетесь с чрезмерной обработкой ошибок, попробуйте извлечь некоторые операции в вспомогательный тип.
7.1.2. WriteResponse
Мой второй пример вдохновлен сообщением в блоге Errors are values.
Ранее в этой презентации мы видели примеры, касающиеся открытия, записи и закрытия файлов. Обработка ошибок присутствует, но не является подавляющей, поскольку операции могут быть инкапсулированы в помощники, такие как 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 %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err }
Сначала мы создаем строку состояния, используяfmt.Fprintf
, и проверяем ошибку. Затем для каждого заголовка мы записываем ключ и значение заголовка, каждый раз проверяя ошибку. Наконец, мы завершаем раздел заголовка дополнительным\r\n
, проверяем ошибку и копируем тело ответа клиенту. Наконец, хотя нам не нужно проверять ошибку изio.Copy
, нам нужно перевести ее из формы с двумя возвращаемыми значениями, которая io.Copy
возвращает в одно возвращаемое значение, которое WriteResponse
возвращает.
Это много повторяющейся работы. Но мы можем упростить себе задачу, введя небольшой тип оболочки, errWriter
.
errWriter
выполняет io.Writer
контракт, чтобы его можно было использовать для переноса существующего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 %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err }
Применение errWriter
к WriteResponse
значительно повышает ясность кода. Каждая из операций больше не нуждается в проверке ошибок. Сообщение об ошибке перемещается в конец функции путем проверки ew.err
поля, избегая раздражающего перевода из `io.Возвращаемые значения Copy.
7.2. Обрабатывайте ошибку только один раз
Наконец, я хочу упомянуть, что вы должны обрабатывать ошибки только один раз. Обработка ошибки означает проверку значения ошибки и принятие единого решения.
// WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) }
Если вы принимаете менее одного решения, вы игнорируете ошибку. Как мы видим здесь, ошибка from 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’s `Error()
метод сообщит что-то вроде этого;
could not write config: write failed: input/output error
7.2.2. Перенос ошибок с github.com/pkg/errors
fmt.Errorf
Шаблон хорошо подходит для аннотирования сообщения об ошибке, но это делается за счет скрытия типа исходной ошибки. Я утверждал, что обработка ошибок как непрозрачных значений важна для создания слабо связанного программного обеспечения, поэтому тип исходной ошибки не должен иметь значения, если единственное, что вы делаете со значением ошибки, это
Однако есть некоторые случаи, я считаю, что они редки, когда вам нужно восстановить исходную ошибку. В этом случае вы можете использовать что-то вроде my errors package для аннотирования подобных ошибок
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) } }
Теперь сообщаемая ошибка будет ошибкой в стиле nice 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 %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", 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
пакета дает вам возможность добавлять контекст к значениям ошибок таким образом, чтобы его мог проверять как человек, так и машина. Если вы пришли на мою вчерашнюю презентацию, вы знаете, что wrapping переходит в стандартную библиотеку в предстоящем выпуске Go.
8. Параллелизм
Часто Go выбирают для проекта из-за его особенностей параллелизма. Команда Go приложила немало усилий, чтобы сделать параллелизм в Go дешевым (с точки зрения аппаратных ресурсов) и производительным, однако можно использовать возможности параллелизма Go для написания кода, который не является ни точным, ни надежным. В оставшееся у меня время я хочу дать вам несколько советов, как избежать некоторых ошибок, связанных с функциями параллелизма Go.
Go имеет первоклассную поддержку параллелизма с каналами select
и go
операторами and. Если вы изучали 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
будет заблокирована основная подпрограмма, потому что она не выполняет никаких операций ввода-вывода, ожидания блокировки, отправки или получения по каналу или иного взаимодействия с планировщиком.
Поскольку время выполнения Go в основном запланировано совместно, эта программа будет бесплодно работать на одном процессоре и в конечном итоге может оказаться заблокированной.
Как мы могли бы это исправить? Вот одно предложение.
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
в программе goroutine, оставляя нас с проблемой, что делать с основной программой goroutine, просто запустите http.ListenAndServe
саму основную программу goroutine.
8. Параллелизм
Часто Go выбирают для проекта из-за его особенностей параллелизма. Команда Go приложила немало усилий, чтобы сделать параллелизм в Go дешевым (с точки зрения аппаратных ресурсов) и производительным, однако можно использовать возможности параллелизма Go для написания кода, который не является ни точным, ни надежным. В оставшееся у меня время я хочу дать вам несколько советов, как избежать некоторых ошибок, связанных с функциями параллелизма Go.
Go имеет первоклассную поддержку параллелизма с каналами select
и go
операторами and. Если вы изучали 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
будет заблокирована основная подпрограмма, потому что она не выполняет никаких операций ввода-вывода, ожидания блокировки, отправки или получения по каналу или иного взаимодействия с планировщиком.
Поскольку время выполнения Go в основном запланировано совместно, эта программа будет бесплодно работать на одном процессоре и в конечном итоге может оказаться заблокированной.
Как мы могли бы это исправить? Вот одно предложение.
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
в программе goroutine, оставляя нас с проблемой, что делать с основной программой goroutine, просто запустите http.ListenAndServe
саму основную программу goroutine.
СОВЕТ. Если main.main
функция программы Go возвращается, то программа 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 возвращает содержимое директории. func ListDirectory(dir string) ([]string, error)
// ListDirectory возвращает канал, по которому // записи каталога будут опубликованы. Когда список // количество записей исчерпано, канал будет закрыт. func ListDirectory(dir string) chan string
Во-первых, очевидные различия; первый пример считывает каталог в срез, а затем возвращает весь срез или сообщение об ошибке, если что-то пошло не так. Это происходит синхронно, вызывающий ListDirectory
блокирует дальнейшее выполнение до тех пор, пока все записи каталога не будут прочитаны. В зависимости от размера каталога это может занять значительное время и создавая срез с именами записей каталога потенциально может занять много памяти.
Давайте посмотрим на второй пример. Который уже больше похож на Go, ListDirectory
возвращает канал, по которому будут передаваться записи каталога. Когда канал закрыт, это означает, что записей в каталоге больше нет. Поскольку заполнение канала происходит после возврата ListDirectory
, то вероятно ListDirectory
, запускает Go процедуру для наполнения канала.
ПРИМЕЧАНИЕ. Для второй версии по факту нет необходимости использовать Go процедуру; здесь так же можно выделить канал, достаточный для хранения всех записей каталога без блокировки, заполнить канал, и закрыть его, а затем вернуть канал вызывающему. Но это маловероятно, поскольку остаются те же проблемы с потреблением большого объема памяти для буферизации всех результатов в канале
Канальная версия ListDirectory
имеет две дополнительные проблемы:
- Используя закрытие канала в качестве сигнала о том, что больше нет элементов для обработки
ListDirectory
, невозможно сообщить вызывающему, что набор элементов, возвращаемых по каналу, является неполным, поскольку на полпути была обнаружена ошибка. Вызывающий абонент не может определить разницу между пустым каталогом и ошибкой при чтении из каталога. Оба результата приводят к возвращению каналаListDirectory
, который, по-видимому, немедленно закрывается. - Вызывающий должен продолжать чтение из канала, пока он не будет закрыт, поскольку это единственный способ, которым вызывающий может узнать, что подпрограмма, которая была запущена для заполнения канала, была остановлена. Это серьезное ограничение на использование
ListDirectory
, вызывающий вынужден тратить время на чтение из канала, даже если он, возможно, уже получил желаемый ответ. Вероятно, это более эффективно с точки зрения использования памяти способ для средних и больших каталогов, но это не быстрее, чем оригинальный метод с использованием среза.
Решение проблем обеих реализаций заключается в использовании обратного вызова, функции, которая вызывается в контексте каждой записи каталога по мере ее выполнения.
func ListDirectory(dir string, fn func(string))
По этому неудивительно, что именно так работает функция filepath.WalkDir
.
СОВЕТ. Если ваша функция запускает подпрограмму, вы должны предоставить вызывающей стороне способ явно остановить эту подпрограмму. Проще всего оставить решение о выполнении функции асинхронно вызывающему эту функцию.
8.3. Никогда не запускайте подпрограмму, если не знаете, когда она остановится
В предыдущем примере было показано использование подпрограммы, когда в ней не было особой необходимости. Но одна из основных причин использования Go - это первоклассные возможности параллелизма, предлагаемые языком. Действительно, есть много случаев, когда вы хотите использовать параллелизм, доступный в вашем оборудовании. Для этого вы должны использовать программы goroutines.
Это простое приложение обслуживает 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
, если требуется. Поскольку оба обработчика работают в goroutines, мы паркуем основную goroutine в a select{}
.
Этот подход имеет ряд проблем:
- Если
ListenAndServer
возвращает с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
делает возврат, и процесс полностью останавливается.
СОВЕТ. Самостоятельное написание этой логики - это повторяющийся и тонкий процесс. Подумайте о чем-то вроде этого пакета,https://github.com/heptio/workgroupкоторый сделает за вас большую часть работы.
Источник: https://dave.cheney.net/practical-go/presentations/qcon-china.html