Go-проект из одного файла — это легко. А из десяти?
Представьте: вы пишете на Go утилиту для парсинга логов. Сначала всё помещается в один файл main.go. Работает, всё отлично. Но через неделю код разрастается: появляются функции для чтения файлов, для фильтрации, для отправки данных в БД. Один файл превращается в монстра на 2000 строк. Знакомо?
Здесь встаёт вопрос, который мучает каждого второго новичка (и который собрал тысячи просмотров на Stack Overflow): как правильно организовать Go-проект с несколькими файлами? Не просто разбросать код по папкам, а сделать так, чтобы это работало, компилировалось и не вызывало ошибок вроде undefined или import cycle not allowed.
В этой статье мы не просто дадим шаблон. Мы заглянем под капот компилятора Go и поймём, как на самом деле работает связка файлов, пакетов и модулей. Вы узнаете, почему go run *.go иногда спасает, а иногда нет, и как построить архитектуру, которая масштабируется.
Файл, пакет, модуль — базовая иерархия
Прежде чем бросаться в код, давайте разберём три ключевых понятия. Без них организация Go-проекта превращается в угадайку.
- Файл (
.go) — это просто текстовый файл с кодом. Сам по себе он ничего не значит для компилятора. - Пакет (
package) — это логическая группа файлов, находящихся в одной директории. Все файлы в папке обязаны иметь одинаковое объявлениеpackage. Если в папке лежат файлы с разными пакетами, компилятор выдаст ошибку. - Модуль (
go.mod) — это корень вашего проекта. Он определяет имя модуля, его зависимости и версии Go. Модуль может содержать множество пакетов.
Золотое правило: один каталог = один пакет. Исключение — тесты, но о них позже.
Практический пример: проект "Анализатор логов"
Давайте создадим небольшой проект, который читает файл лога, фильтрует строки по ключевому слову и выводит результат. Мы сделаем это сначала "в лоб" (один файл), а потом разобьём на несколько файлов правильно.
Этап 1: Всё в одном файле (плохо)
// main.go
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func readLog(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
func filterLines(lines []string, keyword string) []string {
var result []string
for _, line := range lines {
if strings.Contains(line, keyword) {
result = append(result, line)
}
}
return result
}
func main() {
lines, err := readLog("server.log")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
filtered := filterLines(lines, "ERROR")
for _, l := range filtered {
fmt.Println(l)
}
}
Работает. Но представьте, что readLog усложняется (нужна поддержка gzip, кодировок), а filterLines обрастает регулярками. Файл станет нечитаемым.
Этап 2: Разбиваем на пакеты (правильно)
Создадим структуру каталогов:
log-analyzer/
├── go.mod
├── main.go
└── pkg/
└── logreader/
├── reader.go
└── filter.go
Инициализируем модуль:
go mod init log-analyzer
Теперь напишем код пакета logreader. Обратите внимание: все файлы в папке pkg/logreader/ имеют package logreader.
pkg/logreader/reader.go
package logreader
import (
"bufio"
"os"
)
func ReadLog(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
pkg/logreader/filter.go
package logreader
import "strings"
func FilterLines(lines []string, keyword string) []string {
var result []string
for _, line := range lines {
if strings.Contains(line, keyword) {
result = append(result, line)
}
}
return result
}
Обратите внимание: функции называются с большой буквы (ReadLog, FilterLines). В Go это означает, что они экспортируемые (публичные) и доступны из других пакетов.
main.go
package main
import (
"fmt"
"log-analyzer/pkg/logreader"
)
func main() {
lines, err := logreader.ReadLog("server.log")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
filtered := logreader.FilterLines(lines, "ERROR")
for _, l := range filtered {
fmt.Println(l)
}
}
Теперь main.go — это чистый "дирижёр", а вся логика спрятана в пакете. Если нужно добавить новый тип фильтрации, мы не трогаем main, а просто дописываем функции в filter.go.
Как это работает под капотом компилятора
Теперь самое интересное. Когда вы запускаете go build или go run, компилятор делает следующее:
- Читает
go.mod— определяет корневой модуль (в нашем случаеlog-analyzer). - Сканирует импорты — начиная с
main.go, он видит импорт"log-analyzer/pkg/logreader". Путь строится какимя_модуля/путь_от_корня. - Собирает все файлы пакета — компилятор заходит в директорию
pkg/logreader/, находит все.goфайлы сpackage logreaderи объединяет их в один логический блок. Все функции, типы и переменные, объявленные в любом из этих файлов, становятся доступны внутри пакета (даже если они не экспортируются). - Проверяет циклические зависимости — если пакет A импортирует пакет B, а B импортирует A, компилятор выдаст ошибку
import cycle not allowed. Это одна из самых частых проблем в больших проектах.