November 1, 2015 · Go Translate

Маленькая книга о Go – Глава 4: Организация кода и интерфейсы

О книге

Настало время поговорить о том, как организовывать ваш код.

Пакеты

Для того, чтобы хранить сложные системы и библиотеки организованно, нам нужно научиться пользоваться пакетами. В Go имена пакетов следуют структуре директорий в вашем рабочем пространстве. Если мы создаём систему для покупок, вероятно мы начнем с пакета по имени “shopping” и сохраним его исходные файлы в папке $GOPATH/src/shopping/.

Однако не хочется хранить все подряд в одной папке. Например, возможно мы захотим вынести логику работы с базой данных в другую директорию. Для этого, мы создадим поддиректорию в $GOPATH/src/shopping/db. Имя пакета для файлов в этом подкаталоге будет просто db, но для получения доступа к ним из других пакетов, включая shopping, нам нужно импортировать shopping/db.

Другими словами, когда вы указываете пакет с помощью ключевого слова package, вы используете одно название, а не полную иерархию (например “shopping” или “db”). Когда вы импортируете пакет, необходимо указывать полный путь.

Давайте попробуем. Внутри папки src рабочего пространства Go (которое мы задали в разделе «Приступая к работе» во введении), создадим новую директорию с именем shopping и поддиректорию, которую назовём db.

Внутри shopping/db, создайте файл с именем db.go и напишите следующий код:

package db

type Item struct {
  Price float64
}

func LoadItem(id int) *Item {
  return &Item{
    Price: 9.001,
  }
}

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

Теперь создайте файл с именем pricecheck.go внутри папки shopping. С таким содержанием:

package shopping

import (
  "shopping/db"
)

func PriceCheck(itemId int) (float64, bool) {
  item := db.LoadItem(itemId)
  if item == nil {
    return 0, false
  }
  return item.Price, true
}

Этот код наталкивает на мысль, что импорт shopping/db почему-то так задан специально, ведь мы уже находимся в пакете/папке shopping. В действительности, вы импортируете $GOPATH/src/shopping/db, это означает, что вы так же легко можете импортировать test/db, если у вас есть пакет с именем db внутри папки src/test вашего рабочего пространства.

Если вы создаёте пакет, вам больше ничего не нужно знать, кроме того, что вы уже узнали. Для того, чтобы создать исполняемый файл, вам ещё нужен main. Я предпочитаю делать его в папке main внутри shopping с помощью файла с именем main.go следующего содержания:

package main

import (
  "shopping"
  "fmt"
)

func main() {
  fmt.Println(shopping.PriceCheck(4343))
}

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

go run main/main.go

Циклические импорты

Когда вы начнете писать более сложные системы, вы обязательно столкнётесь с циклическими импортами. Это происходит когда пакет А импортирует пакет Б, а пакет Б импортирует пакет А (непосредственно или косвенно через другой пакет). Это то, что компилятор делать не позволяет.

Давайте изменим структуру нашей системы покупок так, чтобы вызвать ошибку.

Переместите определение Item из shopping/db/db.go в shopping/pricecheck.go. Файл pricecheck.go должен выглядеть так:

package shopping

import (
  "shopping/db"
)

type Item struct {
  Price float64
}

func PriceCheck(itemId int) (float64, bool) {
  item := db.LoadItem(itemId)
  if item == nil {
    return 0, false
  }
  return item.Price, true
}

Если вы попытаетесь запустить код, вы получите несколько ошибок в db/db.go о том, что Item не был определён. Это логично. Item больше не существует в пакете db; он был перемещен в пакет shopping. Нам нужно изменить файл shopping/db/db.go так:

package db

import (
  "shopping"
)

func LoadItem(id int) *shopping.Item {
  return &shopping.Item{
    Price: 9.001,
  }
}

Теперь, когда вы запустите код, вы получите ошибку: import cycle not allowed. Мы исправим её с помощью добавления нового пакета, который будет содержать общие структуры. Директория в итоге будет выглядеть так:

$GOPATH/src
  - shopping
    pricecheck.go
    - db
      db.go
    - models
      item.go
    - main
      main.go

pricecheck.go всё ещё импортирует shopping/db, но db.go теперь импортирует shopping/models вместо shopping, тем самым прерывая цикл. Так как мы переместили общую структуру Item в shopping/models/item.go, нам нужно изменить в shopping/db/db.go ссылку на структуру Item из пакета models:

package db

import (
  "shopping/models"
)

func LoadItem(id int) *models.Item {
  return &models.Item{
    Price: 9.001,
  }
}

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

Видимость

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

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

Например, если наш файл items.go содержит функцию, которая выглядит так:

func NewItem() *Item {
  // ...
}

То она может быть вызвана с помощью models.NewItem(). Но если функцию назвать newItem, получить доступ к ней из другого пакета не получится.

Попробуйте изменить имя другой функции, структуры или поля из пакета shopping. Например, если вы измените имя поля Price структуры Item на price, то вы получите ошибку.

Управление пакетами

Мы использовали команду go для запуска и сборки с помощью run и build, но у нее есть еще команда get, которая используется для получения библиотек сторонних разработчиков. go get поддерживает различные протоколы, но для этого примера мы получим библиотеку с сайта GitHub. Для этого вы должны установить git на свой компьютер.

Когда git установлен, из командной строки выполните:

go get github.com/mattn/go-sqlite3

go get забирает удалённые файлы и сохраняет их в вашем рабочем пространстве. Проверьте свою папку $GOPATH/src. В дополнении к проекту shopping, который создали мы, вы увидете папку github.com. Внутри находится папка mattn, а в ней папка go-sqlite3.

Мы только что говорили о том, как импортировать пакеты, которые лежат в вашем рабочем пространстве. Для использования нового пакета go-sqlite3 мы импортируем его так:

import (
  "github.com/mattn/go-sqlite3"
)

Я знаю, это выглядит как URL адрес, но на самом деле происходит импорт пакета go-sqlite3, который должен находиться в папке $GOPATH/src/github.com/mattn/go-sqlite3.

Управление зависимостями

Команда go get имеет пару дополнительных хитростей. Если мы выполним go get внутри проекта, она просмотрит все файлы на наличие библиотек третих лиц в блоках import и скачает их. В некотором смысле, весь наш исходный код является чем-то вроде Gemfile или package.json.

Если вы выполните go get -u, произойдёт обновление пакетов (или определённого пакета при выполнении go get -u ПОЛНОЕ_ИМЯ_ПАКЕТА).

В конечном счете работа go get вам может показаться неадекватной. По той причине, что нет возможности указать конкретную ревизию, всегда будет использована последняя версия из master/head/trunk/default. Это может стать проблемой, если у вас два проекта и вам необходимы две разные версии одной библиотеки.

Чтобы избавиться от этой проблемы вы можете использовать утилиты управления зависимостями от сторонних разработчиков. Все они достаточно молоды, но две из них выглядят перспективными – goop и godep. Полный список доступен на go-wiki.

Интерфейсы

Интерфейс – это такой тип, который определяет описание, но не содержит реализации:

type Logger interface {
  Log(message string)
}

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

type SqlLogger struct { ... }
type ConsoleLogger struct { ... }
type FileLogger struct { ... }

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

Как можно использовать интерфейс? Так же как и любой другой тип, он может быть полем в структуре:

type Server struct {
  logger Logger
}

или параметром функции (или возвращаемым значением):

func process(logger Logger) {
  logger.Log("hello!")
}

В таких языках как C# или Java мы должны явно указывать, что класс реализует интерфейс:

public class ConsoleLogger : Logger {
  public void Logger(message string) {
    Console.WriteLine(message)
  }
}

В Go это происходит неявно. Если ваша структура имеет функцию с именем Log принимающую параметр string и не возвращающую значений, то она может быть использована как Logger. Это избавляет от подробного описания при использовании интерфейсов:

type ConsoleLogger struct {}
func (l ConsoleLogger) Log(message string) {
  fmt.Println(message)
}

Также существует тенденция к использованию небольших и целенаправленных интерфейсов. Интерфейсов полно в стандартной библиотеке. Пакет io содержит целую кучу реализаций io.Reader, io.Writer, и io.Closer. Если вы пишете функцию, которая ожидает параметр с методом, который вы назвали Close(), то в качестве типа параметра лучше использовать io.Closer вместо вашего конкретного типа.

Интерфейсы могут использовать композицию. И интерфейсы сами по себе могут состоять из интерфейсов. Например, интерфейс io.ReadCloser состоит из io.Reader и io.Closer.

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

Перед тем как продолжить

В конечном счете то, как структурировать ваш Go код в рабочем пространстве, вы поймёте после того, как напишете пару нетривиальных проектов. Главное запомнить, что существует тесная связь между именами пакетов и структурой директорий (не только в самом проекте, но и во всём рабочем пространстве).

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

И, наконец, если вы новичок в использовании интерфейсов, вам может понадобиться некоторое время, чтобы получить какое-то представление о них. Тем не менее, когда вы впервые увидите, что функция ожидает какой-то параметр вроде io.Reader, мысленно поблагодарите её автора за то, что он не требует передавать ему больше чем, нужно.

Далее: Глава 5 – Лакомые кусочки

Comments powered by Disqus