Урок 38: Генерация кода — sqlc, Wire, oapi-codegen

Урок 38. Генерация кода — sqlc, Wire, oapi-codegen

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

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

mkdir go-codegen && cd go-codegen
go mod init go-codegen

# Устанавливаем инструменты
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/google/wire/cmd/wire@latest
go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest

# Создаём структуру
mkdir -p internal/database
mkdir -p internal/database/queries
mkdir -p internal/di
mkdir -p internal/api
mkdir -p cmd/server

🗄️ Файл: internal/database/schema.sql

-- Схема БД для sqlc

CREATE TABLE IF NOT EXISTS authors (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(255) NOT NULL,
    bio         TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS books (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       VARCHAR(255) NOT NULL,
    author_id   UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
    price       BIGINT NOT NULL, -- цена в центах
    published   BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_books_author ON books(author_id);

🗄️ Файл: internal/database/queries/authors.sql

-- name: CreateAuthor :one
INSERT INTO authors (name, bio)
VALUES ($1, $2)
RETURNING *;

-- name: GetAuthor :one
SELECT * FROM authors WHERE id = $1;

-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;

-- name: UpdateAuthor :one
UPDATE authors
SET name = $2, bio = $3
WHERE id = $1
RETURNING *;

-- name: DeleteAuthor :exec
DELETE FROM authors WHERE id = $1;

-- name: CountAuthors :one
SELECT COUNT(*) FROM authors;

🗄️ Файл: internal/database/queries/books.sql

-- name: CreateBook :one
INSERT INTO books (title, author_id, price, published)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: GetBook :one
SELECT * FROM books WHERE id = $1;

-- name: ListBooksByAuthor :many
SELECT * FROM books
WHERE author_id = $1
ORDER BY created_at DESC;

-- name: UpdateBook :one
UPDATE books
SET title = $2, price = $3, published = $4
WHERE id = $1
RETURNING *;

-- name: DeleteBook :exec
DELETE FROM books WHERE id = $1;

-- name: GetAuthorWithBooks :many
SELECT 
    a.id AS author_id,
    a.name AS author_name,
    a.bio AS author_bio,
    b.id AS book_id,
    b.title AS book_title,
    b.price AS book_price
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
WHERE a.id = $1
ORDER BY b.created_at;

🗄️ Файл: sqlc.yaml

version: "2"
sql:
  - engine: "postgresql"
    queries: "internal/database/queries"
    schema: "internal/database/schema.sql"
    gen:
      go:
        package: "database"
        out: "internal/database"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_pointers_for_null_types: true
        emit_interface: true  # Генерирует Querier интерфейс!
        overrides:
          - db_type: "uuid"
            go_type: "github.com/google/uuid.UUID"
          - db_type: "timestamptz"
            go_type: "time.Time"

💻 Файл: internal/di/wire.go

// go:build wireinject
// +build wireinject

package di

import (
    "context"
    "github.com/google/wire"
    "github.com/jackc/pgx/v5/pgxpool"
    "go-codegen/internal/database"
)

// ProviderSet — набор всех провайдеров
var ProviderSet = wire.NewSet(
    ProvideDBPool,
    ProvideQuerier,
    ProvideServerConfig,
    // Добавляйте новые провайдеры сюда
)

// ProvideDBPool — создаёт пул PostgreSQL
func ProvideDBPool(ctx context.Context, cfg ServerConfig) (*pgxpool.Pool, error) {
    poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
    if err != nil {
        return nil, err
    }
    poolCfg.MaxConns = 20

    pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
    if err != nil {
        return nil, err
    }

    if err := pool.Ping(ctx); err != nil {
        return nil, err
    }

    return pool, nil
}

// ProvideQuerier — создаёт Querier (сгенерирован sqlc)
func ProvideQuerier(pool *pgxpool.Pool) database.Querier {
    return database.New(pool)
}

// ServerConfig — конфигурация сервера
type ServerConfig struct {
    Port        string
    DatabaseURL string
}

func ProvideServerConfig() ServerConfig {
    return ServerConfig{
        Port:        "8080",
        DatabaseURL: "postgres://postgres:secret@localhost:5432/appdb?sslmode=disable",
    }
}

// InitializeApp — точка входа для Wire (будет сгенерировано)
func InitializeApp(ctx context.Context) (*App, error) {
    wire.Build(
        ProviderSet,
        wire.Struct(new(App), "*"),
    )
    return nil, nil
}

// App — главная структура приложения
type App struct {
    Config ServerConfig
    Pool   *pgxpool.Pool
    Querier database.Querier
}

📄 Файл: internal/api/openapi.yaml

openapi: "3.0.3"
info:
  title: Bookstore API
  version: "1.0.0"
  description: API для управления книгами и авторами
servers:
  - url: http://localhost:8080/api/v1
paths:
  /authors:
    post:
      operationId: createAuthor
      summary: Создать автора
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAuthorRequest'
      responses:
        '201':
          description: Автор создан
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Author'
    get:
      operationId: listAuthors
      summary: Список авторов
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Список авторов
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Author'

  /authors/{id}:
    get:
      operationId: getAuthor
      summary: Получить автора
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Автор
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Author'
        '404':
          description: Не найден

  /books:
    post:
      operationId: createBook
      summary: Создать книгу
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookRequest'
      responses:
        '201':
          description: Книга создана
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'

components:
  schemas:
    Author:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        bio:
          type: string
        created_at:
          type: string
          format: date-time
    CreateAuthorRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
        bio:
          type: string
    Book:
      type: object
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        author_id:
          type: string
          format: uuid
        price:
          type: integer
          description: Цена в центах
        published:
          type: boolean
        created_at:
          type: string
          format: date-time
    CreateBookRequest:
      type: object
      required: [title, author_id, price]
      properties:
        title:
          type: string
        author_id:
          type: string
          format: uuid
        price:
          type: integer
        published:
          type: boolean
          default: false

💻 Файл: oapi-codegen.yaml

package: api
output: internal/api/server.gen.go
generate:
  std-http-server: true
  models: true
  strict-server: true

💻 Файл: cmd/server/main.go

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "go-codegen/internal/api"
    "go-codegen/internal/database"
    "go-codegen/internal/di"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🚀 Запуск приложения (sqlc + Wire + oapi-codegen)...")

    ctx := context.Background()

    // 1. Инициализация через Wire (DI)
    // В реальном коде — использовать сгенерированный wire_gen.go
    // app, err := di.InitializeApp(ctx)
    
    // Для демонстрации — ручная инициализация
    cfg := di.ProvideServerConfig()
    pool, err := di.ProvideDBPool(ctx, cfg)
    if err != nil {
        log.Fatalf("БД: %v", err)
    }
    defer pool.Close()

    querier := database.New(pool)

    // 2. Создаём API-сервер (сгенерирован oapi-codegen)
    apiServer := api.NewServer(querier)

    // 3. Настраиваем роутер
    handler := api.HandlerWithOptions(
        api.NewStrictHandler(apiServer, nil),
        api.StdHTTPServerOptions{
            BaseURL:    "/api/v1",
            Middlewares: []api.MiddlewareFunc{loggingMiddleware},
        },
    )

    mux := http.NewServeMux()
    mux.Handle("/", handler)

    server := &http.Server{Addr: ":" + cfg.Port, Handler: mux}

    go func() {
        log.Printf("✅ Сервер на http://localhost:%s/api/v1", cfg.Port)
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Сервер: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("🛑 Выключение...")
    server.Shutdown(context.Background())
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

// В реальном проекте вам понадобятся:
// - internal/database/ (сгенерировано sqlc)
// - internal/api/ (сгенерировано oapi-codegen)
// - internal/di/wire_gen.go (сгенерировано Wire)
// - internal/api/server.go (ваша реализация интерфейса)

💻 Файл: internal/api/server.go (реализация)

package api

import (
    "context"
    "net/http"

    "go-codegen/internal/database"
    "github.com/google/uuid"
)

// Server — реализация сгенерированного интерфейса StrictServerInterface
type Server struct {
    db database.Querier
}

func NewServer(db database.Querier) *Server {
    return &Server{db: db}
}

// CreateAuthor — POST /authors
func (s *Server) CreateAuthor(ctx context.Context, req CreateAuthorRequest) (CreateAuthorResponse, error) {
    params := database.CreateAuthorParams{
        Name: req.Name,
        Bio:  ptrToString(req.Bio),
    }

    author, err := s.db.CreateAuthor(ctx, params)
    if err != nil {
        return nil, err
    }

    return CreateAuthor201JSONResponse{
        Id:        author.ID,
        Name:      author.Name,
        Bio:       author.Bio,
        CreatedAt: author.CreatedAt,
    }, nil
}

// GetAuthor — GET /authors/{id}
func (s *Server) GetAuthor(ctx context.Context, req GetAuthorRequest) (GetAuthorResponse, error) {
    author, err := s.db.GetAuthor(ctx, req.Id)
    if err != nil {
        return GetAuthor404JSONResponse{Message: "Author not found"}, nil
    }

    return GetAuthor200JSONResponse{
        Id:        author.ID,
        Name:      author.Name,
        Bio:       author.Bio,
        CreatedAt: author.CreatedAt,
    }, nil
}

// ListAuthors — GET /authors
func (s *Server) ListAuthors(ctx context.Context, req ListAuthorsRequest) (ListAuthorsResponse, error) {
    limit := int32(10)
    offset := int32(0)
    if req.Limit != nil {
        limit = *req.Limit
    }
    if req.Offset != nil {
        offset = *req.Offset
    }

    authors, err := s.db.ListAuthors(ctx, database.ListAuthorsParams{
        Limit:  limit,
        Offset: offset,
    })
    if err != nil {
        return nil, err
    }

    var resp ListAuthors200JSONResponse
    for _, a := range authors {
        resp = append(resp, Author{
            Id:        a.ID,
            Name:      a.Name,
            Bio:       a.Bio,
            CreatedAt: a.CreatedAt,
        })
    }
    return resp, nil
}

// CreateBook — POST /books
func (s *Server) CreateBook(ctx context.Context, req CreateBookRequest) (CreateBookResponse, error) {
    params := database.CreateBookParams{
        Title:     req.Title,
        AuthorID:  req.AuthorId,
        Price:     req.Price,
        Published: req.Published,
    }

    book, err := s.db.CreateBook(ctx, params)
    if err != nil {
        return nil, err
    }

    return CreateBook201JSONResponse{
        Id:        book.ID,
        Title:     book.Title,
        AuthorId:  book.AuthorID,
        Price:     book.Price,
        Published: book.Published,
        CreatedAt: book.CreatedAt,
    }, nil
}

func ptrToString(s *string) string {
    if s == nil {
        return ""
    }
    return *s
}

💻 Файл: Makefile

# ╔══════════════════════════════════════════════════════════╗
# ║  КОМАНДЫ ГЕНЕРАЦИИ КОДА                                 ║
# ╚══════════════════════════════════════════════════════════╝

.PHONY: generate generate-sqlc generate-wire generate-oapi

# Вся генерация одной командой
generate: generate-sqlc generate-wire generate-oapi
	@echo "✅ Весь код сгенерирован"

# sqlc: генерация типизированного кода из SQL
generate-sqlc:
	@echo "📦 Генерация sqlc..."
	sqlc generate

# Wire: генерация DI-кода
generate-wire:
	@echo "🔌 Генерация Wire..."
	cd internal/di && wire

# oapi-codegen: генерация HTTP-сервера из OpenAPI
generate-oapi:
	@echo "📄 Генерация oapi-codegen..."
	oapi-codegen -config oapi-codegen.yaml internal/api/openapi.yaml

# Альтернатива: go generate (вызывает всё)
# Добавьте в generate.go:
# //go:generate sqlc generate
# //go:generate wire ./internal/di
# //go:generate oapi-codegen -config oapi-codegen.yaml internal/api/openapi.yaml

🚀 Запуск

# Установка инструментов (один раз)
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/google/wire/cmd/wire@latest
go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest

# Генерация всего кода
make generate

# Или по отдельности:
sqlc generate                    # Генерирует internal/database/*.go
cd internal/di && wire           # Генерирует internal/di/wire_gen.go
oapi-codegen -config oapi-codegen.yaml internal/api/openapi.yaml

# Проверяем, что всё скомпилировалось
go build ./...

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

📊 Инструменты генерации кода

ИнструментИз чего генерируетЧто генерируетАналог в Node.js
sqlcSQL-запросыТипизированные Go-функцииPrisma, kysely
WireПровайдеры (Go-функции)Код инициализации DIInversifyJS, tsyringe
oapi-codegenOpenAPI/Swagger YAMLHTTP-сервер + моделиtsoa, swagger-codegen
gqlgenGraphQL-схемаGraphQL-серверgraphql-codegen
protoc-gen-go.proto файлыgRPC-сервер/клиентprotobuf-ts
⚠️ Типичные ошибки:
💡 Практический совет:

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

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

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

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

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