Язык Go на практике: советы по написанию поддерживаемых программ

Go

Эта статья представляет собой рассказ Дэвида Чейни, основного докладчика на конференции QCon 2018 Shanghai Station, старшего инженера Heptio и известного эксперта по языку го на английском языке. Эта статья в основном основана на переводахcloud.Tencent.com/developer/ ах…Организовать и опубликовать

введение

В следующих двух сессиях я дам вам несколько лучших практик написания кода Go.

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

Вы можете найти последнюю онлайн-версию этого доклада здесь:Dave.Cheney.net/practical - а…

1. Руководящие принципы

Мы должны говорить о лучших практиках в языке программирования, тогда мы должны сначала уточнить, что является «лучшим». Если вы слушаете моего вчерашнего спикера, вы должны увидеть предложение Расса Кокса из команды Go:

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

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

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

  1. простота
  2. удобочитаемость
  3. производительность

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

1.1 Простота

Почему мы стремимся к простоте и почему простота так важна для программирования на Go?

Слишком много раз мы говорим: «Я не понимаю этот код», не так ли? Мы боимся немного изменить код, чтобы это изменение не привело к тому, что что-то еще, чего вы не понимаете, пойдет не так, и вы не сможете это исправить.

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

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

1.2 Читабельность

Удобочитаемость необходима для удобства сопровождения», — Марк Рейнхольд, саммит по языку JVM, 2018 г. Читабельность имеет решающее значение для ремонтопригодности.

Почему читабельность кода Go так важна? Почему мы должны стремиться к удобочитаемости?

Программы должны быть написаны для того, чтобы их читали люди, и лишь случайно для того, чтобы машины выполняли их — Хэл Абельсон и Джеральд Сассман, «Структура и интерпретация компьютерных программ» Программы должны быть написаны для того, чтобы люди могли их читать, и лишь случайно для того, чтобы машины могли выполнять их.

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

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

Самый важный навык для программиста — это умение эффективно доносить идеи.— Гастон Хоркера ^1 Наиболее важным навыком для программиста является способность эффективно доносить идеи.

Удобочитаемость — это ключ к пониманию того, что делает программа. Если вы не знаете, что делает программа, как вы собираетесь ее поддерживать? Если часть программного обеспечения недоступна для обслуживания, она может быть переписана, и это может быть последний раз, когда ваша компания инвестировала в GO.

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

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

1.3 Производительность

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

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

Есть шутка, что Go был разработан во время компиляции программ на C++. Быстрая компиляция — ключевая особенность языка Go, привлекающая новых разработчиков. Скорость компиляции по-прежнему является постоянным полем битвы, и справедливо сказать, что другие языки компилируются за минуты, а Go делает это за секунды. Это помогает разработчикам Go работать так же продуктивно, как разработчики динамических языков, но без проблем с надежностью самих этих динамических языков.

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

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

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

2 идентификатора

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

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

2.1 Выбирайте понятные, а не краткие имена

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

Ключом к этой ясности являются идентификаторы, которые мы выбираем для наших программ Go. Давайте посмотрим, каким должно быть хорошее имя:

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

Далее давайте обсудим это подробно.

2.2 Длина идентификатора

Иногда люди критикуют стиль Go за рекомендацию коротких имен переменных. Как сказал Роб Пайк, «разработчикам Go нужны идентификаторы правильной длины». ^ 1 Эндрю Джерранд предлагает использовать более длинные идентификаторы, чтобы показать читателям, что они более важны.

Исходя из этого, мы можем резюмировать некоторые рекомендации:

  • Короткие имена переменных хорошо работают, когда расстояние между объявлением и последним использованием невелико.
  • Длинные имена переменных нужно обосновывать иначе: чем длиннее имя переменной, тем больше причин его нужно обосновывать. Длинные многословные имена несут мало информации по сравнению с их весом на странице.
  • Не включайте имя его типа в имя переменной.
  • Константы должны описывать значение хранимого в них значения, а не то, как его использовать.
  • Однобуквенные переменные могут использоваться для циклов или логических ветвей, словесные переменные могут использоваться для параметров или возвращаемых значений, а многословные фразы могут использоваться в объявлениях на уровне функций и пакетов.
  • Слова могут использоваться для методов, интерфейсов и пакетов
  • Имейте в виду, что имя пакета будет именем, по которому пользователи будут обращаться к нему, убедитесь, что это имя более осмысленно. Давайте посмотрим пример:
type Person struct {
Name string
Age  int
}
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}

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

Напротив, переменная people определена в параметре функции, и в ней 7 строк, и то же самое верно для sum и count, которые используют более длинные имена, и читатель должен сосредоточиться на более широком диапазоне строк кода.

Я также мог бы использовать s вместо sum и c (или n) вместо count, но это привело бы к кластеризации переменных по всей программе с одинаковым значением. Я мог бы также использовать p вместо людей, но тогда еще один вопрос, какая переменная используется в цикле for...range? Единственное лицо также выглядит странно, с очень недолговечным именем, которое длиннее, чем значение, от которого оно было получено.

Остин Луо: Я хочу сказать, что если имя переменной p используется в массиве people, то становится проблемой назвать каждый элемент, полученный из массива.Например, если вы используете person, даже если вы используете person, выглядит странно.с одной стороны единично.с одной стороны жизненный цикл человека всего две линии (очень короткие), а имя р(люди),имеющее более длинный жизненный цикл,ещё длиннее . Совет: Используйте пустые строки для сегментации выполнения функции так же, как вы используете пустые строки для сегментации документа. В функции AverageAge последовательно выполняются три операции. Первое условие — проверка того, что мы не делим на ноль, когда людей нет, второе — суммирование и подсчет, а последнее — вычисление среднего.

2.2.1 Контекст имеет ключевое значение

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

for index := 0; index < len(s); index++ {
}

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

for i := 0; i < len(s); i++ {
}

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

Сказав это, какой из следующих двух фрагментов кода более читаем?

func (s *SNMP) Fetch(oid []int, index int) (int, error)

или

func (s *SNMP) Fetch(o []int, i int) (int, error)

В этом примере oid — это сокращение от SNMP Object ID, поэтому оно обозначается аббревиатурой o. Означает, что разработчики должны переводить обычные обозначения, которые они видят в документации, в более короткие обозначения в коде. Аналогично, сокращение индекса до i уменьшает его значение как индекса сообщений SNMP.

Совет: не смешивайте длинные и короткие стили именования в объявлениях параметров.

2.3 Не включайте название типа в название

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

Имя переменной должно отражать ее содержимое, а не ее тип. Давайте посмотрим на следующий пример:

var usersMap map[string]*User

В чем преимущество такого наименования? Мы можем сказать, что это карта и она связана с типом *User, что может быть нормально. Но Go, будучи статически типизированным языком, не позволяет нам случайно использовать скалярную переменную там, где она нам нужна, поэтому суффикс Map фактически избыточен. Теперь давайте посмотрим, что происходит, когда переменная определяется следующим образом:

var (
companiesMap map[string]*Company
productsMap map[string]*Products
)

Теперь у нас есть три переменные карты типов в этой области: usersMap, companyMap и productsMap, каждая из которых отображает строки в разные типы. Мы знаем, что они оба являются картами, и мы также знаем, что их объявления карт не позволяют нам использовать одну вместо другой — если мы попытаемся использовать companyMap там, где требуется map[string]*User, компилятор выдаст ошибку. В этом случае ясно, что суффикс Map не улучшает ясность кода, это просто избыточный материал для ввода при программировании. (Остин Луо: старый способ мышления)

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

Совет: если пользователи не могут быть описаны достаточно четко, то и usersMap тоже не сможет.

Этот совет также относится к параметрам функции, таким как:

type Config struct {
}
func WriteConfig(w io.Writer, config *Config)

будетПараметр конфигурации с именем конфигурация избыточна, мы знаем, что этоКонфиг, сигнатура функции четко написана.

В этом случае рекомендуется рассмотреть conf или c - если время жизни достаточно короткое.

Если в области действия более одного *Config, имена conf1 и conf2 менее информативны, чем исходное и обновленное, а последние менее подвержены ошибкам, чем первые.

ПРИМЕЧАНИЕ. Не позволяйте именам пакетов замещать имена, более подходящие для переменных. Импортированный идентификатор будет содержать имя пакета, которому он принадлежит. Например, мы знаем, что context.Context — это тип Context в контексте пакета. Это приводит к тому, что мы больше не можем использовать контекст в качестве переменной или имени типа в наших собственных пакетах. func WriteLog(context context.Context, строка сообщения) Не удается скомпилировать. Вот почему мы обычно называем переменные типа context.Context как ctx, например: func WriteLog(ctx context.Context, строка сообщения)

2.4 Используйте единый стиль именования

Еще одна характеристика хорошего имени заключается в том, что оно должно быть предсказуемым. Читатели должны быть в состоянии понять, как использовать его в первый раз, когда они видят его. Если они встречают имя по соглашению, они должны быть в состоянии думать, что оно не изменило своего значения с тех пор, как они видели его в последний раз. Например, если вы передаете дескриптор базы данных, убедитесь, что имена параметров каждый раз одинаковы. использовать его вместоd *sql.DB,dbase *sql.DB,DB *sql.DBиdatabase *sql.DB, их лучше унифицировать как:

db *sql.DB

Это повышает узнаваемость: если вы видите базу данных, вы знаете, что это *sql.DB, и она была определена локально или предоставлена ​​вызывающей стороной. Аналогично для приемников методов используйте одно и то же имя получателя в каждом методе типа, что облегчает читателям возможность делать субъективные выводы при чтении и понимании разных методов.

Остин Луо: «Приемник» — это параметр особого типа. ^2 соотношение如func (b *Buffer) Read(p []byte) (n int, err error), который обычно обозначается одной или двумя буквами, но должен быть одинаковым для всех методов. Примечание. Соглашение Go о кратких именах получателей несовместимо с текущими рекомендациями. Это всего лишь один из вариантов, сделанных ранее, и он стал предпочтительным стилем, например, использование CamelCase вместо змеиного_кейса.

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

Наконец, некоторые однобуквенные переменные традиционно ассоциируются с циклами и счетчиками. Например, i, j и k обычно являются простыми для переменных цикла. n обычно ассоциируется со счетчиками или аккумуляторами. v обычно является сокращением для некоторого значения, k обычно используется для ключа карты, а s обычно используется в качестве сокращения для параметра типа string.

Как и в приведенном выше примере db, программист ожидает, что i будет переменной цикла. Если вы гарантируете, что i всегда является переменной цикла и не используется вне цикла for, читатели будут знать, что они все еще находятся в цикле, когда они столкнутся с переменной с именем i или j.

Совет: если вы обнаружите, что использовали все i, j, k во вложенных циклах, то, очевидно, пришло время разделить функцию на более мелкие части. Используйте последовательный декларативный стиль

2.5 Используйте согласованный стиль объявления

В Go есть как минимум 6 способов объявления переменных (Остин Луо: автор сказал 6, но перечислил только 5)

  • var x int = 1
  • var x = 1
  • var x int; x = 1
  • var x = int(1)
  • x := 1

Я уверен, что есть и другие, о которых я не подумал. Именно здесь дизайнеры 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)

Поскольку 0 — это нулевое значение для игроков, избыточно явно инициализировать 0 для игроков. Итак, чтобы было понятнее, что мы используем нулевое значение, его следует записать так:

var players int

А второе утверждение? Мы не можем игнорировать типы и писать:

var things = nil

Потому что ноль просто не тип ^ 2. Вместо этого у нас есть выбор, хотите ли мы ноль ценность ломтика?

var things []Thing

Или мы хотим создать срез без элементов?

var things = make([]Thing, 0)

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

things := make([]Thing, 0)

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

var thing = new(Thing)

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

thing := new(Thing)

Это проясняет, что объект item явно инициализируется результатом new(Thing) — указателем на Thing — но по-прежнему сохраняет новое, которое мы не часто используем. Мы можем решить эту проблему, используя форму инициализации компактной структуры,

thing := &Thing{}

Это делает то же самое, что и new(Thing) — и многим программистам Go не нравится такое дублирование. Однако это предложение по-прежнему означает, что мы явно инициализируем указатель на Вещь{} для вещи — нулевое значение для Вещи.

Здесь мы должны понимать, что вещь инициализируется нулевым значением и передает свой адрес указателя в json.Unmarshall:

var thing Thing
json.Unmarshall(reader, &thing)

Примечание. Конечно, из любого эмпирического правила есть исключения. Например, некоторые переменные очень коррелированы, поэтому вместо того, чтобы писать это:var min int max := 1000Было бы читабельнее написать так:min, max := 0, 1000

В итоге:

  • Только объявить, а не инициализировать, использовать var.
  • При объявлении и явной инициализации используйте :=.

Совет: сделайте остроумные утверждения более очевидными. Когда что-то сложно, сделайте так, чтобы это казалось сложным. var length uint32 = 0x80, где length может использоваться с библиотекой, требующей определенного числового типа, а length явно указывается как uint32, а не просто короткое объявление: length := uint32(0x80) в первом примере, я намеренно нарушил правила использования формы объявления var и явных инициализаторов. Это решение отличается от моей обычной формы, чтобы читатель осознал необходимость внимания.

2.6 Быть командным игроком

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

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

Совет: если вы переименовываете всю кодовую базу, не смешивайте другие изменения. Если другие люди используют git bisect, они не захотят «проходить» переименование тысяч строк кода, чтобы найти ваши другие изменения.

3 комментария к коду

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

Хороший код имеет много комментариев, плохой код требует много комментариев. - Дейв Томас и Эндрю Хант, прагматичный программист Хороший код имеет много комментариев, плохой код не хватает множества комментариев.

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

  1. Комментарии должны объяснять, «что делать».
  2. Комментарии должны объяснять «как».
  3. Комментарии должны объяснять, «почему это делается».

Первая форма подходит для открытых символов:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

Вторая форма подходит для аннотаций внутри методов:

// queue all dependant actions
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Присвоение значения 6 также объясняет, откуда берется значение 6. но это не описываетrandomNumberГде он будет использоваться. Вот еще примеры:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

Как определено в разделе 6.2.1 RFC 7231, в контексте HTTP 100 рассматривается какStatusContinue.

Совет: Для переменных без начальных значений в комментариях должно быть указано, кто будет отвечать за их инициализацию. // sizeCalculationDisabled указывает, безопасно ли это // для расчета ширины и выравнивания типов См. dowidth. var sizeCalculationDisabled bool Здесь дайте читателю понять, что функция dowidth отвечает за поддержание состояния sizeCalculationDisabled посредством комментариев. Совет: скройте очевидное Кейт Грегори упомянула немного ^3, иногда хорошее название, можно опустить лишние комментарии. // реестр драйверов SQL реестр var = make (строка картыsql.Драйвер) Комментарий добавлен автором исходника, потому что в реестре не было четкого определения его назначения - это реестр, но реестр чего? Переименовав переменную в sqlDrivers, становится ясно, что эта переменная предназначена для хранения драйверов SQL. var sqlDrivers = make(строка картыsql.Драйвер) 现在注释已经多余了,可以移除。

3.2 Всегда пишите документацию для общественных символов

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

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

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

Есть одно исключение из этого правила: Вам не нужно документировать реализацию метода интерфейса, в частности, не нравится это:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

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

Вот пример из пакета io:

// 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, указывающий, кто нашел плохой код. Имя в записке не означает обязательство решить проблему, но когда дело доходит до ее решения, он, вероятно, лучший человек. Другие комментарии обычно включают дату или номер вопроса.

3.2.2 Вместо того, чтобы комментировать кусок кода, проведите его рефакторинг

Хороший код сам по себе является лучшей документацией. Собираясь добавить комментарий, спросите себя: «Как я могу улучшить код, чтобы этот комментарий не понадобился?» Улучшите код, а затем задокументируйте его, чтобы сделать его более ровным. яснее» — Стив МакКоннелл Хороший код — лучшая документация. Когда вы будете готовы добавить строку комментария, спросите себя: «Как я могу улучшить этот код, чтобы он не нуждался в комментариях?» Оптимизируйте код, а затем прокомментируйте его, чтобы сделать его более понятным.

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

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

4 упаковки дизайн

Пишите застенчивый код — модули, которые не раскрывают ничего ненужного другим модулям и не полагаются на реализации других модулей (Дэйв Томас). Пишите сдержанный код — модули не раскрывают никакой лишней информации внешнему миру и не зависят от реализации внешних модулей.

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

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

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

4.1 Хороший пакет начинается с его имени

Написание хорошего пакета Go начинается с именования. Хорошо подумайте над названием вашего пакета и опишите, что это такое, одним словом. (Остин Луо: Как и в случае с «лоббированием в лифте», вы можете описать то, что хотите сказать, только за очень короткий промежуток времени и очень немногими словами.) Так же, как я говорил об именовании переменных в предыдущем разделе, имя пакета также очень важно. По моему опыту, мы должны думать не о том, «какие типы я должен поместить в этот пакет», а о том, «что должны делать службы, предоставляемые пакетом». Обычно ответом на этот вопрос должно быть не «этот пакет предоставляет такой-то тип», а «этот пакет позволяет вам осуществлять HTTP-связь».

Совет: назовите то, что пакет «обеспечивает», а не то, что он «содержит».

4.1.1 Хорошие имена пакетов должны быть уникальными

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

  • Имя пакета слишком общее.
  • Дублируется другим пакетом с таким же названием. В этом случае следует пересмотреть дизайн или просто объединить два пакета.

4.2 Избегайте именования пакетов как base, common, util

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

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

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

Небольшое дублирование намного дешевле неправильной абстракции (Сэнди Мец). (Небольшое) повторение гораздо полезнее ложной абстракции.

В тех случаях, когда подход класса полезности используется в нескольких местах, предпочтительно выбрать несколько пакетов (проекта), каждый из которых фокусируется на одном аспекте, а не на всем пакете. (Остин Луо: Разделение интересов.)

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

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

В качестве конкретного примера пакет net/http не всегда имеет два подпакета, client и server, а только два файла с именами client.go и server.go, каждый из которых обрабатывает свой тип, и файл transport.go. файл Код для публичной передачи сообщения.

Совет: имя идентификатора включает в себя имя его пакета. Важно помнить, что имя идентификатора включает в себя имя пакета, в котором он находится. Функция Get в пакете net/http становится http.Get, когда на нее ссылается другие пакеты. Тип Reader в пакете strings становится strings.Reader после импорта других пакетов. Интерфейс Error в пакете net явно связан с сетевыми ошибками.

4.3 Быстрый возврат вместо глубокого вложения

Точно так же, как Go не использует исключения для управления потоком выполнения, нет необходимости делать глубокий отступ в коде только для того, чтобы добавить блок try...catch... на верхнем уровне. По сравнению с размещением успешного пути выполнения справа, слой за слоем, код в стиле Go перемещает успешный путь в нижнюю часть экрана по мере выполнения функции. Мой друг Мэт Райер называет это кодированием «прямой видимости». ^4

Это достигается с помощью «защитных предложений» (Остин Луо: похоже на то, что мы часто называем защитным программированием): условные блоки кода, которые устанавливают предварительные условия сразу после входа в функцию. Вот пример из пакета 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 Делаем нулевые значения осмысленными

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

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

Посмотрим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()
}

Остин Луо: Исходный текст «полезный», который я перевожу как «значимый» вместо «полезный», чтобы подчеркнуть, что его нулевое значение соответствует деловому, логическому, а также начальному и стандартному, а не «полезному». Оставь это в покое, пусть будет ноль». Это также тесно связано с именами переменных, таких как: isCacheEnabled bool // Включен или нет кеш isCacheDisabled bool // Отключен ли кеш Две приведенные выше переменные выглядят одинаково, просто определите одну из них в будет, разница только в представлении. Включение одного означает его отключение. Но учитывая, что «бизнес требует, чтобы кеширование было включено по умолчанию» и «нулевое значение bool равно false», то, очевидно, мы должны определить isCacheDisabled bool вместо первого. С одной стороны, когда вызывающий объект явно не присваивает значение, значение по умолчанию, равное нулю, является ложным. Это то, что делает нулевое значение действительно значимым, поскольку строка i.mu, прокомментированная в примере, не показывает, что ее инициализация означает доступность блокировки по умолчанию.

Другой пример типа со значимым нулевым значением — bytes.Buffer. Вы можете объявить bytes.Buffer без явной инициализации и сразу начать запись в него.

func main() {
  var b bytes.Buffer
  b.WriteString("Hello, world!\n")
  io.Copy(os.Stdout, &b)
}

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

type slice struct {
  array *[...]T // pointer to the underlying array
  len   int
  cap   int
}

Нулевое значение этой структуры будет означать, что значение LEN и CAP равно 0, и указывает на указатель Array, а содержимое массива за срезом сохраняется, и значение также равно NIL. Это означает, что вам не нужен явный срез Make, вам нужно только объявить его.

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 выглядит аналогично двум закомментированным выше строкам, но не идентично. Чтобы определить разницу между нулевым слайсом и слайсом нулевой длины, следующий код выведет false:

func main() {
	var s1 = []string{}
	var s2 []string
	fmt.Println(reflect.DeepEqual(s1, s2))
}

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

type Config struct {
	path string
}

func (c *Config) Path() string {
	if c == nil {
		return "/usr/home"
	}
	return c.path
}

func main() {
	var c1 *Config
	var c2 = &Config{
		path: "/export",
	}
	fmt.Println(c1.Path(), c2.Path())
}

4.5 Избегайте состояния на уровне пакета

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

Есть два отличных способа добиться слабой связанности в Go:

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

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

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

Остин Луо: Глобальные переменные видны каждой функции, но разработчики могут не знать о существовании глобальных переменных (то есть скрытых параметров).Даже если они знают и используют глобальные переменные, они могут не осознавать, что переменная может быть в Modified в другом месте, что приводит к ненадежному использованию глобальных переменных и неработающим функциям, которые зависят от состояния (значения) этой переменной.

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

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

5 Структура проекта

Давайте рассмотрим ситуацию, когда несколько пакетов объединяются в проект. Обычно это должен быть отдельный репозиторий git, но в будущем разработчики Go будут использовать его взаимозаменяемо.moduleиproject.

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

Совет: по моему опыту.commonБиблиотеки тесно связаны со своими крупнейшими потребителями (потребителями), что позволяет выполнять отдельные обновления без блокировки шагов.commonИли потребителям становится сложно обновлять или исправлять, что приводит к множеству несвязанных изменений и поломке API.

Если ваш проект представляет собой приложение, такое как ваше веб-приложение, контроллер Kubernetes и т. д., то в вашем проекте может быть один или несколькоmainСумка. Например, у контроллера Kubernetes, который я поддерживаю, есть отдельныйcmd/contourPackage, для предоставления услуг по развертыванию кластера Kubernetes, а также по отладке клиента.

5.1 учитывайте меньшее количество больших пакетов

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

Go не предоставляет подробного способа установить видимость: как в Javapublic,protected,privateи неявныйdefaultмодификаторы доступа и отсутствие эквивалента C++friendКласс концепции.

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

Примечание. Вы можете услышать, как кто-то говорит «экспорт» и «неэкспорт», это синонимы общедоступного и частного.

Учитывая ограниченный контроль над видимостью символов в пакетах, что может сделать программист Go, чтобы избежать создания чрезмерно сложных иерархий пакетов?

Совет: удалитьcmd/иinternal/Кроме того, каждый пакет должен содержать некоторый исходный код.

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

Следующие несколько разделов позволяют нам исследовать эти рекомендации более подробно.

Советы:из Явы?Если у вас есть опыт разработки на Java или C#, учтите следующее практическое правило: пакет Java эквивалентен отдельному.goИсходные файлы; пакет Go эквивалентен целому модулю Maven или сборке .NET.

5.1.1 Организация кода в несколько файлов с операторами импорта

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

Вот несколько правил, которыми я пользуюсь:

  • из одного.goфайл и используйте то же имя, что и пакет. такие как пакетыhttpПервый файл должен бытьhttp.goИ поставить имяhttpв папке.
  • По мере постепенного роста пакета вы можете разделить разные части на разные файлы в зависимости от обязанностей. Например, будетRequestиResponseтип разделен наmessage.goв, будетClientтип разделен наclient.goв, будетServerтип разделен наserver.goсередина.
  • Если вы обнаружите, что ваши файлы имеют очень похожие объявления импорта, рассмотрите возможность их объединения, в противном случае определите конкретные различия между ними и оптимальным образом отрефакторите их.
  • Разные файлы должны отвечать за разные области пакета.messages.goМожет отвечать за сортировку HTTP-запросов и ответов, связанных с сетью,http.goможет содержать низкоуровневую логику сетевой обработки,client.goиserver.goРеализуйте бизнес-логику создания HTTP-запроса или маршрута и так далее.

Совет: имена исходных файлов должны учитывать существительные. Примечание. Компилятор Go компилирует отдельные пакеты параллельно. Внутри пакета компилятор Go параллельно компилирует отдельные функции (методы — это просто причудливые функции в Go). Изменение расположения и распределения кода в исходном коде пакета не влияет на время компиляции.

5.1.2 Внутреннее тестирование лучше внешнего

Набор инструментов Go позволяет писать тесты для пакетов в двух местах. Предположим, имя вашего пакетаhttp2, вы можете использовать егоpackage http2объявить и написатьhttp2_test.goфайл, это поставит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/каталог или подкаталог. Команда go обнаруживает, что импортированный пакет содержитinternalпуть, он проверяет, что пакет, выполняющий импорт, находится вinternalРодительский каталог как корня дерева.

Например, пакет.../a/b/c/internal/d/e/fдоступ возможен только из корневого дерева каталогов.../a/b/cимпорт кода в , не может быть.../a/b/gИли импорт кода в любую другую библиотеку. ^5

5.2 Убедитесь, что основной пакет как можно меньше

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

так какmain.mainявляется синглтоном, поэтомуmain.mainЕсть много предположений в вызовеmain.mainилиmain.initЗвонили во время и только один раз. Это затрудняетmain.mainМодульные тесты написаны в коде на , поэтому ваша цель должна состоять в том, чтобы вынести вашу бизнес-логику из основной функции, а лучше вообще из основного пакета.

Остин Луо: Главное здесь в том, что, поскольку вся программа (включая модульные тесты) может иметь только одинmain.main, Таким образом, вmain.mainНаписание слишком большого количества кода в коде затруднит его покрытие тестами, поэтому его следует удалить изmain.mainв - даже изmainВ пакете — изолирован, чтобы можно было писать юнит-тесты для тестирования. («Предположим» в этой статье для целей тестирования, а «предположим», что код в main работает правильно.) Советы:mainИдентификация должна быть разрешена, соединение с базой данных открыто, модуль ведения журнала инициализирован и т. д., а затем конкретное выполнение должно быть передано другим высокоуровневым объектам.

6 Дизайн API

Последний совет по дизайну, данный сегодня, я считаю самым важным.

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

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

6.1 Разработка API-интерфейсов, которые трудно использовать не по назначению

API-интерфейсы должны быть просты в использовании и трудно использовать не по назначению — Джош Блох ^3 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коммутативны, порядок аргументов не имеет значения, 8 против 10 в любом случае на 10 больше, будь то 8 против 10 или 10 против 8. Однако для CopyFile такой функции нет:

CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")

Какой оператор копирует файл Presentation.md, а какой заменяет файл Presentation.md версией прошлой недели? Без документации вам сложно сказать. Рецензенты кода не могут узнать, переданы ли ваши параметры в правильном порядке без документации.

Возможное решение состоит в том, чтобы ввести вспомогательный класс, который правильно вызывает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 для варианта использования по умолчанию

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

#####6.2.1 не рекомендуетсяnilкак параметр

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

Вот изnet/httpПример пакета:

package http

// ListenAndServe listens on the TCP network address addr and then calls

// Serve with handler to handle requests on incoming connections.

// Accepted connections are configured to enable TCP keep-alives.

//

// The handler is typically nil, in which case the DefaultServeMux is used.

//

// ListenAndServe always returns a non-nil error.

func ListenAndServe(addr string, handler Handler) error {

ListenAndServeЕсть два параметра: TCP-адрес для прослушивания входящих соединений,http.HandlerОбрабатывать входящие HTTP-запросы.ServeРазрешить второму параметруnil, и обратите внимание, что вызывающий абонент обычно передаетnilиспользуется для обозначения того, что они хотят использоватьhttp.DefaultServeMuxкак неявный параметр.

в настоящее времяServeУ вызывающей стороны есть два способа сделать то же самое:

http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

Оба способа делают одно и то же.

этоnilПоведение вирусное. существуетhttpСуществует такжеhttp.ServeПомогающие классы, как вы можете себе представитьListenAndServeустанавливается так:

func ListenAndServe(addr string, handler Handler) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	defer l.Close()
	return Serve(l, handler)
}

так какListenAndServeпозволяет вызывающему передать второй параметрnil,такhttp.ServeЭто поведение также поддерживается. По факту,http.Serveэто "когдаhandlerзаnil, затем используйтеDefaultServeMux"Реализация этой логики. Позволяет передать один из параметров вnilможет заставить вызывающих абонентов думать, что они могут передать оба аргументаnil(Остин Луо: Вызывающий может подумать, что, поскольку второй параметр имеет реализацию по умолчанию, первый параметр также может иметь), но вызовите его следующим образом:

http.Serve(nil, nil)

Это приведет к уродливой панике.

Совет: не смешивайте опции в сигнатурах функций.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()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)

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

6.2.2 Предпочтение var args аргументам slice

Написание функции или метода для обработки среза очень распространено:

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
}

Учитывая, что если операторы становятся очень длинными, я хотел вынести эту проверку в отдельную функцию, вот результат оптимизации:

// 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? ". Однако было бы лучше, если бы он мог: изменитьanyPositiveПодпись делает обязательным, чтобы вызывающая сторона передала хотя бы один параметр. Мы можем комбинировать обычные и переменные параметры следующим образом:

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将doc的内容写入文件f。
func Save(f * os.File,doc * 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Между прочим, предоставляет механизм закрытия своего потока (Остин Луо: из-заio.WriteCloserПрисутствие,Saveнеявное закрытие потока). Мы унаследовали этот механизм, так что он по-прежнему выглядит как файл, который вызывает ситуацию, в которойwcбудет закрыто.

возможныйSaveБудет звонить безоговорочноCloseИли звоните в случае успехаClose. это даетSaveВызывающий 's поднимает вопрос, что, если они хотят записать другие данные в поток данных после того, как документ был записан?

// 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 Обработка ошибок

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

7.1 Устранение обработки ошибок путем устранения ошибок

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

Примечание. Я не имею в виду «удалить обработку ошибок». Я предлагаю изменить ваш код, чтобы вам не нужно было обрабатывать ошибки. Этот раздел вдохновлен новой книгой Джона Оустерхаута «Философия дизайна программного обеспечения»^9. Одна из глав называется «Определить несуществующие ошибки», и давайте рассмотрим этот совет в 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держитio.Reader, вместо*File- предоставить контент для подсчетаio.Readerявляется обязанностью вызывающего абонента. Мы построилиbufio.Reader, и поместите его в цикл с именемReadStringметод, который накапливает счетчик до конца файла, то мы возвращаем количество прочитанных строк. По крайней мере, это код, который мы ожидаем, но функция усложняется обработкой ошибок. Например, вот странная структура:

_, err = br.ReadString('n')
lines++
if err != nil {
  break
}

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

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

Ошибка еще не проверена.ReadStringВозвращает, когда встречается конец файлаio.EOF. Это, как и ожидалось,ReadStringДолжен быть какой-то способ «стоп, больше нечего читать». Поэтому в нашемCountLineПрежде чем вызывающая сторона вернет ошибку, нам нужно проверить наличие ошибки.нетio.EOF, и распространять его только в этом случае, иначе мы возвращаемnilСказал, что все в порядке. Расс Кокс увидел, что обработка ошибок может запутать работу функций, и я думаю, что это хороший пример. Давайте посмотрим на оптимизированную версию:

func CountLines(r io.Reader) (int, error) {
  sc := bufio.NewScanner(r)
  lines := 0
  for sc.Scan() {
lines++
  }
  return lines, sc.Err()
}

Эта оптимизированная версия используетbufio.Scannerвместоbufio.Reader. существуетbufio.ScannerСлучай использования пакетаbufio.Reader, но он обеспечивает хороший уровень абстракции, который помогает нам удалитьCountLinesНеоднозначная ошибка операции.

Уведомление:bufio.ScannerМожет сканировать по любому шаблону, но по умолчанию ищет только новые строки.sc.Scan()Этот метод возвращает значение, когда строка текста соответствует и ошибок не обнаружено.true,следовательно,for循环会在遇到文件结尾或者遇到错误时退出。 типbufio.Scannerбудет регистрировать первую обнаруженную ошибку, и как только она завершится, мы можем использоватьsc.Err()способ получить эту ошибку. Наконец,sc.Err()будет рассматриваться разумноio.EOF, а когда обнаружен конец файла, но нет других ошибок, превратить ошибку вnil. Совет: если вы столкнулись с ошибкой, которую трудно устранить, попробуйте выделить некоторые действия во вспомогательный класс.

7.1.2 Написать ответ

Мой второй пример был вдохновлен сообщением в блоге «Ошибки — это ценности»^10. В предыдущих лекциях мы видели, как открывать, записывать и закрывать файлы. Обработка ошибок все еще существует, но ее не так сложно устранить, мы можем использовать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Строка состояния построена и проверена на наличие ошибок. Затем прописать ключи и значения для каждого заголовка запроса, также проверенного на наличие ошибок. Наконец, мы используемrnЗакончил раздел шапки запроса и еще проверил на наличие ошибок. Затем скопируйте тело ответа клиенту. Наконец, хотя нам не нужно проверять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)
}

Если вы не обработаете ошибку один раз, вы ее проигнорируете. как мы можем видеть,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Содержимое неизвестно, оно может ничего не содержать, но было бы еще хуже, если бы оно содержало 1/2 фрагмента 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()Метод сообщит следующую информацию: не удалось записать конфигурацию: ошибка записи: ошибка ввода/вывода

7.2.2 Использованиеgithub.com/pkg/errorsошибка упаковки

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

  1. Проверить, еслиnil
  2. распечатать или записать

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

func ReadFile(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.Wrap(err, "open failed")
	}
	defer f.Close()

	buf, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, errors.Wrap(err, "read failed")
	}
	return buf, nil
}

func ReadConfig() ([]byte, error) {
	home := os.Getenv("HOME")
	config, err := ReadFile(filepath.Join(home, ".settings.xml"))
	return config, errors.WithMessage(err, "could not read config")
}

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

Ошибки, о которых сообщается сейчас, будут хорошими ошибками в стиле K&D ^ 11: не удалось прочитать конфигурацию: открыть не удалось: открыть /Users/dfc/.settings.xml: нет такого файла или каталога

И значение ошибки сохраняет ссылку на первоначальную причину.

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Пакеты позволяют вам добавлять контекст к ошибкам так, чтобы их могли обнаружить и люди, и машины. Если бы вы вчера смотрели мой доклад, вы бы зналиerrorОболочка для появится в стандартной библиотеке в следующей версии Go.

8 Параллелизм

Обычно мы выбираем Go для проектов разработки из-за его параллельной природы. Команда Go приложила немало усилий, чтобы сделать параллелизм в Go дешевым (с точки зрения аппаратных ресурсов) и производительным, но с использованием Go По-прежнему можно писать код, который не является ни производительным, ни надежным. Собираясь уйти, я хотел бы дать несколько советов, как избежать ловушек, связанных с параллелизмом.

Первый тип параллелизма, поддерживаемый функциями Go, предназначен для каналов,selectзаявление и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 {
	}
}

Этот код выполняется, как мы и ожидали, он запускает простую веб-службу. Но в то же время он делает что-то еще, что тратит ЦП в бесконечном цикле. Это потому чтоmainпоследняя строкаfor {}Цикл блокирует основную сопрограмму, потому что она не выполняет никаких операций ввода-вывода, не ожидает блокировок, не отправляет и не получает данные по каналу или иным образом не взаимодействует с планировщиком.

Поскольку среда выполнения 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Выполните в сопрограмме и поднимите вопрос «что нужно сделать в основной сопрограмме», лучше просто сделать так, чтобы основная сопрограмма сделала это самаhttp.ListenAndServe.

Совет: программа Gomain.mianЕсли функция завершается, программа 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)
  }
}

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

Совет: многие программистые программисты злоупотребляют COROUTINES, особенно начинающим. Как и во всем в жизни, умеренность - это ключ к успеху.

8.2 Оставьте параллелизм вызывающей стороне

В чем разница между двумя следующими API?

// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)

// ListDirectory returns a channel over which
// directory entries will be published. When the list
// of entries is exhausted, the channel will be closed.
func ListDirectory(dir string) chan string

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

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

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

версия каналаListDirectoryЕсть еще два вопроса:

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

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

func ListDirectory(dir string, fn func(string))

Не удивительно,filepath.WalkDirВот что он делает.

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

8.3 Не запускайте сопрограмму, которая никогда не останавливается

В предыдущем примере показано использование сопрограмм, когда в этом нет необходимости. Но одна из главных причин использования Go — это первоклассные функции параллелизма, которые предоставляет язык. На самом деле, во многих случаях вы хотите воспользоваться параллелизмом, доступным на аппаратном уровне. Для этого вы должны использовать сопрограммы. Это простое приложение обслуживает http на двух разных портах: порт 8080 для трафика самого приложения и порт 8081 для доступа./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Возвращает и заставляет программу закрыть окончательный перезапуск любого диспетчера процессов, который вы используете.

Совет: Точно так же, как параллелизм функции остается за вызывающей стороной, приложения должны оставить мониторинг состояния и перезапуск вызывающей программе. Не возлагайте на приложение ответственность за самоперезапуск — этот процесс лучше всего выполнять вне приложения. но,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 {}
}

Теперь звоним по необходимости поlog.FatalПроверятьserverAppиserveDebugотListenAndServeОшибка возврата. Поскольку оба процессора работают на уровне, мы используемselect{}чтобы заблокировать основную сопрограмму. С этим подходом связано много проблем:

  1. еслиListenAndServeвернутьnil,log.Fatalне будет вызываться, соответствующая служба HTTP будет остановлена, и приложение не завершит работу.
  2. log.Fatalпозвонюos.ExitЗавершите процесс безоговорочно, defer не будет вызываться, другие сопрограммы не будут уведомлены о закрытии, и приложение остановится. Это может затруднить написание тестов для этих функций.

Совет: только вmain.mainилиinitиспользовать по назначениюlog.Fatal.

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

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Каналы не блокируются при отправке состояния, в противном случае это заблокировало бы отключение COROUTINE, вызывая утечки.

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

В результате уведомитьhttp.ServerВведено отключение. Поэтому я преобразовал эту логику во вспомогательную функцию.serveПомогите нам сохранить адрес иhttp.Handler,похожийhttp.ListenAndServeи один для запускаShutdownметодstopряд.

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Он возвращается, и процесс останавливается чисто.

Совет: Самостоятельное написание этой логики является повторяющимся и тонким. Рассмотрим что-то вроде этой сумки,GitHub.com/ и обычный ввод-вывод/работа…Он сделает большую часть работы за вас.

【Заканчивать】

Ссылка на ссылку

  1. Gaston.life/books/EF Fe C…
  2. talks.golang.org/2014/ так что говори. …
  3. woohoo.info Q.com/articles/AP…
  4. Woohoo.LY Sat or.Liu.Color/From/Pike style…
  5. Speaker deck.com/campo has/UN's…
  6. Woohoo.YouTube.com/watch?V=IC2…
  7. medium.com/@horshoe Medicine/lee…
  8. golang.org/doc/go1.4#i…
  9. Dave.Cheney.net/2014/10/17/…
  10. command center.blogspot.com/2014/01/Сатир…
  11. Dave.Cheney.net/2016/04/27/…
  12. Вухуу. Amazon.com/philosophy-…
  13. Blog.go wave .org/errors-area-...
  14. www.gopl.io/

Организовано изcloud.Tencent.com/developer/ ах…

оригинальныйDave.Cheney.net/practical - а…