С таким громким заголовком я думал сначала написать статью. Нет, на самом деле, вполне возможно, что у вас всё хорошо и эта статья — не про вас. Но очень часто, когда люди приходят из других языков, можно видеть, как они пытаются «притянуть за уши» паттерны из того языка, к которому они привыкли, и они в Go зачастую работают плохо.
В этой статье я хотел бы собрать несколько типичных ошибок, которые делают начинающие программисты на Go (и я в том числе), и как этих ошибок избежать.
Разбиение на пакеты
Пакеты в go обладают одной особенностью, которой нет в большинстве других языков — циклические зависимости между пакетами запрещены. Поэтому разбиение на пакеты неожиданно становится очень важным для того, чтобы вы могли эффективно разрабатывать большие приложения.
Очень часто, при разбиении на пакеты, люди пытаются их делить на слишком мелкие сущности, и появляются пакеты с абстрактными именами вроде «common», «domain», «misc» и т.д., которые содержат «общую логику» между пакетами, во избежание создания циклов. В принципе, непосредственно в этом факте ничего плохого нет, но если посмотреть на стандартную библиотеку, то таких пакетов в ней нет, хотя она оперирует достаточно сложными сущностями.
Как ей это удается? В чём отличие стандартной библиотеки от вашего проекта? Если присмотреться, то можно выделить буквально пару основных пунктов, где в go вещи отличаются от других языков:
1. Пакеты могут быть большими
В пакете «net/http», например, находится больше 40 файлов, и определено больше 20 разных публичных типов. Они все относятся к HTTP и было было бы нелогично разбивать их на несколько пакетов. Типы вроде http.Header, http.Client, http.Server все выглядят логично и нет необходимости в том, чтобы пытаться, к примеру, отделить реализацию клиента от реализации сервера просто ради получения более мелких модулей.
2. Пакеты могут состоять почти полностью только из интерфейсов, глобальных констант и переменных
Например, есть пакет io, который содержит в себе все необходимые интерфейсы и константы, которые относятся к весьма общей концепции «ввода-вывода». Но в этом пакете вообще нет намека на реализацию, потому что реализаций этих интерфейсов много и в противном случае получились бы кольцевые зависимости.
Если кратко, то идея состоит в следующем — разбивайте на пакеты на основании предметной области, к которой соответствующие сущности относятся. Если какие-то сущности из разных пакетов ссылаются друг на друга, то, возможно, надо их просто засунуть в один большой пакет и вы в итоге заодно получите намного более логичную и понятную иерархию типов.
Использование интерфейсов
Интерфейсы должны определяться в отдельном пакете от реализации. Реализация же возвращает конкретный тип, а не интерфейс. Почти всегда интерфейсы должны определяться в том пакете, который принимает зависимость, или же совсем отдельно. Это позволяет одновременно избегать циклических зависимостей, и делает ваш код более тестируемым и гибким.
Пример первого подхода (интерфейс определяется в приемнике)
Вы все знаете пакет fmt. И также наверняка писали что-то вроде следующего:
// файл habr.go
package habr
type Article struct { title string }
func (a *Article) String() string { return a.title }
// файл main.go
package main
import (
"fmt"
"habr"
)
func main() {
a := &habr.Article{title: "Вы используете интерфейсы в Go неправильно!"}
fmt.Printf("The article: %sn", a)
}
Обратите внимание на метод String(). В пакете fmt объявлен интерфейс fmt.Stringer, и в этом же пакете принимается реализация этого интерфейса (пусть и в данном случае неявно).
Пакет habr же, в свою очередь, от пакета fmt вообще не зависит и пакет fmt может свободно его импортировать, если пожелает. Это позволяет «мягко» создавать циклические зависимости, без необходимости рефакторить код и перестраивать всю структуру пакетов.
Более подробный пример (и обоснование) можно увидеть по следующим ссылкам (на английском):
- github.com/golang/go/wiki/CodeReviewComments#interfaces
- idiomaticgo.com/post/best-practice/accept-interfaces-return-structs
Пример второго подхода (выделение интерфейсов в отдельный пакет)
Если интерфейс (или какой-то тип) нужен больше, чем в одном месте и он имеет ценность сам по себе, то его нужно выделить в отдельный пакет. Так появился пакет io — в нём собраны наиболее полезные интерфейсы, константы и переменные, которые так или иначе относятся к вводу-выводу. Чтобы не вносить дополнительных зависимостей при импорте этого пакета, есть отдельный пакет, где собраны удобные функции для работы с интерфейсами из io — пакет ioutil.
Интерфейсы из пакета io получились настолько удачными, что, насколько мне известно, Go — это единственный язык, в котором стандартная библиотека «из коробки» умеет печатать одновременно в файлы, сокеты, HTTP-ответы, байтовые буферы и т.д., причём эта функциональность досталась стандартной библиотеке почти «бесплатно», благодаря хорошо продуманным абстракциям.
Общие рекомендации
В этой (немного короткой и немного сумбурной) статье я привел 2 вещи, которые новички часто пропускают в рекомендациях по использованию go. Надеюсь, эта статья поможет достигнуть лучшего понимания, как не испытывать боль при использовании этого прекрасного языка.
Ссылки
Скорее всего вы уже читали Effective Go, но если нет, то очень рекомендую :). Также есть две замечательные статьи, в которых описаны «хорошие практики» при программировании на go:
- github.com/golang/go/wiki/CodeReviewComments
- idiomaticgo.com/post/best-practice/accept-interfaces-return-structs
Автор: youROCK