Понимание видимости пакетов в 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, чтобы разместить в нем наши файлы логирования:

  1. mkdir logging

Затем перейдите в этот каталог:

  1. cd logging

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

  1. 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 %s\n", time.Now().Format(time.RFC3339), statement)
}

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

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

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

Давайте выйдем из директории logging, создадим новую директорию под названием cmd и перейдём в неё:

  1. cd ..
  2. mkdir cmd
  3. cd cmd

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

  1. 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 modules — это конфигурационные файлы, размещенные в директории вашего пакета, которые сообщают компилятору, откуда импортировать пакеты. Хотя изучение модулей выходит за рамки этой статьи, мы можем написать всего несколько строк конфигурации, чтобы этот пример работал локально.

Откройте следующий файл go.mod в директории cmd:

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

  1. cd ../logging
  2. 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 с помощью следующих команд:

  1. cd ../cmd
  2. go run main.go

Вы получите вывод, подобный следующему:

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

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

Поскольку функции Debug и Log экспортируются из пакета логирования, мы можем использовать их в нашем пакете 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 %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 следующим образом:

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

Запуск этой программы даст вам следующий вывод:

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

В этом примере мы ввели новый аргумент в метод Log. Теперь мы можем передавать уровень level сообщения журнала. Метод 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.

Теперь мы можем передавать level каждого сообщения, переключая 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...")
}
Output
cmd/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.

Source:
https://www.digitalocean.com/community/tutorials/understanding-package-visibility-in-go