Пишем на Go как в Google. Лучшие практики — часть первая
- среда, 17 мая 2023 г. в 00:04:51
Этот документ — часть документации по стилю Go в Google. Он не является ни нормативным, ни каноничным, это дополнение к «Руководству по стилю». Подробности смотрите в Обзоре.
Здесь приведены рекомендации по лучшим практикам применения требований «Руководства по стилю» для Go. Это руководство охватывает общие и распространенные случаи, но не может применяться к каждому частному случаю. Обсуждение альтернатив, по возможности, включено в текст руководства вместе с указаниями о том, когда они применимы, а когда — нет.
Полная документация руководства по стилю описывается в обзоре.
При именовании функции или метода учитывайте контекст, в котором это имя читается. Чтобы не возникало лишних повторений в точке вызова, следуйте рекомендациям ниже:
В общем случае имя функции можно не включать следующую информацию:
// Плохо:
package yamlconfig
func ParseYAMLConfig(input string) (*Config, error)
// Хорошо:
package yamlconfig
func Parse(input string) (*Config, error)
В методах не повторяйте имя приемника метода:
// Плохо:
func (c *Config) WriteConfigTo(w io.Writer) (int64, error)
// Хорошо:
func (c *Config) WriteTo(w io.Writer) (int64, error)
Не повторяйте имена переменных, которые указывались как параметры:
// Плохо:
func OverrideFirstWithSecond(dest, source *Config) error
// Хорошо:
func Override(dest, source *Config) error
Не повторяйте имена и типы возвращаемых значений:
// Плохо:
func TransformYAMLToJSON(input *Config) *jsonconfig.Config
// Хорошо:
func Transform(input *Config) *jsonconfig.Config
Однако, если необходимо разграничить функции с похожим именем, допустимо включить в имя дополнительную информацию:
// Хорошо:
func (c *Config) WriteTextTo(w io.Writer) (int64, error)
func (c *Config) WriteBinaryTo(w io.Writer) (int64, error)
Есть и другие правила именования методов и функций:
Имена возвращающих значение функций должны быть образованы от существительных:
// Хорошо:
func (c *Config) JobName(key string) (value string, ok bool)
Таким образом, префикса Get
в именах функций и методов нужно избегать:
// Плохо:
func (c *Config) GetJobName(key string) (value string, ok bool)
Имена выполняющих действия функций должны быть образованы от глаголов:
// Хорошо:
func (c *Config) WriteDetail(w io.Writer) (int64, error)
Идентичные функции, которые отличаются только используемым типом, в конце имени функции должны содержать имя типа:
// Хорошо:
func ParseInt(input string) (int, error)
func ParseInt64(input string) (int64, error)
func AppendInt(buf []byte, value int) []byte
func AppendInt64(buf []byte, value int64) []byte
Если есть «первичная» версия, тип в ее имени можно опустить:
// Хорошо:
func (c *Config) Marshal() ([]byte, error)
func (c *Config) MarshalText() (string, error)
При именовании тестовых пакетов и типов, особенно тестовых дублей (test doubles), применимо несколько правил. По своей функции тестовый дубль может быть заглушкой (stub), объектом-имитацией (fake), макетом объекта (mock) или тестовым шпионом (spy).
В данных примерах речь, как правило, идет о заглушках. Если в вашем случае это имитация или что-то другое, обновите имена соответствующим образом.
Допустим, у вас есть специализированный пакет, представляющий работающий код:
package creditcard
import (
"errors"
"path/to/money"
)
// ErrDeclined указывает на то, что эмитент отклоняет платеж.
var ErrDeclined = errors.New("creditcard: declined")
// Карта содержит информацию о кредитной карте, такую как ее эмитент,
// срок действия и лимит.
type Card struct {
}
// Сервис позволяет совершать операции с кредитными картами внешних
// платежных систем, такие как взимание платы, авторизация, возмещение и подписка.
type Service struct {
}
func (s *Service) Charge(c *Card, amount money.Money) error { /* опущено */ }
Вы хотите создать пакет с тестовыми дублями для другого пакета. Воспользуемся выражением package creditcard
, взятым из приведенного выше кода.
Вариант: можно ввести для теста новый пакет Go, создав его на основе работающего пакета. Чтобы не перепутать эти пакеты, к имени пакета припишем слово test
: ("creditcard" + "test"):
// Хорошо:
package creditcardtest
Если не указано иное, все примеры в последующих разделах будут писаться в рамках package creditcardtest
.
Вы хотите добавить набор тестовых дублей для Service. Поскольку Card фактически заглушка, подобная сообщению Protocol Buffer, он не нуждается в специальной обработке в тестах, а значит, дублирование не требуется. Если вы ожидаете, что тестовые дубли будут применяться только для одного типа (например, Service), вы можете назвать дубли лаконично:
// Хорошо:
import (
"path/to/creditcard"
"path/to/money"
)
// Stub заглушает creditcard.Service и не предоставляет собственного поведения.
type Stub struct{}
func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }
В отличии от имени StubService
или, хуже того, StubCreditCardService
, такой выбор имени открыто приветствуется, ведь имя базового пакета и типы его предметных областей дают достаточно информации о creditcardtest.Stub
.
И наконец, если пакет создан в Bazel, убедитесь, что новое правило go_library
для этого пакета помечено как testonly
:
# Good:
go_library(
name = "creditcardtest",
srcs = ["creditcardtest.go"],
deps = [
":creditcard",
":money",
],
testonly = True,
)
Это стандартный, вполне понятный большинству инженеров подход.
См. также:
Когда для ваших тестов требуется более одного варианта заглушек (например, нужна заглушка, которая всегда выдает ошибку), рекомендуется давать им имена, согласно моделируемому поведению. Например, Stub
можно переименовать в AlwaysCharges
и ввести новую заглушку — AlwaysDeclines
:
// Хорошо:
// AlwaysCharges — заглушка creditcard.Service, симулирующая успех операции.
type AlwaysCharges struct{}
func (AlwaysCharges) Charge(*creditcard.Card, money.Money) error { return nil }
// AlwaysDeclines — заглушка creditcard.Service, симулирующая отклонение платежа.
type AlwaysDeclines struct{}
func (AlwaysDeclines) Charge(*creditcard.Card, money.Money) error {
return creditcard.ErrDeclined
}
Предположим, что package creditcard
содержит несколько типов, и для каждого имеет смысл создавать дубли, как показано ниже для Service
и StoredValue
:
package creditcard
type Service struct {
}
type Card struct {
}
// StoredValue управляет кредитными балансами клиентов. Структура
// применима, когда возвращенный товар зачисляется на локальный счет
// клиента, а не обрабатывается эмитентом кредита. По этой причине он
// реализован как отдельный сервис.
type StoredValue struct {
}
func (s *StoredValue) Credit(c *Card, amount money.Money) error { /* опущено */ }
В этом случае целесообразно давать тестовым дублям более явные имена:
// Хорошо:
type StubService struct{}
func (StubService) Charge(*creditcard.Card, money.Money) error { return nil }
type StubStoredValue struct{}
func (StubStoredValue) Credit(*creditcard.Card, money.Money) error { return nil }
Если переменные относятся к тестовым дублям, их имена должны четко отличать дубли от работающих типов с учетом контекста. Допустим, вы хотите протестировать работающий код:
package payment
import (
"path/to/creditcard"
"path/to/money"
)
type CreditCard interface {
Charge(*creditcard.Card, money.Money) error
}
type Processor struct {
CC CreditCard
}
var ErrBadInstrument = errors.New("payment: instrument is invalid or expired")
func (p *Processor) Process(c *creditcard.Card, amount money.Money) error {
if c.Expired() {
return ErrBadInstrument
}
return p.CC.Charge(c, amount)
}
Тестовый дубль CreditCard
с именем "spy" располагается рядом с рабочими типами, поэтому префикс перед именем поможет внести ясность:
// Хорошо:
package payment
import "path/to/creditcardtest"
func TestProcessor(t *testing.T) {
var spyCC creditcardtest.Spy
proc := &Processor{CC: spyCC}
// объявления опущены: карта и сумма
if err := proc.Process(card, amount); err != nil {
t.Errorf("proc.Process(card, amount) = %v, want %v", got, want)
}
charges := []creditcardtest.Charge{
{Card: card, Amount: amount},
}
if got, want := spyCC.Charges, charges; !cmp.Equal(got, want) {
t.Errorf("spyCC.Charges = %v, want %v", got, want)
}
}
Так понятнее, чем без префикса.
// Плохо:
package payment
import "path/to/creditcardtest"
func TestProcessor(t *testing.T) {
var cc creditcardtest.Spy
proc := &Processor{CC: cc}
// объявления опущены: карта и сумма
if err := proc.Process(card, amount); err != nil {
t.Errorf("proc.Process(card, amount) = %v, want %v", got, want)
}
charges := []creditcardtest.Charge{
{Card: card, Amount: amount},
}
if got, want := cc.Charges, charges; !cmp.Equal(got, want) {
t.Errorf("cc.Charges = %v, want %v", got, want)
}
}
В этом разделе употребляются два неофициальных термина — это сокрытие (stomping) и затенение (shadowing). Они не относятся к официальной терминологии языка Go.
Как и во многих других языках, в Go есть изменяемые переменные. Это означает, что оператор присвоения меняет значение переменной.
// Хорошо:
func abs(i int) int {
if i < 0 {
i *= -1
}
return i
}
При кратком объявлении переменных с помощью оператора :=
иногда новая переменная не создается. Мы называем это сокрытием переменной (stomping). Оно вполне допустимо, когда начальное значение переменной нам больше не потребуется.
// Хорошо:
// innerHandler — хелпер для обработчика запросов, самостоятельно
// отправляющий запросы другим бэкендам.
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
// Unconditionally cap the deadline for this part of request handling.
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info("Capped deadline in inner request")
// Код здесь больше не имеет доступа к исходному контексту.
// Это хороший стиль, если при первом написании такого кода вы ожидаете,
// что даже по мере роста кода ни одна корректная операция не должна
// использовать (возможно, неограниченный) исходный контекст,
// предоставленный вызывающей стороной.
// ...
}
Но будьте осторожны с коротким объявлением переменных в новой области видимости. Оно приводит к созданию новой переменной. Мы называем это затенением переменной. Код после окончания блока относится к начальному значению. Ниже представлена ошибочная попытка сократить крайний срок выполнения (deadline) по условию:
// Плохо:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
// Попытка ограничить срок условием.
if *shortenDeadlines {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info(ctx, "Capped deadline in inner request")
}
// БАГ: "ctx" здесь снова означает контекст, предоставленный
// вызывающей стороной.
// Приведенный выше код с ошибками скомпилирован, потому что и ctx, и
// Cancel использовались внутри оператора if.
// ...
}
Корректный код может выглядеть так:
// Хорошо:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
if *shortenDeadlines {
var cancel func()
// Применяется простое присвоение, = а не :=.
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info(ctx, "Capped deadline in inner request")
}
// ...
}
Здесь мы скрыли (stomping) переменную. Поскольку новой переменной нет, назначаемый тип должен соответствовать типу начальной переменной. При затенении (shadowing) мы вводим полностью новый объект, который может иметь другой тип. Затенение может быть полезно, но здесь для ясности всегда используйте новое имя.
Использовать вне области видимости переменные с именами, которые повторяют имена исходных пакетов — не лучшая идея, ведь незадействованные функции такого пакета становятся недоступными. И наоборот, при выборе имени пакета избегайте имен, которые могут потребовать переименования при импорте или затенить хорошие имена переменных на стороне клиента.
// Плохо:
func LongFunction() {
url := "https://example.com/"
// Oops, now we can't use net/url in code below.
}
Пакеты Go имеют имя, указанное в объявлении пакета, отдельно от пути импорта. Имя пакета для удобочитаемости важнее пути.
Имена пакетов Go должны быть связаны с их функционалом. Назвать пакет всего одним словом: util
, helper
, common
или подобным образом, как правило, не лучшее решение (однако это слово может стать частью имени). Неинформативные имена затрудняют чтение кода, а при частом использовании могут даже вызывать необоснованные конфликты при импорте.
Но точка вызова может выглядеть так:
// Хорошо:
db := spannertest.NewDatabaseFromFile(...)
_, err := f.Seek(0, io.SeekStart)
b := elliptic.Marshal(curve, x, y)
Приблизительное представление о функционале каждого объекта можно получить без списка импортов (cloud.google.com/go/spanner/spannertest
, io
и crypto/elliptic
). С не столь содержательными именами код выглядел бы так:
// Плохо:
db := test.NewDatabaseFromFile(...)
_, err := f.Seek(0, common.SeekStart)
b := helper.Marshal(curve, x, y)
Если вы задавались вопросом о том, насколько большим должен быть пакет в Go, и нужно ли помещать сходные типы в один пакет или разделять их по разным, поиск ответа стоит начать со статьи в блога об именах пакетов в Go. Статья посвящена не только именам. В ней есть полезные подсказки, обсуждения и цитаты по разным темам.
А вот некоторые другие соображения и замечания.
В качестве пакета на одной странице пользователи видят godoc, а все методы, экспортируемые типами из пакета, группируются по их типу; godoc также группирует конструкторы вместе с возвращаемыми ими типами. Если клиентскому коду, скорее всего, потребуется взаимодействие двух значений разного типа, пользователю может быть удобно хранить их в одном пакете.
Код в рамках пакета может получить доступ к неэкспортированным идентификаторам внутри пакета. Если у вас есть несколько связанных типов, реализация которых тесно связана, их размещение в одном пакете позволяет достичь связи между ними, не засоряя деталями об этой связи публичный API.
Тем не менее, если поместить весь проект в один пакет, такой пакет окажется непомерно раздутым. Когда часть проекта концептуально отличается от других частей, проще выделить аутентичную часть в отдельный пакет. Известное клиентам короткое имя пакета вместе с экспортируемым именем типа образуют понятный идентификатор, например bytes.Buffer
, ring.New
. В этой статье блога вы найдете больше примеров.
Стиль Go позволяет гибко менять размер файлов: при сопровождении пакета код можно перемещать внутри пакета из одного файла в другой без ущерба для вызывающих [частей кода]. Но, как показывает опыт, ни один файл со многими тысячами строк, ни множество маленьких файлов оптимальным решением не являются. В Go нет правила "один тип — один файл". Структура файлов организована достаточно хорошо, чтобы редактирующий его программист понимал, что и в каком файле искать. При этом файлы должны быть достаточно маленькими, чтобы в них было легче что-то найти. В стандартной библиотеке исходный код пакета часто разбивают на несколько файлов, группируя взаимосвязанный код в один файл. Хорошим примером может послужить код пакета bytes
. В пакетах с объемной сопроводительной документацией один doc.go
можно выделить для документации пакета и его объявления. В общем случае включать туда что-то еще не требуется.
В кодовой базе Google и в проектах на Bazel расположение каталогов кода Go отличается от расположения кода в проектах Go с открытым исходным кодом: можно иметь несколько целевых объектов (targets) go_library
в одном каталоге. Если вы планируете сделать проект открытым, это хорошее обоснование, чтобы выделить каждому пакету отдельный каталог.
См. также:
Импортирование библиотек протоколов отличается от импортирования других импортируемых объектов Go в плане обработки кросс-языковой спецификой. Правило для переименованных импортов proto основано на правиле создания пакета:
pb
обычно используется в рамках правил go_proto_library
.grpc
обычно используется в рамках правил go_grpc_library
.Префикс обычно состоит из одной или двух букв:
// Хорошо:
import (
fspb "path/to/package/foo_service_go_proto"
fsgrpc "path/to/package/foo_service_go_grpc"
)
Если в пакете используется всего один протокол (proto) или пакет жестко привязан к протоколу, то префикс можно опустить:
import ( pb "path/to/package/foo\_service\_go\_proto" grpc "path/to/package/foo\_service\_go\_grpc" )
Если в протоколе используются универсальные (generic) или малоинформативные символы, а также неочевидные сокращения, префиксом может стать короткое слово:
// Хорошо:
import (
mapspb "path/to/package/maps_go_proto"
)
Здесь, когда связь кода с картами неочевидна, mapspb.Address
понять проще, чем mpb.Address
.
Как правило, импорты группируются в два и более блоков в такой последовательности:
"fmt"
.fpb "path/to/foo_go_proto"
._ "path/to/package"
.Если файл не имеет группы для одной из указанных выше опциональных категорий, соответствующий иморт включается в группу импорта проекта.
Как правило, допустима любая ясная, доступная для понимания группировка импорта. Участники команды могут выбрать группировку импорта gRPC отдельно от импорта protobuf.
Для кода, содержащего только две обязательных группы, то есть стандартные библиотечные и другие импорты, инструмент goimports
выдает результат, соответствующий требованиям этого руководства.
Однако об опциональных группах goimports
ничего не знает, и поэтому аннулирует их. Если опциональные группы применяются, авторы и мейнтейнеры кода должны обратить внимание на соответствие группировки указанным требованиям.
При этом приемлем любой подход, предоставляющий полную и последовательную группировку в соответствии с указанными требованиями.
Это лишь небольшая часть документа, скоро мы опубликуем его продолжение. А пока вы можете начать практиковаться и получать полезный опыт на наших курсах:
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также