Все главы учебника
Содержание учебника

Пакеты и модули

14 мин чтенияКонтент v0.12.0

О чём глава

Любая программа на Go состоит из пакетов. Даже первая программа курса — это пакет main. Пока код помещается в один файл, об этом можно не думать. Но сервис из курса «Backend на Go» — это десятки файлов: обработка HTTP, очередь заданий, хранилище. Свалить их в один main.go — значит потом полчаса искать нужную функцию. Go решает это строго: код группируют по пакетам, а пакеты живут внутри модуля.

В этой главе разберём три уровня этой системы. Пакет — папка с файлами, которые работают вместе. Импорт — как один пакет берёт код другого. Модуль — единица, которой пакеты распространяются и версионируются, со своим файлом go.mod. Заодно поймём, почему Println пишут с большой буквы, и напишем первый автоматический тест.

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

Что такое пакет

Пакет — это просто папка с .go-файлами, у которых в первой строке одно и то же объявление package. Все файлы одного пакета делят общее пространство имён: функция, объявленная в одном файле, видна в другом без всякого импорта. Делить пакет на несколько файлов — нормально и полезно: складываете связанные части по смыслу.

// файл user.go
package account
 
type User struct {
    Name string
}
// файл balance.go — тот же пакет account, другой файл
package account
 
func NewUser(name string) *User {
    return &User{Name: name} // User виден без импорта: тот же пакет
}

Имя пакета по соглашению короткое, в нижнем регистре, без подчёркиваний: account, http, strconv. Обычно оно совпадает с именем папки — так проще ориентироваться. Особый случай — пакет main: он не библиотека, а точка входа. Именно в нём живёт func main(), с которой стартует исполняемая программа.

Импорты

Чтобы воспользоваться кодом другого пакета, его импортируют. Один импорт пишут в строку, несколько — группируют в блок import ( ... ):

import (
    "fmt"
    "strings"
)

В кавычках указывают путь импорта — это не имя пакета, а его адрес. Для стандартной библиотеки путь короткий: fmt, strings, math/rand. Для чужого кода путь обычно начинается с домена репозитория, например github.com/google/uuid. А обращаются к содержимому всегда по имени пакета (оно обычно совпадает с последним элементом пути): импортировав math/rand, вызываете rand.Intn(...).

Go придирчив к импортам: неиспользуемый импорт — это ошибка компиляции, а не предупреждение. Сначала раздражает, потом привыкаешь: лишних зависимостей в коде не накапливается. gofmt форматирует код и сортирует импорты внутри групп. goimports дополнительно добавляет нужные импорты и удаляет неиспользуемые.

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

import (
    crand "crypto/rand" // теперь обращаемся как crand.Read(...)
    "math/rand"         // а это просто rand
)

Экспорт: всё решает заглавная буква

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

package account
 
type User struct {
    Name    string // экспортируется: видно снаружи
    balance int    // с маленькой — приватное поле, снаружи недоступно
}
 
func New(name string) *User { ... } // New виден другим пакетам
func validate(name string) bool { ... } // validate — только внутри account

Никаких ключевых слов public/private — регистр первой буквы и есть модификатор доступа. Это работает для всего: функций, типов, методов, полей структур, констант, переменных. Поэтому fmt.Println пишется с большой P (его вызывают извне), а внутренние помощники пакета fmt — с маленькой, и вы их просто не видите.

Практический смысл: то, что с заглавной, — это публичный контракт пакета, его API. То, что с маленькой, — внутренняя кухня, которую автор волен менять, не ломая пользователей. Когда проектируете пакет, делайте экспортируемым только необходимый минимум: чем меньше публичная поверхность, тем легче её поддерживать.

Модуль и go.mod

Пакеты не висят в воздухе — они принадлежат модулю. Модуль — это дерево пакетов, которые версионируются и распространяются как одно целое. У модуля есть корневая папка и в ней файл go.mod, который объявляет путь модуля и его зависимости.

Создаётся модуль одной командой в корне проекта:

go mod init github.com/me/shop

Она генерирует минимальный go.mod:

module github.com/me/shop
 
go 1.25.6

Вторая строка — версия языка, под которую написан модуль. Go 1.25 и старше подставляют версию установленного тулчейна. Начиная с Go 1.26 команда записывает версию на один релиз ниже (Go 1.26 пишет go 1.25.0). Поэтому число у вас может отличаться.

Первая строка — путь модуля. Он становится префиксом для путей импорта всех пакетов внутри. Если в модуле github.com/me/shop есть папка cart, то её пакет импортируют как github.com/me/shop/cart. Так Go однозначно понимает, где искать код, и не путает ваш cart с чужим.

Когда вы добавляете внешнюю зависимость и запускаете go mod tidy, Go дописывает её в go.mod в блок require. Выбор версий задают go.mod и граф зависимостей, а go.sum хранит контрольные суммы скачанных модулей и их файлов go.mod. Это не файл-«замок» версий: он помогает проверить целостность зависимостей.

Как раскладывают код по пакетам

Посмотрим, как это выглядит целиком. Типичный небольшой проект:

shop/
├── go.mod                      module github.com/me/shop
├── go.sum
├── main.go                     package main
├── cart/
│   ├── cart.go                 package cart
│   └── cart_test.go            package cart
└── internal/
    └── pricing/
        └── pricing.go          package pricing

Что здесь важно увидеть:

  • main.go в корне — это package main с func main(). Точка входа, которую собирают в исполняемый файл командой go build.
  • cart/ — обычный библиотечный пакет. Файл cart.go начинается с package cart; из main.go его берут импортом github.com/me/shop/cart.
  • cart_test.go лежит рядом с кодом, который тестирует, — в Go тесты держат в том же пакете, в файлах с суффиксом _test.go.
  • internal/ — особая папка. Пакеты под ней может импортировать только код из дерева каталогов, корнем которого является родитель internal. Это встроенный в Go способ сказать «это деталь реализации, снаружи трогать нельзя».

Когда исполняемых программ в модуле несколько (например, сервер и утилита миграций), их package main кладут в отдельные папки под cmd/: cmd/server/main.go, cmd/migrate/main.go. Так устроен и стартовый проект курса «Backend на Go»: точка входа в cmd/server, логика в internal/jobs.

Главный принцип группировки: по смыслу, а не по типу. Не делайте папки models, services, utils со свалкой всего подряд. Лучше пакет cart, в котором и тип корзины, и операции над ней. Имя пакета — часть API: вызов cart.Add(...) читается лучше, чем utils.AddToCart(...).

Стандартная библиотека

Вместе с Go устанавливается большая стандартная библиотека — набор пакетов, доступных без скачивания. Часть из них вы уже встречали; вот самые ходовые:

  • fmt — форматированный ввод-вывод: Println, Printf, Sprintf.
  • strings — операции над строками: Contains, Split, ToUpper, Join.
  • strconv — преобразования строк и чисел: Atoi, Itoa, ParseFloat.
  • sort — сортировка срезов и пользовательских коллекций.
  • errors — создание и разбор ошибок: New, Is, As.
  • time — время, длительности, таймеры.
  • math, math/rand — математика и псевдослучайные числа.
  • os — аргументы, переменные окружения, работа с файлами (в браузере недоступен).

Всё это обычные пакеты: импортируете по короткому пути и обращаетесь по имени, так же, как к своим.

Соберём небольшую программу, которая задействует сразу три пакета стандартной библиотеки: strings разбивает строку, strconv превращает текст в числа, а sort их упорядочивает.

package main
 
import (
	"fmt"
	"sort"
	"strconv"
	"strings"
)
 
func main() {
	raw := "42, 7, 13, 99, 1"
 
	parts := strings.Split(raw, ",") // ["42", " 7", ...]
	nums := make([]int, 0, len(parts))
	for _, p := range parts {
		n, _ := strconv.Atoi(strings.TrimSpace(p)) // текст -> число
		nums = append(nums, n)
	}
 
	sort.Ints(nums) // сортируем по возрастанию
 
	fmt.Println("отсортировано:", nums)
	fmt.Println("минимум:", nums[0], "максимум:", nums[len(nums)-1])
}

Вывод:

отсортировано: [1 7 13 42 99]
минимум: 1 максимум: 99

strings.Split оставляет пробел перед каждым числом (" 7"), и strconv.Atoi такую строку не разберёт. Поэтому сначала strings.TrimSpace. Ошибку Atoi здесь отбрасываем через _ только потому, что входная строка написана прямо в коде; для данных от пользователя так делать нельзя.

Когда строку собирают из многих кусков, используют strings.Builder: он дописывает куски в один буфер, не создавая новую строку на каждом шаге. fmt.Fprintf пишет отформатированный текст прямо в него:

package main
 
import (
	"fmt"
	"strings"
)
 
func main() {
	var b strings.Builder
	for i := 1; i <= 3; i++ {
		fmt.Fprintf(&b, "шаг %d; ", i) // пишем в Builder, а не на экран
	}
	line := strings.TrimSuffix(b.String(), "; ")
 
	fmt.Println("собрано:", line)
	fmt.Println("в верхнем регистре:", strings.ToUpper(line))
}

Вывод:

собрано: шаг 1; шаг 2; шаг 3
в верхнем регистре: ШАГ 1; ШАГ 2; ШАГ 3

&b передаёт адрес билдера: Fprintf должен дописывать в нашу переменную, а не в её копию — ровно та ситуация из главы об указателях. TrimSuffix срезает лишний разделитель в конце.

Первый тест: проверяем функцию

До сих пор результат программы вы проверяли глазами: запустили, посмотрели на вывод. Это работает, пока функция одна и проверок три. Тест — это код, который делает такую проверку за вас: вызывает функцию с известными входами и сравнивает результат с ожидаемым. Запускается одной командой, сколько угодно раз, после каждой правки.

В учебной папке с go.mod создайте sum.go:

package main
 
func Sum(a, b int) int {
    return a + b
}

Рядом создайте файл sum_test.go:

package main
 
import "testing"
 
func TestSum(t *testing.T) {
    got := Sum(2, 3)
    if got != 5 {
        t.Fatalf("Sum(2, 3) = %d; хотим 5", got)
    }
}

Суффикс _test.go, имя с Test и параметр *testing.T позволяют команде найти проверку. t.Fatalf отмечает ошибку и останавливает этот тест. Если в папке остался main.go из предыдущей главы, он может оставаться: Sum там повторно объявлять не нужно. Функции main для go test не требуется.

go test .
go test ./...

Точка проверяет пакет в текущей папке, ./... — пакеты в ней и во всех вложенных папках. При правильном результате появится строка вида:

ok  	example.com/go-study	0.214s

Иногда вместо времени стоит (cached): Go не стал перезапускать тест, потому что код не менялся. Чтобы запустить заново, добавьте флаг: go test -count=1 ..

Замените + на - в Sum и запустите тест снова:

--- FAIL: TestSum (0.00s)
    sum_test.go:8: Sum(2, 3) = -1; хотим 5
FAIL
FAIL	example.com/go-study	0.180s
FAIL

Сообщение называет файл, строку и полученное значение — для этого мы и писали его в Fatalf. Верните +. Обратите внимание: ошибка компиляции выглядит иначе. Если код не собрался, тест даже не начал выполняться, и в выводе будет [build failed].

Табличный тест

Проверок обычно больше одной, и копировать блок if для каждой утомительно. В Go принято собирать случаи в таблицу — срез анонимных структур из главы о структурах — и проходить по ней циклом:

func TestSumTable(t *testing.T) {
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"положительные", 2, 3, 5},
        {"с отрицательным", -2, 2, 0},
        {"с нулём", 0, 7, 7},
    }
    for _, tc := range cases {
        got := Sum(tc.a, tc.b)
        if got != tc.want {
            t.Errorf("%s: Sum(%d, %d) = %d; хотим %d", tc.name, tc.a, tc.b, got, tc.want)
        }
    }
}

Новый случай — одна строка в таблице. Здесь t.Errorf вместо t.Fatalf: он отмечает провал, но не останавливает тест, и вы увидите все неверные строки таблицы сразу, а не только первую. Именно такой тест вас попросят написать в первом же этапе курса «Backend на Go».

Когда появятся горутины с общей памятью, проверяйте тесты также через go test -race .. Этот флаг ищет гонки данных на выполненных путях. Он не заменяет сравнение результатов, проверку завершения и тесты ошибочных входов. Для -race нужен локальный Go на поддерживаемой платформе и необходимые инструменты C; если команда сообщает об отсутствии компилятора, установите инструменты разработки для своей ОС.

Частые ошибки

  • Путают путь импорта и имя пакета. В кавычках пишут адрес (math/rand), а обращаются по последнему элементу (rand.Intn). Если имя пакета внутри файла отличается от папки — ориентируйтесь на строку package, именно она задаёт, как к пакету обращаться.
  • Забывают про заглавную букву. Объявили func parseConfig(...) с маленькой и удивляются, почему из другого пакета его не видно. Хотите экспортировать — ParseConfig. И наоборот: не делайте публичным то, что должно быть внутренним.
  • Неиспользуемый импорт. Go не соберёт код с импортом, который вы не применяете. Удалите строку (или дайте goimports сделать это за вас). Если пакет нужен только ради его побочного эффекта — есть приём import _ "путь", но это редкий случай.
  • Свалка в utils. Один гигантский пакет «на всё» быстро превращается в клубок зависимостей. Группируйте по смыслу и держите публичный API узким.

Попробуйте сами

Добавьте в sum.go функцию CountWords(s string) int, которая возвращает число слов в строке (подойдёт strings.Fields из главы о коллекциях). Напишите для неё табличный тест минимум на четыре случая: обычная фраза, лишние пробелы между словами, пустая строка, строка из одних пробелов. Перед запуском ответьте: нужен ли в тесте импорт собственного sum.go? Затем намеренно испортите функцию (например, верните len(s)), убедитесь, что тест падает и показывает все неверные случаи, и восстановите её.

Проверка

// sum.go
func CountWords(s string) int {
    return len(strings.Fields(s))
}
// sum_test.go
func TestCountWords(t *testing.T) {
    cases := []struct {
        in   string
        want int
    }{
        {"привет мир", 2},
        {"  много   пробелов  ", 2},
        {"", 0},
        {"   ", 0},
    }
    for _, tc := range cases {
        if got := CountWords(tc.in); got != tc.want {
            t.Errorf("CountWords(%q) = %d; хотим %d", tc.in, got, tc.want)
        }
    }
}

В sum.go нужен import "strings". Импорт sum.go в тесте не нужен: оба файла объявляют один пакет main, а внутри пакета все имена общие. Импорт подключает другой пакет, а не соседний файл. С испорченной версией len(s) провалятся все четыре случая, и благодаря t.Errorf вы увидите их все. Глагол %q в сообщении показывает строку в кавычках, иначе пустую строку и строку из пробелов не различить.

Что дальше

В следующей главе горутины и каналы добавят независимое выполнение: результат придётся не только вычислить, но и дождаться его. Проверки error из главы об ошибках остаются нужны при работе с пакетами и вызовами, которые могут не завершиться успешно.

Продолжить

Остались вопросы?