Видимость пакетов в Go

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

Читайте также:

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

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

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

Требования

Чтобы выполнить это руководство, вам понадобится рабочее пространство Go, настроенное по мануалу Установка Go и настройка локальной среды разработки в Ubuntu 18.04 (также есть мануалы для macOS и Windows 10). Здесь мы используем такую файловую структуру:

├── bin

└── src
. └── github.com
. └── gopherguides

Экспортированные и неэкспортированные элементы

В отличие от других программных языков, таких как Java и Python, которые для указания области действия используют модификаторы доступа типа public, private или protected, Go смотрит, является ли элемент экспортированным (exported) или неэкспортированным (unexported) посредством его объявления. Экспорт элемента в этом случае делает его видимым (visible ) за пределами текущего пакета. Неэкспортированный элемент виден и может использоваться только в том пакете, в котором он был определен.

Внешней видимостью можно управлять с помощью именования: элементы, чье имя начинается с заглавной буквы, видны за пределами текущего пакета. Например, элементы Types, Variables, Constants, Functionsбудут видимы вне своего пакета. Давайте посмотрим на следующий код. Обратите особое внимание на заглавные буквы:

package greet
import "fmt"
var Greeting string
func Hello(name string) string {
return fmt.Sprintf(Greeting, name)
}

Первая строка объявляет, что код находится в пакете greet. Затем объявляются два символа: переменная Greeting и функция Hello. Поскольку обе они начинаются с заглавной буквы, они являются экспортированными и потому доступны для любой внешней программы.

Как указывалось ранее, ограниченный доступ к пакету позволит улучшить дизайн API и упростить внутреннее обновление вашего пакета, не нарушая чей-либо код, который зависит от вашего пакета.

Определение видимости пакета

Чтобы более подробно рассмотреть, как работает видимость пакета в программе, давайте создадим тестовый пакет logging. При этом следует помнить о том, какие элементы вы хотите сделать видимыми вне пакета, а какие нет. Этот пакет будет отвечать за регистрацию всех программных сообщений в консоли. Также он будет следить за уровнем логирования. Уровень описывает тип лога и может принимать одно из трех состояний: info, warning и error.

В каталоге src создайте каталог logging, чтобы поместить в него файлы логов:

mkdir logging

Перейдите в каталог:

cd logging

С помощью текстового редактора типа nano создайте файл logging.go:

nano logging.go

Поместите следующий код в файл logging.go:

package logging
import (
"fmt"
"time"
)
var debug bool
func Debug(b bool) {
debug = b
}
func Log(statement string) {
if !debug {
return
}
fmt.Printf("%s %s\n", time.Now().Format(time.RFC3339), statement)
}

В первой строке этого кода объявляется пакет logging. В этом пакете есть две экспортированные функции: Debug и Log. Эти функции могут быть вызваны любым другим пакетом, который импортирует пакет logging. Также тут есть внутренняя переменная debug. Эта переменная доступна только внутри пакета logging. Обратите внимание: хотя функция Debug и переменная debug называются одинаково, функция пишется с большой буквы, а переменная – нет. Потому это разные объявления с разными областями применения.

Сохраните файл и закройте его.

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

Выйдите из каталога logging, а затем создайте каталог cmd и перейдите в него:

cd ..
mkdir cmd
cd cmd

Создайте файл main.go:

nano main.go

Добавьте следующий код:

package main
import "github.com/gopherguides/logging"
func main() {
logging.Debug(true)
logging.Log("This is a debug statement...")
}

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

Откройте файл go.mod в каталоге cmd:

nano go.mod

Поместите в него строки:

module github.com/gopherguides/cmd
replace github.com/gopherguides/logging => ../logging

Первая строка этого файла сообщает компилятору, что в пакете cmd есть путь к файлу github.com/gopherguides/cmd. Вторая строка сообщает ему, что пакет github.com/gopherguides/logging можно найти локально на диске в каталоге ../logging.

Нам также понадобится файл go.mod для пакета logging. Давайте вернемся в каталог logging и создадим файл go.mod:

cd ../logging
nano go.mod

Добавьте следующую строку в файл:

module github.com/gopherguides/logging

Эта строка говорит компилятору, что созданный нами пакет logging на самом деле является пакетом github.com/gopherguides/logging. Это позволяет импортировать пакет в пакет main с помощью этой строки, которую мы записали ранее:

package main
import "github.com/gopherguides/logging"
func main() {
logging.Debug(true)
logging.Log("This is a debug statement...")
}

Теперь у вас должна быть следующая структура каталогов:

├── cmd
│   ├── go.mod
│   └── main.go
└── logging
. ├── go.mod
. └── logging.go

Теперь, когда все настройки готовы, мы можем запустить программу main из пакета cmd с помощью следующих команд:

cd ../cmd
go run main.go

Вы получите подобный вывод:

2019-08-28T11:36:09-05:00 This is a debug statement...

Программа отобразит текущее время в формате RFC 3339 с выражением, которое мы отправили логгеру. RFC 3339 – это формат, разработанный для представления времени в Интернете, который обычно используется в файлах логов.

Поскольку функции Debug и Log экспортируются из пакета logging, мы можем использовать их в пакете main. Однако переменная debug в пакете logging является неэкспортированной. Попытка ссылки на неэкспортированное объявление приведет к ошибке времени компиляции.

Добавьте следующую выделенную строку в main.go:

package main
import "github.com/gopherguides/logging"
func main() {
logging.Debug(true)
logging.Log("This is a debug statement...")
fmt.Println(logging.debug)
}

Сохраните и закройте файл. Вы получите примерно такую ошибку:

. . .
./main.go:10:14: cannot refer to unexported name logging.debug

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

Видимость в структурах

Схема видимости в логгере, который мы создали в последнем разделе, может работать в простых программах, но она подвержена влиянию слишком многих состояний, чтобы работать для нескольких пакетов. Дело в том, что экспортируемые переменные доступны нескольким пакетам, а они могут изменять переменные и приводить в противоречивые состояния. Если вы таким образом позволите изменить состояние вашего пакета, вам будет сложно предсказать, как ваша программа будет вести себя. Например, в текущем проекте один пакет может установить в переменной Debug значение true, а другой пакет – значение false в том же экземпляре. Возникнет конфликт, который затронет оба пакета, импортирующие пакет logging.

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

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

package logging
import (
"fmt"
"time"
)
type Logger struct {
timeFormat string
debug      bool
}
func New(timeFormat string, debug bool) *Logger {
return &Logger{
timeFormat: timeFormat,
debug:      debug,
}
}
func (l *Logger) Log(s string) {
if !l.debug {
return
}
fmt.Printf("%s %s\n", time.Now().Format(l.timeFormat), s)
}

В этом коде мы создали структуру Logger. Она будет содержать неэкспортированное состояние, включая формат времени и значение переменной debug (true или false). Функция New устанавливает исходное состояние для создания логгера, включая формат времени и состояние отладки. Затем она сохраняет полученные значения в неэкспортированные переменные timeFormat и debug. Мы также создали метод Log для типа Logger, он принимает выражение, который мы хотим отобразить на экране. В методе Log есть ссылка на локальную переменную метода l, чтобы получить доступ к его внутренним полям, таким как l.timeFormat и l.debug.

Этот подход позволяет создать Logger во многих различных пакетах и использовать его независимо от других пакетов.

Чтобы использовать его в другом пакете, давайте отредактируем cmd/main.go следующим образом:

package main
import (
"time"
"github.com/gopherguides/logging"
)
func main() {
logger := logging.New(time.RFC3339, true)
logger.Log("This is a debug statement...")
}

Запустив программу, и вы получите такой результат:

2019-08-28T11:56:49-05:00 This is a debug statement...

В этом коде мы создали экземпляр логгера, вызвав экспортированную функцию New. Мы сохранили ссылку на этот экземпляр в переменной logger. Теперь можно вызвать logging.Log, чтобы отобразить выражения.

Если мы попытаемся сослаться на неэкспортированное поле из Logger, например на поле timeFormat, мы получим ошибку времени компиляции. Попробуйте добавить следующую выделенную строку и запустить cmd/main.go:

package main
import (
"time"
"github.com/gopherguides/logging"
)
func main() {
logger := logging.New(time.RFC3339, true)
logger.Log("This is a debug statement...")
fmt.Println(logger.timeFormat)
}

Это выдаст следующую ошибку:

. . .
cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)

Компилятор видит, что поле logger.timeFormat не экспортируется и поэтому его нельзя извлечь из пакета logging.

Видимость в методах

Методы также бывают экспортированными или неэкспортированными

Чтобы проиллюстрировать это, давайте добавим в логгер уровни логирования. Уровни помогают классифицировать логи, разделяя определенные типы событий. Вот уровни, которые мы поместим в наш логгер:

  • Уровень info, представляющий события информационного типа, которые сообщают пользователям о действии, например Program started или Email sent. Они помогают нам отлаживать и отслеживать части программы, отображая поведение.
  • Уровень warning определяет, когда происходит что-то неожиданное, что не является ошибкой (например, сообщение не удалось отправить: Email failed to send, retrying). Этот уровень помогает увидеть, что в программе идет не так гладко, как вы ожидали.
  • Уровень error показывает, что в программе возникла проблема, например, File not found. Это часто приводит к сбою работы программы.

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

Добавьте уровни логирования, внеся следующие изменения в файл logging/logging.go:

package logging
import (
"fmt"
"strings"
"time"
)
type Logger struct {
timeFormat string
debug      bool
}
func New(timeFormat string, debug bool) *Logger {
return &Logger{
timeFormat: timeFormat,
debug:      debug,
}
}
func (l *Logger) Log(level string, s string) {
level = strings.ToLower(level)
switch level {
case "info", "warning":
if l.debug {
l.write(level, s)
}
default:
l.write(level, s)
}
}
func (l *Logger) write(level string, s string) {
fmt.Printf("[%s] %s %s\n", level, time.Now().Format(l.timeFormat), s)
}

В этом примере мы ввели в метод Log новый аргумент. Теперь можно передавать на уровень сообщения лога — level. Метод Log определяет уровень сообщения. Если это сообщение уровня info или warning, а поле debug имеет значение true, то метод записывает сообщение. В противном случае он игнорирует его. Сообщения уровня error регистрируются всегда.

Большая часть логики для определения того, отображается ли сообщение, находится в методе Log. Мы также представили неэкспортированный метод write. Фактически именно он выводит сообщение лога.

Теперь мы можем использовать уровневое логирование в другом пакете. Для этого измените cmd/main.go следующим образом:

package main
import (
"time"
"github.com/gopherguides/logging"
)
func main() {
logger := logging.New(time.RFC3339, true)
logger.Log("info", "starting up service")
logger.Log("warning", "no tasks found")
logger.Log("error", "exiting: no work performed")
}

Вы получите такой вывод:

[info] 2019-09-23T20:53:38Z starting up service
[warning] 2019-09-23T20:53:38Z no tasks found
[error] 2019-09-23T20:53:38Z exiting: no work performed

В этом примере файл cmd/main.go без проблем использовал метод Log.

Теперь можно передать level каждого сообщения, переключив debug в false:

package main
import (
"time"
"github.com/gopherguides/logging"
)
func main() {
logger := logging.New(time.RFC3339, false)
logger.Log("info", "starting up service")
logger.Log("warning", "no tasks found")
logger.Log("error", "exiting: no work performed")
}

Теперь в выводе будут сообщения уровня error:

[error] 2019-08-28T13:58:52-05:00 exiting: no work performed

Если мы попытаемся вызвать метод write извне пакета logging, мы получим ошибку времени компиляции:

package main
import (
"time"
"github.com/gopherguides/logging"
)
func main() {
logger := logging.New(time.RFC3339, true)
logger.Log("info", "starting up service")
logger.Log("warning", "no tasks found")
logger.Log("error", "exiting: no work performed")
logger.write("error", "log this message...")
}
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)

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

Логгер в этом мануале пошагово показывает, как открывать доступ к отдельным фрагментам кода. Поскольку вы можете контролировать, какие части пакета находятся во внешнем доступе, в будущем вы сможете вносить изменения, не затрагивая чужой код, который зависит от вашего пакета. К примеру, если вы хотите отключить сообщения уровня info, когда debug имеет значение false, вы можете внести это изменение, не влияя на другие части API. Также можно менять сообщения логов, включать в них дополнительную информацию (например, название каталога, из которого запускалась программа).

Заключение

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

Читайте также:

Tags: ,