С таким громким заголовком я думал сначала написать статью. Нет, на самом деле, вполне возможно, что у вас всё хорошо и эта статья — не про вас. Но очень часто, когда люди приходят из других языков, можно видеть, как они пытаются «притянуть за уши» паттерны из того языка, к которому они привыкли, и они в 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: %s\n", a)
}

Обратите внимание на метод String(). В пакете fmt объявлен интерфейс fmt.Stringer, и в этом же пакете принимается реализация этого интерфейса (пусть и в данном случае неявно).

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

Более подробный пример (и обоснование) можно увидеть по следующим ссылкам (на английском):


Пример второго подхода (выделение интерфейсов в отдельный пакет)

Если интерфейс (или какой-то тип) нужен больше, чем в одном месте и он имеет ценность сам по себе, то его нужно выделить в отдельный пакет. Так появился пакет io — в нём собраны наиболее полезные интерфейсы, константы и переменные, которые так или иначе относятся к вводу-выводу. Чтобы не вносить дополнительных зависимостей при импорте этого пакета, есть отдельный пакет, где собраны удобные функции для работы с интерфейсами из io — пакет ioutil.

Интерфейсы из пакета io получились настолько удачными, что, насколько мне известно, Go — это единственный язык, в котором стандартная библиотека «из коробки» умеет печатать одновременно в файлы, сокеты, HTTP-ответы, байтовые буферы и т.д., причём эта функциональность досталась стандартной библиотеке почти «бесплатно», благодаря хорошо продуманным абстракциям.

Общие рекомендации


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

Ссылки


Скорее всего вы уже читали Effective Go, но если нет, то очень рекомендую :). Также есть две замечательные статьи, в которых описаны «хорошие практики» при программировании на go:

Поделиться с друзьями
-->

Комментарии (8)


  1. FungusWarrior
    12.07.2017 20:38
    +13

    С таким громким заголовком я думал сначала написать статью.

    С таким громким заголовком вы её и написали.) Простите, не удержался


  1. JekaMas
    12.07.2017 21:12

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


    1. youROCK
      12.07.2017 21:23

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


  1. Fesor
    12.07.2017 22:12
    -2

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

    Мне как-то казалось что это вообще очень важная штука вне зависимости от языка. Что до циклических зависимостей — это такой хороший признак того что у нас что-то не так с разбивкой на модули. Есть еще такой принцип — Acyclic Dependencies Principle.


    Они все относятся к HTTP и было было бы нелогично разбивать их на несколько пакетов.

    Повышаем Cohesion.


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

    Применяем Dependency Inversion Principle, соблюдаем Abstract Dependency Principle, Stable Dependency Principle.


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


  1. Sirikid
    13.07.2017 01:39
    +8

    Интерфейсы из пакета io получились настолько удачными, что, насколько мне известно, Go — это единственный язык, в котором стандартная библиотека «из коробки» умеет печатать одновременно в файлы, сокеты, HTTP-ответы, байтовые буферы и т.д., причём эта функциональность досталась стандартной библиотеке почти «бесплатно», благодаря хорошо продуманным абстракциям.

    Спорное утверждение, посмотрите на стандартную библиотеку Java.


    1. youROCK
      13.07.2017 05:40

      Я думал, добавлять ли Java, потому что формально она и правда подходит. Но там все-таки не доведено всё до конца. Например, функция printf не может напечатать в байтовый буфер, а только на экран. Ну все эти Stream-интерфейсы не выглядят «почти бесплатными» — в стандартную библиотеку io.Reader, io.Writer и т.д. были добавлены не сразу, и переделывать много кода при этом не пришлось. В Java, если бы все эти *Stream-классы не были бы продуманы изначально, то добавить их в систему потом было бы очень больно.


      1. Sirikid
        13.07.2017 06:02
        +1

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


        Не хотелось бы углубляться в технические детали, но


        Например, функция printf не может напечатать в байтовый буфер, а только на экран.

        printf вообще всё равно куда печатать, подойдет любой наследник OutputStream, эта функция, кстати, появилась только в JDK 1.5.


  1. pawlo16
    13.07.2017 07:44

    Забавно, буквально на этой неделе обнаружил за собой грех делать неоправданно мизерные пакеты по привычке, выработанной для модулей F#. И тут ваша статья :-)