Как организовать Go-проект с несколькими файлами: под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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, компилятор делает следующее:

  1. Читает go.mod — определяет корневой модуль (в нашем случае log-analyzer).
  2. Сканирует импорты — начиная с main.go, он видит импорт "log-analyzer/pkg/logreader". Путь строится как имя_модуля/путь_от_корня.
  3. Собирает все файлы пакета — компилятор заходит в директорию pkg/logreader/, находит все .go файлы с package logreader и объединяет их в один логический блок. Все функции, типы и переменные, объявленные в любом из этих файлов, становятся доступны внутри пакета (даже если они не экспортируются).
  4. Проверяет циклические зависимости — если пакет A импортирует пакет B, а B импортирует A, компилятор выдаст ошибку import cycle not allowed. Это одна из самых частых проблем в больших проектах.
  5. Блоги

    Рекомендаций по книгам