Любая программа на Go состоит из пакетов. Даже первая программа курса —
это пакет main. Пока код помещается в один файл, об этом можно не думать. Но
сервис из курса «Backend на Go» — это десятки файлов: обработка HTTP, очередь
заданий, хранилище. Свалить их в один main.go — значит потом полчаса искать
нужную функцию. Go решает это строго: код группируют по пакетам, а пакеты живут
внутри модуля.
В этой главе разберём три уровня этой системы. Пакет — папка с файлами,
которые работают вместе. Импорт — как один пакет берёт код другого.
Модуль — единица, которой пакеты распространяются и версионируются, со своим
файлом go.mod. Заодно поймём, почему Println пишут с большой буквы, и
напишем первый автоматический тест.
Большинство примеров здесь без кнопки запуска: в браузере выполняется только один
файл, а модуль из нескольких пакетов и тесты (пакета testing там нет) нужно
запускать локально. Если вы ещё не установили Go, вернитесь к разделу о первом запуске в
первой главе.
Пакет — это просто папка с .go-файлами, у которых в первой строке одно и
то же объявление package. Все файлы одного пакета делят общее пространство
имён: функция, объявленная в одном файле, видна в другом без всякого импорта.
Делить пакет на несколько файлов — нормально и полезно: складываете связанные
части по смыслу.
// файл user.gopackage accounttype User struct { Name string}
// файл balance.go — тот же пакет account, другой файлpackage accountfunc 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 accounttype 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 init github.com/me/shop
Она генерирует минимальный go.mod:
module github.com/me/shopgo 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.
Это не файл-«замок» версий: он помогает проверить целостность зависимостей.
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 устанавливается большая стандартная библиотека — набор пакетов,
доступных без скачивания. Часть из них вы уже встречали; вот самые ходовые:
strings — операции над строками: Contains, Split, ToUpper, Join.
strconv — преобразования строк и чисел: Atoi, Itoa, ParseFloat.
sort — сортировка срезов и пользовательских коллекций.
errors — создание и разбор ошибок: New, Is, As.
time — время, длительности, таймеры.
math, math/rand — математика и псевдослучайные числа.
os — аргументы, переменные окружения, работа с файлами (в браузере недоступен).
Всё это обычные пакеты: импортируете по короткому пути и обращаетесь по имени,
так же, как к своим.
Соберём небольшую программу, которая задействует сразу три пакета стандартной
библиотеки: strings разбивает строку, strconv превращает текст в числа, а
sort их упорядочивает.
package mainimport ( "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])}
strings.Split оставляет пробел перед каждым числом (" 7"), и
strconv.Atoi такую строку не разберёт. Поэтому сначала strings.TrimSpace.
Ошибку Atoi здесь отбрасываем через _ только потому, что входная строка
написана прямо в коде; для данных от пользователя так делать нельзя.
Когда строку собирают из многих кусков, используют strings.Builder: он
дописывает куски в один буфер, не создавая новую строку на каждом шаге.
fmt.Fprintf пишет отформатированный текст прямо в него:
package mainimport ( "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))}
&b передаёт адрес билдера: Fprintf должен дописывать в нашу переменную, а
не в её копию — ровно та ситуация из главы об указателях.
TrimSuffix срезает лишний разделитель в конце.
До сих пор результат программы вы проверяли глазами: запустили, посмотрели на вывод. Это работает, пока функция одна и проверок три. Тест — это код, который делает такую проверку за вас: вызывает функцию с известными входами и сравнивает результат с ожидаемым. Запускается одной командой, сколько угодно раз, после каждой правки.
В учебной папке с go.mod создайте sum.go:
package mainfunc Sum(a, b int) int { return a + b}
Суффикс _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 ..
Сообщение называет файл, строку и полученное значение — для этого мы и писали его в 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.gofunc CountWords(s string) int { return len(strings.Fields(s))}
// sum_test.gofunc 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 из главы об ошибках остаются нужны при работе с пакетами и вызовами, которые могут не завершиться успешно.