Эта статья представляет собой рассказ Дэвида Чейни, основного докладчика на конференции QCon 2018 Shanghai Station, старшего инженера Heptio и известного эксперта по языку го на английском языке. Эта статья в основном основана на переводахcloud.Tencent.com/developer/ ах…Организовать и опубликовать
введение
В следующих двух сессиях я дам вам несколько лучших практик написания кода Go.
Сегодня это доклад в стиле семинара, и я собираюсь отказаться от этих причудливых PPT и использовать документы, которые вы можете забрать.
Вы можете найти последнюю онлайн-версию этого доклада здесь:Dave.Cheney.net/practical - а…
1. Руководящие принципы
Мы должны говорить о лучших практиках в языке программирования, тогда мы должны сначала уточнить, что является «лучшим». Если вы слушаете моего вчерашнего спикера, вы должны увидеть предложение Расса Кокса из команды Go:
Разработка программного обеспечения — это то, что происходит после того, как вы добавляете продолжительность или разработчиков в процесс программирования. — Расс Кокс
Расс иллюстрирует разницу между «программированием» программного обеспечения и «инженерией» программного обеспечения: первое — это программа, которую вы пишете, а второе — продукт, которым со временем будет пользоваться все больше людей. Инженеры-программисты приходят и уходят, команды растут или сокращаются, требования меняются, добавляются новые функции и исправляются ошибки — в этом суть «инженерии» программного обеспечения.
Я могу быть первым пользователем Go на сцене, но мои утверждения не основаны на моих учетных данных, а сегодня я говорю о руководящем принципе, который на самом деле исходит из самого языка Go, а именно:
- простота
- удобочитаемость
- производительность
Как вы могли заметить, я не упомянул производительность или параллелизм. На самом деле существует довольно много языков, которые работают эффективнее, чем 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. Примечание должно быть сделано по крайней мере один из следующих трех аспектов:
- Комментарии должны объяснять, «что делать».
- Комментарии должны объяснять «как».
- Комментарии должны объяснять, «почему это делается».
Первая форма подходит для открытых символов:
// 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 в другом месте, что приводит к ненадежному использованию глобальных переменных и неработающим функциям, которые зависят от состояния (значения) этой переменной.
Если вы хотите уменьшить муфту, вызванную глобальной переменной, то:
- Переместите связанные переменные как поле в структуру, которая в них нуждается.
- Используйте интерфейс, чтобы уменьшить связь между поведением и реализацией этого поведения.
5 Структура проекта
Давайте рассмотрим ситуацию, когда несколько пакетов объединяются в проект. Обычно это должен быть отдельный репозиторий git, но в будущем разработчики Go будут использовать его взаимозаменяемо.module
иproject
.
Как и пакеты, каждый предмет должен иметь четкую цель. Если ваш проект представляет собой библиотеку, он должен предоставлять только одну функцию, например синтаксический анализ XML или ведение журнала. Вам следует избегать смешивания нескольких разных целей в одном и том же проекте, это помогает избежатьcommon
Появление библиотеки.
Совет: по моему опыту.
common
Библиотеки тесно связаны со своими крупнейшими потребителями (потребителями), что позволяет выполнять отдельные обновления без блокировки шагов.common
Или потребителям становится сложно обновлять или исправлять, что приводит к множеству несвязанных изменений и поломке API.
Если ваш проект представляет собой приложение, такое как ваше веб-приложение, контроллер Kubernetes и т. д., то в вашем проекте может быть один или несколькоmain
Сумка. Например, у контроллера Kubernetes, который я поддерживаю, есть отдельныйcmd/contour
Package, для предоставления услуг по развертыванию кластера 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 Обработка ошибок
Я сделал несколько докладов об обработке ошибок и много писал об этом в своем блоге, и я многое рассказал во вчерашнем разделе, так что я не буду вдаваться в подробности.
- Dave.Cheney.net/2014/12/24/…
- Dave.Cheney.net/2016/04/07/…Вместо этого я хочу поговорить о двух других аспектах обработки ошибок.
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
Шаблоны полезны для подсказки сообщений об ошибках, но за счет маскировки исходного типа ошибки. На мой взгляд, обработка ошибок как непрозрачных значений важна для создания слабосвязанного программного обеспечения, поэтому не имеет значения, какого типа была исходная ошибка, если со значением ошибки делается только одно из следующего.
- Проверить, если
nil
- распечатать или записать
Однако в некоторых сценариях, которые могут встречаться нечасто, вам необходимо восстановить исходную ошибку. В этом случае вы можете использовать что-то вроде моего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
.
Совет: программа Go
main.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{}
чтобы заблокировать основную сопрограмму.
С этим подходом связано много проблем:
- если
ListenAndServe
вернутьnil
,log.Fatal
не будет вызываться, соответствующая служба HTTP будет остановлена, и приложение не завершит работу. -
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/ и обычный ввод-вывод/работа…Он сделает большую часть работы за вас.
【Заканчивать】
Ссылка на ссылку
- Gaston.life/books/EF Fe C…
- talks.golang.org/2014/ так что говори. …
- woohoo.info Q.com/articles/AP…
- Woohoo.LY Sat or.Liu.Color/From/Pike style…
- Speaker deck.com/campo has/UN's…
- Woohoo.YouTube.com/watch?V=IC2…
- medium.com/@horshoe Medicine/lee…
- golang.org/doc/go1.4#i…
- Dave.Cheney.net/2014/10/17/…
- command center.blogspot.com/2014/01/Сатир…
- Dave.Cheney.net/2016/04/27/…
- Вухуу. Amazon.com/philosophy-…
- Blog.go wave .org/errors-area-...
- www.gopl.io/
Организовано изcloud.Tencent.com/developer/ ах…
оригинальныйDave.Cheney.net/practical - а…