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

Введение

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

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

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

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

Предварительные требования

Для выполнения примеров из этой статьи вам потребуется следующее:

  • Рабочее пространство Go, настроенное согласно руководству по установке Go и настройке локальной среды для разработки. В настоящем обучающем модуле будет использоваться следующая структура файлов:
. ├── bin │ └── src     └── github.com         └── gopherguides 

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

В отличие от таких языков программирования, как Java и Python, где используются различные* модификаторы доступа*, декларирующие элементы как public, private или protected. Декларирование помогает Go определить, являются ли элементы экспортированными или неэкспортированными. В этом случае экспорт элемента делает его видимым за пределами текущего пакета. Если элемент не экспортирован, его можно видеть и использовать только внутри пакета, где он определен.

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

Рассмотрим следующий код, обращая особое внимание на использование заглавных букв:

greet.go

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

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

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

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

Создайте в каталоге src каталог с именем logging, где будут размещены наши файлы регистрации:

  • mkdir logging

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

  • cd logging

Используйте nano или другой редактор для создания файла logging.go:

  • nano logging.go

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

logging/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 %sn", time.Now().Format(time.RFC3339), statement) } 

В первой строчке этого кода декларируется пакет с именем logging. В этом пакете содержится две экспортируемые функции: Debug и Log. Эти функции сможет вызывать любой другой пакет, импортирующий пакет logging. Также существует private переменная с именем debug. Эта переменная доступна только из пакета logging. Важно отметить, что функция Debug и переменная debug имеют одинаковое написание, но имя функции начинается с заглавной буквы, а имя переменной — нет. Это обеспечивает отдельное декларирование с разной сферой действия.

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

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

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

  • cd ..
  • mkdir cmd
  • cd cmd

Создайте файл с именем main.go в каталоге cmd, который мы только что создали:

  • nano main.go

Теперь мы можем добавить следующий код:

cmd/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

Затем поместите в файл следующий код:

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

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

go.mod

module github.com/gopherguides/logging 

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

cmd/main.go

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

Результат должен выглядеть примерно следующим образом:

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

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

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

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

cmd/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) } 

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

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

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

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

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

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

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

logging/logging.go

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 %sn", time.Now().Format(l.timeFormat), s) } 

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

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

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

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...") } 

При запуске этой программы будут выведены следующие результаты:

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

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

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

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) } 

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

Output. . . 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:

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 %sn", level, time.Now().Format(l.timeFormat), s) } 

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

Основная логика определения необходимости вывода сообщения содержится в методе Log. Также мы представили неэкспортированный метод с именем write. Метод write фактически выполняет вывод сообщения журнала.

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

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")  } 

При запуске вы увидите следующее:

Output[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.

Мы можем передать уровень каждого сообщения, изменив значение debug на false:

main.go

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:

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

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

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")      logger.write("error", "log this message...") } 
Outputcmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write) 

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

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

Заключение

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

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