Взлет: обмен навыками программирования на Golang

задняя часть Go API Raft

0. Введение

Прочитайте блог Дэйва Чейни о программировании на ходу:Practical Go: Real world advice for writing maintainable Go programs

В практическом применении для меня, начинающего, эффект улучшения заметен.

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

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

(PS: Если есть какие-либо нарушения, пожалуйста, свяжитесь со мной, я удалю статью вовремя, распространение знаний безгранично, я надеюсь, что все поддержат)

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

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

Автор Дэйв Чейни упомянул, что необходимо учитывать руководящие принципы передового опыта языка го.3 балла:

  1. лаконичный
  2. удобочитаемость
  3. Эффективность разработки

1.1 Краткость

Лаконичность — для людей.Если код очень сложный, или даже нарушает инерционное понимание людей, то модификация и сопровождение коснутся всего тела.

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

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

По моему личному мнению, как и в случае с алгоритмом консенсуса, одной из важных причин, почему raft более широко распространен и применяется, чем paxos, является то, что raft легче понять Автор raft также упомянул в статье, что наиболее важным первоначальным намерением дизайн плота То есть паксос слишком сложен для понимания. Удобочитаемость должна стоять на первом месте.

1.3 Эффективность разработки

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

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

2. Именование

Именование имеет решающее значение для написания читаемых программ на Go!

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

На самом деле очень важно именование не только переменных, но и функций, методов, типов, пакетов и т.д.

2.1 Выбирайте узнаваемые имена вместо коротких имен

Точно так же, как кодирование — это не написание программы в кратчайшее возможное количество строк. Скорее, это написание программ, которые легко читаются.

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

Характеристики хорошего имени должны иметь:

  1. Короткое: хорошее имя должно быть как можно короче, но при этом быть узнаваемым.
    1. Например, метод определения прав пользователя на вход: плохое имяjudgeAuth(от легкого до двусмысленного),judgeUserLoginAuthority(длинный)
    2. хороший примерjudgeLoginAuth
  2. Описательное: хорошее имя должно описывать назначение переменных и констант, а не их содержимое; результат функции или поведение метода, а не их работу; назначение пакета, а не его содержимое. Точность описания измеряет, насколько хорошо название.
    1. Например, спроектируйте пакет для выбора ведущий-ведомый. неправильное имя пакетаleader_operation, хорошее имяelection
    2. неправильное имя функции или методаReturnElection, хорошее имяNewElection
    3. неправильное имя переменной или константыElectionState, хорошее имяRole
  3. Предсказуемость: хорошее имя, по одному только имени люди могут сделать вывод о его назначении. Это должно следовать обычному пониманию каждого. Это будет подробно объяснено ниже. Например
    1. i,j,kЧасто используется для описания значений счетчика ссылок в итерациях.
    2. nОбычно используется для представления накопленного значения счетчика
    3. vОбычно представляет значение функции кодирования
    4. kключ обычно используется в карте
    5. sОбычно используется для представления строк

2.2 Именованная длина

Что касается длины имени, у нас есть следующие предложения:

  1. Если объявление переменной находится на небольшом расстоянии от последнего использования, можно использовать короткое имя переменной.
  2. Если переменная важна, двусмысленности можно избежать, позволив именам переменных быть длиннее, устраняя неоднозначность.
  3. Пожалуйста, не включайте имя типа переменной в имя переменной.
  4. Имена констант должны описывать значение, которое они содержат, а не то, как это значение используется.
  5. Однобуквенные имена могут использоваться для итераций, решений логических ветвей, параметров и возвращаемых значений. Используйте комбинацию букв для имен пакетов и функций.
  6. метод, интерфейс, пакет, пожалуйста, используйте одно слово
  7. Имя пакета также необходимо указать при обращении вызывающего абонента, поэтому используйте имя пакета.

Вот пример из авторского текста:

type Person struct {
	Name string
	Age  int
}

// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
	if len(people) == 0 {
		return 0
	}

	var count, sum int
	for _, p := range people {
		sum += p.Age
		count += 1
	}

	return sum / count
}

В этом примере люди находятся в 7 строках от последнего использования, а переменная p используется для перебора perple, p находится в 1 строке от последнего использования. Так что р можно назвать одной буквой, а людей можно назвать словами.

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

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

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

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

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

и

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

Напротив, очевидно, что более читабельно использовать имя oid, в то время как короткую переменную o нелегко понять.

2.3 Именование переменных не несет в себе тип переменной

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

var usersMap map[string]*User

Мы назвали структуру карты из string в User и назвали ее UsersMap, Вроде бы разумно, но тип переменной уже содержит карту, поэтому указывать ее в переменной не нужно.

По словам автора: Если описание Users неясно, то и nameUsersMap не столь понятно.

То же самое относится к именам функций, таким как:

type Config struct {
    //
}

func WriteConfig(w io.Writer, config *Config)

Имя config избыточно, в типе уже указано, что это *Config, если расстояние между последним обращением к переменной в функции достаточно короткое, то будет более лаконично использовать аббревиатуру c или conf.

Совет: не позволяйте именам пакетов вытеснять хорошие имена переменных. Например, контекстный пакет, если вы используетеfunc WriteLog(context context.Context, message string), то при компиляции будет сообщено об ошибке, потому что имя пакета и имя переменной конфликтуют. Таким образом, в общем случае будет использоватьсяfunc WriteLog(ctx context.Context, message string)

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

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

А для типа переменной в коде не меняйте ее имя много раз, старайтесь использовать одно имя. Например, переменные, обрабатываемые базой данных, не должны иметь каждый раз разные имена, напримерd *sql.DB,dbase *sql.DB,DB *sql.DB, предпочтительно используя идиоматические, согласованные именаdb *sql.DB. Таким образом, когда вы видите переменную db в других кодах, вы также можете сделать вывод, что это*sql.DB

Здесь также упоминаются некоторые идиоматические короткие имена переменных:

  • i, j, kиспользовать в качестве индекса в цикле
  • nдля подсчета и накопления
  • vУказывает значение
  • kПредставляет ключ карты или фрагмента
  • sпредставляет строку

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

Существует несколько типов объявлений для объявления переменных:

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

По мнению автора, это ошибка разработчиков go, но исправлять ее уже поздно, и новая версия должна оставаться форвард-совместимой. С таким количеством способов объявить его, как нам выбрать свой собственный тип?

Автор дает такие предложения:

  • При объявлении переменной, но не ее инициализации, используйтеvar.
var players int    // 0

var things []Thing // an empty slice of Things

var thing Thing    // empty Thing struct
json.Unmarshall(reader, &thing)

varЧасто указывает, что это нулевое значение данного типа.

  • При объявлении и инициализации значений используйте:=
var things []Ting = make([]Thing, 0)

vs

var things = make([]Thing, 0)

vs

things := make([]Thing, 0)

Для go тип справа от = — это тип слева от =. В приведенных выше трех примерах последний использует:=пример, который является достаточным для идентификации типа и достаточно кратким.

22.6 В составе команды

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

3. Примечания

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

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

Например

Это подходящая аннотация для внешних методов, объясняющая, что и как делается.

/ Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
The second form is ideal for commentary inside a method:

Это подходящий комментарий внутри метода, поясняющий, что делается

// 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,
        },
}

Эффект от установки значения 0 понять непросто, а добавление комментариев значительно повышает понятность.

3.1 Комментарии к переменным и константам должны описывать их содержимое, а не то, что они делают

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

const randomNumber = 6 // determined from an unbiased die

В этом примере комментарий описывает, почемуrandomNumberприсвоено значение 6, в комментарии не указано, гдеrandomNumerбудет использоваться. Посмотрите еще несколько примеров:

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

Вот различие: содержимое представляет собой то, что представляет 100, что представляет RFC 7231, но цель 100 — представлять StatusContinue.

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

// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool

3.2 Чтобы добавить документацию к публичным именам

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

Вот две рекомендации для руководства по стилю Google:

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

Например:

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
}

Совет: Прежде чем писать содержимое функции, лучше написать комментарий к функции.

3.2.1 Не пишите комментарии к несовершенному коду, а перепишите его

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

Традиционный подход заключается в регистрации задач в коде для напоминаний. Например

// TODO(dfc) this is O(N^2), find a faster way to do this.

3.2.2 Если вы хотите добавить комментарий к фрагменту кода, подумайте, можно ли его отрефакторить

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

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

Кроме того, чем компактнее функция, тем проще ее тестировать. И само название функции — лучший комментарий.

4. Дизайн упаковки

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

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

Далее мы обсудим, как разработать пакет, включая имя, тип и несколько советов по методам написания и функциям.

4.1 У хорошей упаковки в первую очередь должно быть хорошее имя

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

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

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

  1. Имя пакета слишком общее
  2. Услуги, предоставляемые этим пакетом, пересекаются с другим пакетом. Если это так, подумайте о дизайне упаковки.

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

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

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

Предложение автора состоит в том, чтобыutilsиhelpersТакое удаление имени пакета: проанализируйте сценарий, в котором вызывается функция, и по возможности переместите функцию в пакет вызывающей стороны, даже если это предполагает копирование некоторого кода.

Подсказка: дублирование кода дешевле неправильной абстракции

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

Мы должны максимально сократить количество пакетов, например, теперь их три.common,client,server, мы можем объединить его в пакетhet/http, используйте client.go и server.go, чтобы различать клиент и сервер, чтобы не создавать слишком много избыточных пакетов.

Совет, имя идентификатора содержит имя пакета, напримерnet/httpизGETфункция, вызываемая с помощью записиhttp.Get, учитывайте это при именовании идентификаторов и пакетов

4.3 Вернуться как можно скорее

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

посмотреть пример

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")
}

В сравнении

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
}

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

4.4 Максимальное использование нулей

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

  • значение по умолчанию 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()
}

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

func main() {
	// s := make([]string, 0)
	// s := []string{}
	var s []string

	s = append(s, "Hello")
	s = append(s, "world")
	fmt.Println(strings.Join(s, " "))
}

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

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

Есть два способа оставаться в паре:

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

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

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

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

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

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

5.1 Используйте как можно меньше пакетов и как можно больше

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

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

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

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

5.1.1 Управление кодом в файлах с помощью операторов импорта

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

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

Совет: называйте имя файла существительным

Совет: Компилятор go параллельно компилирует разные пакеты, а также пакеты с разными методами и функциями. Таким образом, изменение местоположения функции в пакете не влияет на время компиляции.

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

Инструмент go поддерживает использованиеtestingpackage записывает тестовые примеры в двух местах. Предположим, ваш пакет называетсяhttp2, то вы можете добавитьhttp2_test.goфайл, использоватьpackage http2. Таким образом, тестовые примеры и код находятся в одном пакете, что называется внутренним тестированием.

Инструмент go также поддерживает специальное объявление пакета: withtestИмя пакета в конце, напримерpackage http_test. Это позволяет вашим файлам тестовых случаев находиться в том же каталоге пакета, что и ваши файлы кода, но при компиляции эти тестовые примеры не будут частью кода вашего пакета. Они существуют в своем собственном пакете. Это называется внешним тестированием.

При написании юнит-тестов авторы рекомендуют использовать внутренние тесты. Внутренние тесты позволяют напрямую тестировать функции или методы.

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

Совет: есть некоторые исключения из приведенных выше рекомендаций, напримерnet/http, http не означает, что это подпакет net.Если вы проектируете иерархическую структуру такого пакета, и в каталоге нет файлов .go, то приведенные выше предложения не применяются.

5.1.3 ИспользованиеinternalПакет уменьшает общедоступный API, открытый внешнему миру.

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

Например,/a/b/c/internal/d/e/fСтруктура каталогов, c как проект,internalДоступ к пакету в каталоге может получить только/a/b/cимпорт, не может быть импортирован другими проектами уровня: например,/a/b/g

5.2 Сведите основную функцию к минимуму

mainфункция иmainПакеты должны быть максимально компактными. Потому что в проекте есть только одинmainпакет, в то время как программа возможна только вmain.mainилиmain.initвызывается один раз. Это ведет кmain.mianТрудно писать тестовые случаи в . Бизнес-логику следует перенести в другие пакеты

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

6. Дизайн API

6.1 Разработка API, которыми нельзя злоупотреблять

Если API сложно использовать в простом сценарии, вызов API будет сложным. Если вызов API сложный, его будет трудно прочитать, и его легко будет не заметить.

6.1.1 Будьте осторожны при использовании функций с несколькими аргументами одного типа

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

func Max(a, b int) int
func CopyFile(to, from string) error

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

Max(8, 10) // 10
Max(10, 8) // 10

Параметры Макса можно поменять местами. Никакой двусмысленности не возникает.

Однако дляCopyFileэто отличается.

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

Из какого файла эти два скопированы в какой файл? Это может легко привести к путанице и двусмысленности.

Возможное решение — ввести вспомогательный тип, который добавляет этот метод:

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")
}

В приведенном выше решенииCopyToОн всегда будет использоваться правильно без двусмысленности.

Совет: API с несколькими параметрами одного типа сложно использовать правильно.

6.2 Вызывающих API не следует принуждать к предоставлению параметров, на которые им не нужно обращать внимание.

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

6.2.1 Поощряйтеnilкак параметр

Если пользователю не нужно обращать внимание на определенное значение параметра API, в качестве параметра по умолчанию можно использовать nil. Вот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Есть два параметра, один адрес прослушивания,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)

существует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)
}

можно представить вServer(l, handler), там будетif handler is nil``,使用Логика DefaultServeMux```. Однако следующие вызовы вызовут панику:

http.Serve(nil, nil)

Подсказка: не помещайте нулевые и ненулевые параметры в один параметр функции.

http.ListenAndServeАвторы этой попытки сделать ее более понятной для пользователей в целом, но это может привести к небезопасному использованию.

В строках кода отображаемое использование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)

Совет: внимательно подумайте, сколько времени экономят программисту вспомогательные функции. Ясность важнее краткости.

6.2.2 Параметр vars лучше, чем параметр []T

Обычно срез используется в качестве аргумента функции.

func ShutdownVms(ids []string) error

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

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

В качестве другого примера, если вам нужно определить, что некоторые параметры не равны 0, вы можете использовать следующие методы:

if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
	// apply the non zero parameters
}

Это делаетifПредложения очень длинные. Есть оптимизированный способ:

// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
	for _, v := range values {
		if v > 0 {
			return true
		}
	}
	return false
}

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

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

// anyPostive indicates if any value is greater than zero.
func anyPositive(first int, rest ...int) bool {
	if first > 0 {
		return true
	}
	for _, v := range rest {
		if v > 0 {
			return true
		}
	}
	return false
}

6.3 Позвольте функциям определять поведение, которое им нужно

Если структуру данных необходимо записать на диск. Это можно написать так:

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

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

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

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

*os.FileОн также содержит множество методов, не все из которых связаны сSaveСвязанный.

Как его оптимизировать?

// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error

использоватьio.ReadWriteCloserИнтерфейс может описать то, что делает функция, в более общем виде. и расширилSaveфункция.

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

Как еще оптимизировать?

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

// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error

Таким образом, мы можем сузить методы, передаваемые в интерфейс, чтобы просто написать и закрыть файл.

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

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

// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error

Лучшее решение - переписатьSave, обеспечивает толькоio.Writer, только запись в файл.

После серии оптимизацийSaveРоль очень понятна, вы можете сохранять данные в интерфейс реализацииio.WriterМесто. Это обеспечивает масштабируемость и уменьшает неоднозначность: он используется только для сохранения и не выполняет операции закрытия или чтения потока.

7. Обработка ошибок

Автор уже писал об обработке ошибок в своем блоге:

inspection-errors

constant-error

Сюда добавляется только некоторый контент, не отраженный в блоге.

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

Лучше, чем запрашивать обработку ошибок, обработка ошибок не требуется. (улучшить код, чтобы обработка ошибок не требовалась)

Автор этого раздела был вдохновлен недавней книгой Джона Оустерхаута «Философия дизайна программного обеспечения». В этой книге есть книга под названием «Определить ошибки вне существования», которая здесь будет применена к языку go.

7.1.1 Подсчет количества строк

Напишем код с таким же количеством строк в файле на той же машине

func CountLines(r io.Reader) (int, error) {
	var (
		br    = bufio.NewReader(r)
		lines int
		err   error
	)

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

	if err != io.EOF {
		return 0, err
	}
	return lines, nil
}

Согласно предыдущему предложению входной параметр функции использует интерфейсio.Readerвместо*File. Функция этой функции заключается в подсчете контента, прочитанного io.Reader.

Эта функция использует функцию ReadString для подсчета достижения конца и накопления. Но это выглядит немного странно из-за введенной обработки ошибок:

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

Он написан таким образом, потому что функция ReadString возвращает ошибку, когда встречает конец.

Мы можем улучшить его следующим образом:

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

В улучшенной версии используетсяbufio.Scanerзамененыbufio.Reader, что улучшает обработку ошибок.

Если сканер обнаружит строку текста,sc.Scan()возвращениеtrue, который возвращает false, если обнаружение или другая ошибка не обнаружены. вместо возврата ошибки. Это упрощает обработку ошибок. и мы можем ввести ошибку вsc.Err()вернуться в.

7.1.2 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
}

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

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

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

7.2 Обработка только одной ошибки за раз

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

// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
        w.Write(buf)
}

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Когда возникает ошибка, мы регистрируем ее, но все равно возвращаем ошибку. Как вы понимаете, при вызовеWriteAllВ функции , он тоже попадет в лог и вернет ошибку. Это привело к тому, что было подсчитано много почетных журналов. Его вызывающий объект может вести себя следующим образом:

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

Но когдаWriteConfigПри вызове возникает ошибка, но контекстной информации нет:

err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF

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
}

Это не приведет к многократному увеличению журнала, но также сохранит неверную контекстную информацию.

7.2.2 Используйте github.com/pkg/errors для переноса сообщений об ошибках.

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

  1. определитьnil
  2. записать сообщение об ошибке в лог

Но есть несколько сценариев, когда вам нужно сохранить исходное сообщение об ошибке. В этом случае вы можете использовать пакет erros:

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)
	}
}

Сообщение об ошибке будет следующим:

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

И неправильный примитивный тип может быть сохранен:

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
		fmt.Printf("stack trace:\n%+v\n", err)
		os.Exit(1)
	}
}

Можно получить следующую информацию:

original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
        /Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
        /Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config

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

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

Многие проекты выбирают язык go из-за его возможностей параллелизма. Команда go сделала все возможное, чтобы сделать параллельные реализации дешевле. Но при использовании go concurrency также есть некоторые подводные камни, и ниже описано, как их избежать.

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 {
	}
}

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

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

Как исправить эту проблему, это следующее:

package main

import (
	"fmt"
	"log"
	"net/http"
	"runtime"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, GopherCon SG")
	})
	go func() {
		if err := http.ListenAndServe(":8080", nil); err != nil {
			log.Fatal(err)
		}
	}()

	for {
		runtime.Gosched()
	}
}

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

(Goshed() означает отказаться от кванта времени процессора и позволить другим горутинам работать)

Если у вас есть опыт программирования на 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 позволяет избежать потери процессора, но не решает основной проблемы.

Решение состоит в том, чтобы не запускать сопрограммуhttp.ListenAndServe(), но выполняется в горутине main.main.

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)
	}
}

существуетhttp.ListenAndServerРеализованы блокировки. Автор упомянул, что многие программисты go злоупотребляют параллелизмом go, и модерация является ключевым моментом.

Вставьте свое собственное понимание здесь:

Как правило, при обработке выхода программы необходимо блокировать и отслеживать соответствующие сигналы (информация об ошибке, выходное сообщение, сигнал: sigkill/sigterm), обычно выбор и канал используются вместе. здесьhttp.ListenAndServeЯ реализовал блокировку выбора самостоятельно, поэтому мне не нужно самому реализовывать набор.

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

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

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

Вторая версия API канала имеет две проблемы:

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

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

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

Этоfilepath.WalkDirметод реализации.

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

Вот пример http-сервиса, прослушивающего два разных порта: 8080 — это порт приложения, а 8001 — анализ производительности запросов./debug/pprofпорт.

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(resp, "Hello, QCon!")
	})
	go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug
	http.ListenAndServe("0.0.0.0:8080", mux)                       // app traffic
}

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

func serveApp() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(resp, "Hello, QCon!")
	})
	http.ListenAndServe("0.0.0.0:8080", mux)
}

func serveDebug() {
	http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}

func main() {
	go serveDebug()
	serveApp()
}

поставивserveAppиserveDebugЛогика реализована внутри собственных функций, их можно комбинировать сmain.mainРазвязка. Мы также следуем приведенному выше совету и оставляем параллелизм вызывающей стороне, например.go 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 {}
}

В приведенной выше программеserverAppиserveDebugКогда служба http работает ненормально, получите сообщение об ошибке и запишите журнал. В основной функции используйте select для блокировки. С этим есть несколько проблем:

  1. еслиListenAndServerвозвращает ноль, тоlog.FatalИсключения не обрабатываются. В это время порт может быть закрыт, но main не может этого обнаружить.
  2. log.Fatalназываетсяos.Exit,os.ExitПрограмма завершится безоговорочно, оператор defers не будет выполнен, и другие горутины не будут уведомлены о том, что их следует закрыть. Эта программа завершается напрямую, и писать модульные тесты неудобно.

Подсказка: должно быть толькоmain.mainИли используйте в функции инициализации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)
		}
	}
}

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

Поскольку безопасного закрытия канала не существует, мы не используем оператор ``for range```` для облегчения канала, а вместо этого используем пропускную способность канала в качестве граничного условия для чтения.

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

В следующем примере мы добавляем вспомогательную функциюserve, он достигаетhttp.ListenAndServeФункция запуска службы http и добавления стоп-канала для приема сообщения об окончании.

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канал, когда изdoneПри чтении сообщения об ошибке закрытие стоп-канала приведет к нормальному выходу других горутин. Таким образом, может быть достигнутоmainФункция завершается нормально.

Совет: самостоятельное написание этой логики для обработки выходов может показаться повторяющимся и тонким. В открытом исходном коде реализовано что-то вроде этого:https://github.com/heptio/workgroup, вы можете обратиться к