Урок 6: Пакеты, модули и внутренняя организация проекта

Урок 6. Пакеты, модули и внутренняя организация проекта

🔄 Node.js → Go (ключевые отличия):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-packages && cd go-packages
go mod init github.com/yourname/go-packages

# Создаём стандартную структуру проекта
mkdir -p cmd/server
mkdir -p internal/user
mkdir -p internal/database
mkdir -p pkg/logger
mkdir -p pkg/middleware
mkdir -p api

📁 Структура проекта

go-packages/ │ ├── go.mod # Модуль: имя, версия Go, зависимости ├── go.sum # Контрольные суммы зависимостей (как lock-файл) │ ├── cmd/ # Точки входа (исполняемые файлы) │ └── server/ │ └── main.go # package main → бинарник │ ├── internal/ # Приватные пакеты (только для ЭТОГО модуля) │ ├── user/ # package user │ │ ├── user.go # Определение структуры User │ │ └── repository.go # Работа с хранилищем пользователей │ └── database/ │ └── postgres.go # Подключение к БД │ ├── pkg/ # Публичные библиотеки (могут импортироваться извне) │ ├── logger/ │ │ └── logger.go # package logger │ └── middleware/ │ └── middleware.go # HTTP middleware │ └── api/ # API-контракты (OpenAPI, proto) └── openapi.yaml

💻 Код: go.mod

module github.com/yourname/go-packages

go 1.22

require (
    github.com/google/uuid v1.6.0
)

require (
    // indirect-зависимости (транзитивные)
)

💻 Код: internal/user/user.go

// Пакет user — внутренний пакет модуля.
// Доступен ТОЛЬКО из go-packages и его подпакетов.
// Внешние модули НЕ могут импортировать internal/.
package user

import (
    "errors"
    "fmt"
    "strings"
)

// User — ЭКСПОРТИРУЕМАЯ структура (заглавная буква U).
// Доступна везде, где импортирован пакет user.
type User struct {
    ID    int    // Экспортируемое поле
    Name  string // Экспортируемое поле
    Email string // Экспортируемое поле

    // password — НЕэкспортируемое поле (строчная буква).
    // Доступно только внутри пакета user.
    password string
}

// NewUser — экспортируемая функция-конструктор.
// Возвращает *User. Пароль хешируется внутри.
func NewUser(id int, name, email, password string) (*User, error) {
    // Валидация (пакетная ответственность)
    if strings.TrimSpace(name) == "" {
        return nil, errors.New("name must not be empty")
    }
    if !strings.Contains(email, "@") {
        return nil, errors.New("invalid email format")
    }
    if len(password) < 8 {
        return nil, errors.New("password too short (min 8 chars)")
    }

    return &User{
        ID:       id,
        Name:     name,
        Email:    email,
        password: hashPassword(password), // приватная функция пакета
    }, nil
}

// ValidatePassword — экспортируемый метод для проверки пароля
func (u *User) ValidatePassword(pass string) bool {
    return hashPassword(pass) == u.password
}

// Info — экспортируемый метод (возвращает публичную информацию)
func (u *User) Info() string {
    return fmt.Sprintf("User{ID:%d, Name:%q, Email:%q}", u.ID, u.Name, u.Email)
}

// hashPassword — НЕэкспортируемая функция (строчная буква).
// Видна только внутри пакета user.
func hashPassword(pass string) string {
    // В реальности — bcrypt/argon2id.
    // Здесь простой пример (НЕ ДЕЛАЙ ТАК В ПРОДАКШЕНЕ!)
    return fmt.Sprintf("hashed_%s", pass)
}

// sanitize — неэкспортируемая (внутренняя утилита)
func sanitize(s string) string {
    return strings.TrimSpace(s)
}

💻 Код: internal/user/repository.go

package user // Тот же пакет user — можно в разных файлах!

import (
    "fmt"
    "sync"
)

// UserRepository — ЭКСПОРТИРУЕМЫЙ интерфейс.
// Описывает контракт хранилища пользователей.
type UserRepository interface {
    Save(u *User) error
    FindByID(id int) (*User, error)
    FindAll() []*User
}

// InMemoryRepo — НЕэкспортируемая структура (строчная).
// Реализует UserRepository в памяти.
type InMemoryRepo struct {
    mu    sync.RWMutex
    users map[int]*User
}

// NewInMemoryRepo — экспортируемый конструктор для InMemoryRepo
func NewInMemoryRepo() *InMemoryRepo {
    return &InMemoryRepo{
        users: make(map[int]*User),
    }
}

// Save — реализация интерфейса UserRepository
func (r *InMemoryRepo) Save(u *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.users[u.ID]; exists {
        return fmt.Errorf("user with ID %d already exists", u.ID)
    }
    r.users[u.ID] = u
    return nil
}

// FindByID — поиск по ID
func (r *InMemoryRepo) FindByID(id int) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    u, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

// FindAll — возвращает всех пользователей
func (r *InMemoryRepo) FindAll() []*User {
    r.mu.RLock()
    defer r.mu.RUnlock()

    result := make([]*User, 0, len(r.users))
    for _, u := range r.users {
        result = append(result, u)
    }
    return result
}

💻 Код: internal/database/postgres.go

package database

import (
    "context"
    "fmt"
    "time"
)

// Config — конфигурация подключения к БД
type Config struct {
    Host     string
    Port     int
    User     string
    Password string
    DBName   string
    SSLMode  string
}

// DSN — формирует строку подключения (Data Source Name)
func (c Config) DSN() string {
    return fmt.Sprintf(
        "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode,
    )
}

// Connect — имитация подключения к PostgreSQL
func Connect(ctx context.Context, cfg Config) error {
    // В реальном коде здесь был бы pgxpool.New(ctx, cfg.DSN())
    fmt.Printf("Connecting to PostgreSQL at %s:%d...\n", cfg.Host, cfg.Port)
    
    // Имитация таймаута подключения
    select {
    case <-time.After(100 * time.Millisecond):
        fmt.Println("Connected successfully!")
        return nil
    case <-ctx.Done():
        return fmt.Errorf("connection cancelled: %w", ctx.Err())
    }
}

💻 Код: pkg/logger/logger.go

// Пакет logger — ПУБЛИЧНАЯ библиотека.
// Может быть импортирована другими модулями.
package logger

import (
    "fmt"
    "io"
    "os"
    "time"
)

// Level — тип уровня логирования
type Level int

// Константы уровней. iota — автоинкремент.
const (
    Debug Level = iota // 0
    Info                // 1
    Warn                // 2
    Error               // 3
)

// String — реализация интерфейса Stringer
func (l Level) String() string {
    switch l {
    case Debug: return "DEBUG"
    case Info:  return "INFO"
    case Warn:  return "WARN"
    case Error: return "ERROR"
    default:    return "UNKNOWN"
    }
}

// Logger — структура логгера
type Logger struct {
    level  Level
    output io.Writer // Куда пишем (stdout, файл, etc.)
}

// New — конструктор. Принимает уровень и опционально Writer.
func New(level Level, outputs ...io.Writer) *Logger {
    out := io.Discard // По умолчанию никуда не пишем
    if len(outputs) > 0 {
        out = outputs[0]
    } else {
        out = os.Stdout // По умолчанию stdout
    }
    return &Logger{level: level, output: out}
}

// log — НЕэкспортируемый метод (внутренний)
func (l *Logger) log(level Level, format string, args ...any) {
    if level >= l.level {
        timestamp := time.Now().Format(time.RFC3339)
        msg := fmt.Sprintf(format, args...)
        fmt.Fprintf(l.output, "[%s] %s %s\n", timestamp, level, msg)
    }
}

// Debug — публичный метод
func (l *Logger) Debug(format string, args ...any) {
    l.log(Debug, format, args...)
}

// Info — публичный метод
func (l *Logger) Info(format string, args ...any) {
    l.log(Info, format, args...)
}

// Warn — публичный метод
func (l *Logger) Warn(format string, args ...any) {
    l.log(Warn, format, args...)
}

// Error — публичный метод
func (l *Logger) Error(format string, args ...any) {
    l.log(Error, format, args...)
}

💻 Код: pkg/middleware/middleware.go

package middleware

import (
    "fmt"
    "time"
)

// Timer — middleware для замера времени выполнения
func Timer(name string) func() {
    start := time.Now()
    return func() {
        elapsed := time.Since(start)
        fmt.Printf("[%s] completed in %v\n", name, elapsed)
    }
}

💻 Код: cmd/server/main.go

// package main — точка входа. Только main компилируется в бинарник.
package main

import (
    "context"
    "fmt"
    "os"
    "time"

    // Абсолютные пути импорта от корня модуля
    "github.com/yourname/go-packages/internal/database"
    "github.com/yourname/go-packages/internal/user"
    "github.com/yourname/go-packages/pkg/logger"
    "github.com/yourname/go-packages/pkg/middleware"
)

func main() {
    // === 1. Инициализация логгера ===
    log := logger.New(logger.Info)
    log.Info("Server starting...")

    // === 2. Подключение к БД ===
    dbCfg := database.Config{
        Host: "localhost", Port: 5432,
        User: "postgres", Password: "secret",
        DBName: "mydb", SSLMode: "disable",
    }

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := database.Connect(ctx, dbCfg); err != nil {
        log.Error("Database connection failed: %v", err)
        os.Exit(1)
    }

    // === 3. Создание пользователей ===
    repo := user.NewInMemoryRepo()

    u1, err := user.NewUser(1, "Alice", "alice@example.com", "securepass123")
    if err != nil {
        log.Error("Failed to create user Alice: %v", err)
    } else {
        repo.Save(u1)
        log.Info("Created: %s", u1.Info())
    }

    u2, err := user.NewUser(2, "Bob", "bob@example.com", "anotherpass456")
    if err != nil {
        log.Error("Failed to create user Bob: %v", err)
    } else {
        repo.Save(u2)
        log.Info("Created: %s", u2.Info())
    }

    // === 4. Демонстрация валидации (ошибка) ===
    _, err = user.NewUser(3, "", "bad-email", "short")
    if err != nil {
        log.Warn("Validation error (expected): %v", err)
    }

    // === 5. Middleware: замер времени ===
    defer middleware.Timer("main")()

    // === 6. Вывод всех пользователей ===
    fmt.Println("\n=== All Users ===")
    for _, u := range repo.FindAll() {
        fmt.Printf("  %s\n", u.Info())
        fmt.Printf("    Password valid? %v\n", u.ValidatePassword("wrongpass"))
        fmt.Printf("    Password valid? %v\n", u.ValidatePassword("securepass123"))
    }

    log.Info("Server stopped gracefully")
}

🚀 Запуск программы

# Скачиваем зависимости (uuid)
go get github.com/google/uuid

# Очищаем неиспользуемые зависимости
go mod tidy

# Запуск
go run ./cmd/server/main.go

# ИЛИ (если пакет main один в cmd/server)
go run ./cmd/server

# Сборка бинарника
go build -o server ./cmd/server
./server

# Просмотр зависимостей
go list -m all

# Просмотр дерева зависимостей
go mod graph

# Вендоринг (копирование зависимостей в vendor/)
go mod vendor

📊 Правила видимости в Go

УровеньСинтаксисВидимость
ЭкспортируемыйUser, NewUser, ValidatePasswordВезде, где импортирован пакет
Неэкспортируемыйpassword, hashPasswordТолько внутри своего пакета
internal/Специальная директорияТолько внутри текущего модуля

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Практический совет:

💡 Best practices от сеньоров:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →