Архитектура проекта
Учимся организовывать папки, разбивать код на пакеты и читать аргументы командной строки
Добро пожаловать в финальный модуль! За предыдущие четыре модуля мы изучили переменные, циклы, функции, структуры, методы, файлы, интерфейсы — словом, весь фундамент языка Go. Теперь пора собрать всё воедино и написать полноценное CLI-приложение. Но прежде чем писать код, нам нужно научиться правильно организовывать проект. В этом уроке мы разберём, как выглядит структура настоящего Go-проекта, что такое пакеты cmd и internal, и как принимать аргументы из командной строки с помощью пакета flag. Это фундамент, на котором мы построим нашу CLI-утилиту в следующих уроках.
🤔 Зачем нужна структура проекта
До сих пор мы писали весь код в одном файле main.go. Для маленьких примеров это нормально. Но представьте, что в файле 500, 1000 или 5000 строк. Искать нужную функцию — мучение. Добавлять новый код — страшно что-то сломать. Тестировать — невозможно.
Правильная структура проекта решает три задачи:
1. Навигация. Любой разработчик, открыв проект, сразу понимает, где что лежит. Точка входа? В cmd/. Бизнес-логика? В internal/. Не нужно изучать весь код — достаточно взглянуть на дерево папок.
2. Изоляция. Каждый пакет занимается своим делом. Пакет, который читает JSON, не знает про командную строку. Пакет, который форматирует вывод, не знает про файловую систему. Если в одном пакете баг — вы чините только его.
3. Переиспользование. Код, выделенный в отдельный пакет, легко использовать повторно. Написали пакет для работы с JSON? Подключайте его из любого места проекта.
В Go-сообществе сложились негласные стандарты организации папок. Это не жёсткие правила языка, а договорённости, которым следуют тысячи проектов. Давайте разберём их по порядку.
📁 Стандартная структура Go-проекта
Вот как выглядит типичный Go-проект:
myapp/
├── cmd/
│ └── myapp/
│ └── main.go # точка входа
├── internal/
│ ├── config/
│ │ └── config.go # чтение настроек
│ ├── converter/
│ │ └── converter.go # бизнес-логика
│ └── output/
│ └── output.go # форматирование вывода
├── go.mod # описание модуля
└── go.sum # контрольные суммы зависимостей
Разберём каждый элемент.
go.mod — паспорт проекта
Файл go.mod вы уже создавали ранее командой go mod init. Он объявляет имя модуля и версию Go. Имя модуля — это путь, по которому другие пакеты будут импортировать ваш код:
module github.com/username/myapp
go 1.22
Даже если вы не планируете публиковать проект на GitHub, принято указывать путь в таком формате. Для учебных проектов можно использовать короткое имя:
module myapp
go 1.22
cmd/ — точки входа
Папка cmd содержит точки входа — файлы с функцией main(). У проекта может быть несколько программ (например, сервер и утилита для миграции базы данных), и каждая из них — отдельная подпапка в cmd/:
cmd/
├── server/
│ └── main.go # запускает веб-сервер
└── migrate/
└── main.go # запускает миграцию БД
Файл main.go внутри cmd/ должен быть максимально коротким. Его задача — прочитать настройки, создать нужные объекты и вызвать логику из других пакетов. Вся «настоящая работа» живёт в internal/.
Вот пример минимального main.go:
// cmd/myapp/main.go
package main
import (
"fmt"
"os"
"myapp/internal/converter"
)
func main() {
result, err := converter.Run("input.json")
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
fmt.Println(result)
}
Обратите внимание: main.go не содержит бизнес-логики. Он только вызывает converter.Run() и обрабатывает результат. Вся логика конвертации спрятана в пакете internal/converter.
💡 Правило: Если ваш main.go разрастается до 100+ строк — это сигнал, что логику пора выносить в отдельные пакеты.
internal/ — внутренняя логика
Папка internal — особенная в Go. Код внутри internal/ доступен только из вашего модуля. Это не просто соглашение — это правило, встроенное в компилятор Go. Если кто-то попытается импортировать ваш пакет из internal/ в свой проект, компилятор выдаст ошибку.
Зачем это нужно? Чтобы вы могли свободно менять внутренний код, не боясь сломать чужие проекты, которые от него зависят. Внутренние пакеты — это детали реализации.
Внутри internal/ создаём подпапки по назначению:
internal/
├── config/ # чтение конфигурации, парсинг флагов
├── converter/ # основная бизнес-логика
├── models/ # структуры данных
└── output/ # форматирование и вывод результата
Каждая подпапка — это отдельный пакет Go. Имя пакета совпадает с именем папки.
🛠 Создаём проект с нуля
Давайте создадим реальный проект с правильной структурой. Наша CLI-утилита будет называться jsontools — инструмент для работы с JSON-файлами. Пока создадим каркас, а в следующих уроках наполним его логикой.
Шаг 1 — создаём папки и инициализируем модуль:
# Создаём корневую папку проекта
mkdir jsontools
cd jsontools
# Инициализируем Go-модуль
go mod init jsontools
# Создаём структуру папок
mkdir -p cmd/jsontools
mkdir -p internal/config
mkdir -p internal/parser
mkdir -p internal/output
Шаг 2 — создаём точку входа cmd/jsontools/main.go:
// cmd/jsontools/main.go
package main
import (
"fmt"
"os"
"jsontools/internal/config"
)
func main() {
// Читаем настройки из аргументов командной строки
cfg, err := config.Parse()
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
fmt.Printf("Файл: %s\n", cfg.Filename)
fmt.Printf("Режим: %s\n", cfg.Mode)
fmt.Println("Готово!")
}
Шаг 3 — создаём пакет конфигурации internal/config/config.go:
// internal/config/config.go
package config
import "fmt"
// Config хранит настройки приложения
type Config struct {
Filename string // путь к JSON-файлу
Mode string // режим работы: "pretty", "validate", "stats"
}
// Parse читает аргументы и возвращает конфигурацию
func Parse() (*Config, error) {
// Пока заглушка — скоро подключим пакет flag
cfg := &Config{
Filename: "data.json",
Mode: "pretty",
}
if cfg.Filename == "" {
return nil, fmt.Errorf("не указан файл")
}
return cfg, nil
}
Шаг 4 — запускаем:
# Из корня проекта запускаем
go run ./cmd/jsontools
# Вывод:
# Файл: data.json
# Режим: pretty
# Готово!
Обратите внимание на путь запуска: go run ./cmd/jsontools. Мы указываем папку, в которой лежит main.go. Префикс ./ означает «относительно текущей папки».
Как работает импорт: В main.go мы пишем import "jsontools/internal/config". Первая часть (jsontools) — это имя модуля из go.mod. Дальше — путь к папке с пакетом. Go сам находит нужные файлы.
📦 Пакеты: правила и видимость
Каждая папка в Go — это пакет. Все .go файлы в одной папке должны иметь одинаковое имя пакета в строке package. Вот главные правила.
Правило заглавной буквы
В Go видимость определяется регистром первой буквы имени. Это одно из самых элегантных решений в языке:
package converter
// Run — экспортируемая функция (заглавная R)
// Доступна из других пакетов: converter.Run()
func Run(filename string) (string, error) {
result := process(filename) // вызываем внутреннюю функцию
return result, nil
}
// process — НЕэкспортируемая функция (строчная p)
// Доступна ТОЛЬКО внутри пакета converter
func process(filename string) string {
return "обработано: " + filename
}
// Result — экспортируемая структура
type Result struct {
Data string // экспортируемое поле
count int // НЕэкспортируемое поле
}
С заглавной буквы (Run, Result, Data) — видно снаружи пакета. Это публичный API.
Со строчной буквы (process, count) — видно только внутри пакета. Это детали реализации.
Если из другого пакета попробовать вызвать converter.process(), компилятор выдаст ошибку:
// main.go
package main
import "myapp/internal/converter"
func main() {
converter.Run("file.json") // ОК — Run экспортирована
converter.process("file.json") // ОШИБКА! process не экспортирована
}
Несколько файлов в одном пакете
Пакет может состоять из нескольких файлов. Все они видят друг друга, как будто это один файл:
internal/converter/
├── converter.go # основная логика
├── helpers.go # вспомогательные функции
└── types.go # структуры данных
// internal/converter/types.go
package converter
// Result — общая структура для всех файлов пакета
type Result struct {
Lines int
Words int
Message string
}
// internal/converter/helpers.go
package converter
import "strings"
// countWords — приватная функция, но доступна в converter.go
func countWords(text string) int {
return len(strings.Fields(text))
}
// internal/converter/converter.go
package converter
import "fmt"
// Analyze — использует типы и функции из других файлов пакета
func Analyze(text string) Result {
words := countWords(text) // функция из helpers.go
return Result{ // тип из types.go
Lines: len(text),
Words: words,
Message: fmt.Sprintf("Найдено %d слов", words),
}
}
Все три файла принадлежат пакету converter. Функция countWords из helpers.go свободно используется в converter.go, хотя они в разных файлах. Тип Result из types.go тоже доступен везде внутри пакета.
💡 Подсказка: Разбивайте пакет на несколько файлов, когда один файл становится длиннее 200-300 строк. Группируйте по смыслу: типы отдельно, хелперы отдельно, основная логика отдельно.
🚩 Пакет flag — чтение аргументов командной строки
Любая CLI-утилита принимает аргументы. Когда вы в терминале пишете go run main.go -file data.json -mode pretty, части -file data.json и -mode pretty — это флаги. Пакет flag из стандартной библиотеки Go делает их разбор простым и удобным.
Базовый пример
Начнём с простейшего примера — программа, которая приветствует пользователя:
package main
import (
"flag"
"fmt"
)
func main() {
// Объявляем флаги
name := flag.String("name", "Мир", "имя для приветствия")
loud := flag.Bool("loud", false, "кричать КАПСОМ")
// Парсим аргументы командной строки
flag.Parse()
// Используем значения
greeting := fmt.Sprintf("Привет, %s!", *name)
if *loud {
greeting = fmt.Sprintf("ПРИВЕТ, %s!!!", *name)
}
fmt.Println(greeting)
}
Запускаем с разными аргументами:
# Без аргументов — используются значения по умолчанию
go run main.go
# Привет, Мир!
# С именем
go run main.go -name Алексей
# Привет, Алексей!
# С именем и криком
go run main.go -name Алексей -loud
# ПРИВЕТ, Алексей!!!
# Справка — автоматически!
go run main.go -help
# Usage of /tmp/go-build.../main:
# -loud
# кричать КАПСОМ
# -name string
# имя для приветствия (default "Мир")
Разберём код по строчкам.
flag.String("name", "Мир", "имя для приветствия") — объявляет строковый флаг. Три аргумента: имя флага, значение по умолчанию и описание для справки. Функция возвращает указатель на строку (*string), поэтому при использовании пишем *name с разыменованием.
flag.Bool("loud", false, "кричать КАПСОМ") — то же самое, но для логического флага. Значение по умолчанию — false. Если передать -loud без значения, оно станет true.
flag.Parse() — читает реальные аргументы из командной строки и заполняет флаги. Эту строку обязательно вызывать после объявления всех флагов, но до их использования.
💡 Бонус: Флаг -help (или -h) генерируется автоматически! Он выводит все объявленные флаги с описаниями. Вам не нужно писать справку вручную.
Типы флагов
Пакет flag поддерживает несколько типов данных:
package main
import (
"flag"
"fmt"
"time"
)
func main() {
// Строка
name := flag.String("name", "default", "строковый флаг")
// Целое число
count := flag.Int("count", 1, "количество повторений")
// Дробное число
rate := flag.Float64("rate", 0.5, "коэффициент")
// Логический
verbose := flag.Bool("verbose", false, "подробный вывод")
// Длительность (особенно удобно!)
timeout := flag.Duration("timeout", 5*time.Second, "тайм-аут")
flag.Parse()
fmt.Printf("name: %s\n", *name)
fmt.Printf("count: %d\n", *count)
fmt.Printf("rate: %.2f\n", *rate)
fmt.Printf("verbose: %v\n", *verbose)
fmt.Printf("timeout: %v\n", *timeout)
}
# Пример запуска
go run main.go -name test -count 3 -rate 0.75 -verbose -timeout 10s
# name: test
# count: 3
# rate: 0.75
# verbose: true
# timeout: 10s
Обратите внимание на flag.Duration — он понимает строки вроде 10s, 5m, 2h, 500ms. Очень удобно для тайм-аутов и задержек.
Альтернативный синтаксис — без указателей
Если вам неудобно работать с указателями, есть второй способ — flag.StringVar и аналоги. Они записывают значение в уже существующую переменную:
package main
import (
"flag"
"fmt"
)
func main() {
var name string
var count int
var verbose bool
// Записываем значения в переменные (не указатели!)
flag.StringVar(&name, "name", "Мир", "имя")
flag.IntVar(&count, "count", 1, "количество")
flag.BoolVar(&verbose, "verbose", false, "подробный вывод")
flag.Parse()
// Используем переменные напрямую — без *
fmt.Printf("Привет, %s! (повторов: %d, подробно: %v)\n",
name, count, verbose)
}
Разница: flag.String возвращает указатель, а flag.StringVar принимает указатель на вашу переменную. Результат одинаковый, выбирайте стиль, который удобнее.
Позиционные аргументы
Помимо флагов (с дефисом), бывают позиционные аргументы — просто слова после всех флагов. Получить их можно через flag.Args():
package main
import (
"flag"
"fmt"
)
func main() {
verbose := flag.Bool("v", false, "подробный вывод")
flag.Parse()
// Позиционные аргументы — всё, что после флагов
args := flag.Args()
fmt.Printf("Флаг -v: %v\n", *verbose)
fmt.Printf("Аргументы: %v\n", args)
fmt.Printf("Количество: %d\n", flag.NArg())
}
go run main.go -v file1.json file2.json file3.json
# Флаг -v: true
# Аргументы: [file1.json file2.json file3.json]
# Количество: 3
flag.Args() возвращает срез строк с позиционными аргументами. flag.NArg() — их количество. Это полезно, когда утилита принимает список файлов для обработки.
🏗 Собираем всё вместе: каркас CLI-утилиты
Теперь объединим структуру проекта и пакет flag. Обновим наш проект jsontools, чтобы он по-настоящему читал аргументы командной строки.
Итоговая структура:
jsontools/
├── cmd/
│ └── jsontools/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ └── app/
│ └── app.go
├── go.mod
└── go.sum
Файл internal/config/config.go — парсинг аргументов:
// internal/config/config.go
package config
import (
"flag"
"fmt"
)
// Config хранит все настройки приложения
type Config struct {
Filename string // путь к JSON-файлу
Mode string // режим: pretty, validate, stats
Indent int // размер отступа для pretty-печати
Verbose bool // подробный вывод
}
// Parse читает аргументы командной строки и возвращает Config
func Parse() (*Config, error) {
cfg := &Config{}
flag.StringVar(&cfg.Filename, "file", "", "путь к JSON-файлу (обязательный)")
flag.StringVar(&cfg.Mode, "mode", "pretty", "режим работы: pretty, validate, stats")
flag.IntVar(&cfg.Indent, "indent", 2, "размер отступа (для режима pretty)")
flag.BoolVar(&cfg.Verbose, "verbose", false, "подробный вывод")
flag.Parse()
// Проверяем обязательные аргументы
if cfg.Filename == "" {
// Может быть, файл передан как позиционный аргумент?
if flag.NArg() > 0 {
cfg.Filename = flag.Arg(0)
} else {
return nil, fmt.Errorf("не указан файл. Использование: jsontools -file data.json")
}
}
// Проверяем корректность режима
switch cfg.Mode {
case "pretty", "validate", "stats":
// всё ОК
default:
return nil, fmt.Errorf("неизвестный режим %q. Доступны: pretty, validate, stats", cfg.Mode)
}
return cfg, nil
}
Файл internal/app/app.go — основная логика приложения:
// internal/app/app.go
package app
import (
"fmt"
"jsontools/internal/config"
)
// Run запускает приложение с заданной конфигурацией
func Run(cfg *config.Config) error {
if cfg.Verbose {
fmt.Printf("[INFO] Файл: %s\n", cfg.Filename)
fmt.Printf("[INFO] Режим: %s\n", cfg.Mode)
fmt.Printf("[INFO] Отступ: %d\n", cfg.Indent)
}
switch cfg.Mode {
case "pretty":
fmt.Println("Режим: форматирование JSON")
// Логику добавим в следующих уроках
case "validate":
fmt.Println("Режим: проверка корректности JSON")
case "stats":
fmt.Println("Режим: статистика по JSON-файлу")
}
return nil
}
Файл cmd/jsontools/main.go — точка входа:
// cmd/jsontools/main.go
package main
import (
"fmt"
"os"
"jsontools/internal/app"
"jsontools/internal/config"
)
func main() {
cfg, err := config.Parse()
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
if err := app.Run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
}
Запускаем и тестируем:
# Без аргументов — ошибка
go run ./cmd/jsontools
# Ошибка: не указан файл. Использование: jsontools -file data.json
# С файлом через флаг
go run ./cmd/jsontools -file data.json
# Режим: форматирование JSON
# С файлом как позиционный аргумент
go run ./cmd/jsontools data.json
# Режим: форматирование JSON
# Все параметры
go run ./cmd/jsontools -file data.json -mode stats -verbose
# [INFO] Файл: data.json
# [INFO] Режим: stats
# [INFO] Отступ: 2
# Режим: статистика по JSON-файлу
# Неправильный режим
go run ./cmd/jsontools -file data.json -mode xml
# Ошибка: неизвестный режим "xml". Доступны: pretty, validate, stats
# Справка
go run ./cmd/jsontools -help
# Usage of jsontools:
# -file string
# путь к JSON-файлу (обязательный)
# -indent int
# размер отступа (для режима pretty) (default 2)
# -mode string
# режим работы: pretty, validate, stats (default "pretty")
# -verbose
# подробный вывод
Посмотрите, как чисто устроен код. main.go — всего 20 строк. Он только читает конфигурацию и запускает логику. Если завтра нужно добавить новый режим — меняем config.go и app.go, а main.go трогать не нужно.
Почему os.Exit(1)? Код завершения 0 означает успех, а любое другое число — ошибку. Если CLI-утилита завершается с ошибкой, она должна вернуть ненулевой код. Это важно для скриптов: if jsontools -file data.json; then echo "ОК"; fi.
Почему fmt.Fprintf(os.Stderr, ...)? Ошибки принято выводить в stderr (поток ошибок), а нормальный вывод — в stdout. Это позволяет перенаправлять вывод в файл, не мешая ошибкам: jsontools -file data.json > output.txt.
🔧 os.Args — низкоуровневый доступ к аргументам
Помимо пакета flag, аргументы можно читать напрямую через os.Args. Это просто срез строк, содержащий все аргументы программы:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Все аргументы:", os.Args)
fmt.Println("Имя программы:", os.Args[0])
if len(os.Args) > 1 {
fmt.Println("Первый аргумент:", os.Args[1])
}
}
go run main.go hello world 123
# Все аргументы: [/tmp/go-build.../main hello world 123]
# Имя программы: /tmp/go-build.../main
# Первый аргумент: hello
os.Args[0] — это всегда путь к самой программе. Пользовательские аргументы начинаются с os.Args[1].
💡 Когда что использовать: flag — для программ с именованными флагами (-file, -mode). os.Args — для простейших случаев, когда аргументы позиционные и их мало. В нашем проекте мы используем flag — он удобнее и даёт автоматическую справку.
⚡ Компиляция в исполняемый файл
До сих пор мы запускали программы через go run. Но для распространения утилиты нужен исполняемый файл. Команда go build компилирует проект:
# Собираем исполняемый файл
go build -o jsontools ./cmd/jsontools
# Теперь можно запускать напрямую
./jsontools -file data.json -mode pretty -verbose
Флаг -o jsontools задаёт имя выходного файла. Без него Go назовёт файл по имени папки.
Go компилирует программу в один бинарный файл без зависимостей. Вы можете скопировать jsontools на другой компьютер с такой же ОС — и он будет работать без установки Go. Это одно из главных преимуществ Go для CLI-инструментов.
Кросс-компиляция: Go умеет собирать программы для других ОС прямо на вашем компьютере:
GOOS=linux GOARCH=amd64 go build -o jsontools-linux ./cmd/jsontools
GOOS=windows GOARCH=amd64 go build -o jsontools.exe ./cmd/jsontools
GOOS=darwin GOARCH=arm64 go build -o jsontools-mac ./cmd/jsontools
📋 Итоги урока
- Правильная структура проекта — залог читаемого и поддерживаемого кода
cmd/— точки входа с минимальным кодом, только запуск и обработка ошибокinternal/— внутренняя логика, защищённая компилятором от импорта извне- Каждая папка — отдельный пакет Go с уникальным именем
- Заглавная буква = экспортировано (публично), строчная = приватно для пакета
- Пакет
flag— удобный разбор флагов командной строки с автоматической справкой flag.String,flag.Int,flag.Bool— объявление флагов,flag.Parse()— парсингflag.StringVar— альтернатива без указателейflag.Args()— позиционные аргументы после флаговos.Args— низкоуровневый доступ к аргументам без парсингаgo build -o name ./cmd/name— компиляция в один бинарный файл- Мы создали каркас CLI-утилиты
jsontools, который будем развивать в следующих уроках
В следующем уроке (5.2) мы научимся работать с JSON в Go: разбирать JSON-файлы в структуры, создавать JSON из данных и обрабатывать вложенные объекты. Мы подключим эту логику к нашей утилите jsontools — и она начнёт по-настоящему читать и форматировать JSON-файлы!