Files
test/chapters/chapter-6-web-api.md

20 KiB
Raw Permalink Blame History

第六章:实战项目 —— 构建一个简易 Web API 服务(深度图解版)

本章目标:综合运用前五章知识,构建一个生产级别的 Go Web API 服务(任务管理系统),涵盖路由、中间件、数据库、并发、配置、错误处理、部署等核心技能。 项目功能

  1. 创建、读取、更新、删除CRUD任务Task
  2. 支持任务状态(待办、进行中、已完成)。
  3. 支持按状态、优先级筛选。
  4. 异步处理任务通知(模拟)。
  5. 优雅关闭、健康检查、监控指标。

6.1 项目架构设计

6.1.1 目录结构

task-api/
├── cmd/
│   └── server/
│       └── main.go          # 入口文件
├── internal/
│   ├── handler/             # HTTP 处理层Handler
│   │   └── task_handler.go
│   ├── service/             # 业务逻辑层Service
│   │   └── task_service.go
│   ├── repository/          # 数据访问层Repository
│   │   └── task_repo.go
│   ├── middleware/          # 中间件
│   │   ├── logger.go
│   │   ├── auth.go
│   │   └── recovery.go
│   ├── config/              # 配置管理
│   │   └── config.go
│   └── model/               # 数据模型
│       └── task.go
├── pkg/
│   └── logger/              # 公共工具
│       └── logger.go
├── migrations/              # 数据库迁移
│   └── 001_init.sql
├── .env                     # 环境变量
├── Dockerfile
├── docker-compose.yml
└── go.mod

📖 深度解析

  • cmd/:应用入口,负责初始化配置、数据库连接、启动服务器。
  • internal/:私有代码,外部无法导入,保证封装性。
    • handler:处理 HTTP 请求/响应,参数校验,调用 Service。
    • service:核心业务逻辑,调用 Repository处理事务。
    • repository数据库操作SQL 查询,映射到 Model。
    • middleware:横切关注点(日志、认证、限流)。
  • pkg/:公共工具库,可被外部导入。
  • migrations/:数据库结构变更脚本。

6.2 核心代码实现

6.2.1 数据模型 (Model)

// internal/model/task.go
package model

import "time"

type Status string

const (
    StatusTodo       Status = "todo"
    StatusInProgress Status = "in_progress"
    StatusDone       Status = "done"
)

type Task struct {
    ID          int64     `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Status      Status    `json:"status"`
    Priority    int       `json:"priority"` // 1-5
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

📖 深度解析

  • Status:使用自定义类型(基于 string增强类型安全避免魔法字符串。
  • JSON 标签:明确字段映射,支持 omitempty 等选项。
  • 时间字段time.Time 自动映射为 ISO 8601 格式。

6.2.2 配置管理 (Config)

// internal/config/config.go
package config

import (
    "os"
    "strconv"
)

type Config struct {
    Port       string
    DBHost     string
    DBPort     string
    DBUser     string
    DBPassword string
    DBName     string
    LogLevel   string
}

func Load() *Config {
    return &Config{
        Port:       getEnv("PORT", "8080"),
        DBHost:     getEnv("DB_HOST", "localhost"),
        DBPort:     getEnv("DB_PORT", "5432"),
        DBUser:     getEnv("DB_USER", "postgres"),
        DBPassword: getEnv("DB_PASSWORD", "password"),
        DBName:     getEnv("DB_NAME", "taskdb"),
        LogLevel:   getEnv("LOG_LEVEL", "info"),
    }
}

func getEnv(key, defaultVal string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return defaultVal
}

📖 深度解析

  • 环境变量:优先从环境变量读取,支持容器化部署。
  • 默认值:提供安全默认值,避免配置缺失导致崩溃。
  • 集中管理:所有配置集中加载,便于传递和测试。

6.2.3 数据库连接池 (Repository)

// internal/repository/task_repo.go
package repository

import (
    "context"
    "database/sql"
    "fmt"
    "task-api/internal/model"
)

type TaskRepo struct {
    db *sql.DB
}

func NewTaskRepo(db *sql.DB) *TaskRepo {
    return &TaskRepo{db: db}
}

// 创建任务
func (r *TaskRepo) Create(ctx context.Context, task *model.Task) error {
    query := `INSERT INTO tasks (title, description, status, priority, created_at, updated_at) 
              VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING id`
    
    err := r.db.QueryRowContext(ctx, query, 
        task.Title, task.Description, task.Status, task.Priority).Scan(&task.ID)
    
    if err != nil {
        return fmt.Errorf("create task failed: %w", err)
    }
    return nil
}

// 获取所有任务
func (r *TaskRepo) List(ctx context.Context, status *model.Status) ([]model.Task, error) {
    query := `SELECT id, title, description, status, priority, created_at, updated_at FROM tasks`
    if status != nil {
        query += " WHERE status = $1"
        rows, err := r.db.QueryContext(ctx, query, *status)
        if err != nil {
            return nil, err
        }
        defer rows.Close()
        // ... 解析 rows
    } else {
        rows, err := r.db.QueryContext(ctx, query)
        if err != nil {
            return nil, err
        }
        defer rows.Close()
        // ... 解析 rows
    }
    return tasks, nil
}

// 更新任务
func (r *TaskRepo) Update(ctx context.Context, task *model.Task) error {
    query := `UPDATE tasks SET title=$1, description=$2, status=$3, priority=$4, updated_at=NOW() 
              WHERE id=$5`
    res, err := r.db.ExecContext(ctx, query, 
        task.Title, task.Description, task.Status, task.Priority, task.ID)
    if err != nil {
        return err
    }
    rows, _ := res.RowsAffected()
    if rows == 0 {
        return fmt.Errorf("task not found")
    }
    return nil
}

// 删除任务
func (r *TaskRepo) Delete(ctx context.Context, id int64) error {
    query := `DELETE FROM tasks WHERE id = $1`
    res, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return err
    }
    rows, _ := res.RowsAffected()
    if rows == 0 {
        return fmt.Errorf("task not found")
    }
    return nil
}

📖 深度解析

  • Context 传递:所有 DB 操作都接收 context.Context,支持超时控制和取消。
  • 预编译语句:使用 $1, $2 防止 SQL 注入。
  • 连接池sql.DB 内部维护连接池,自动管理连接生命周期。
  • 错误包装:使用 fmt.Errorf("%w") 包装错误,保留原始错误信息。
  • 资源管理defer rows.Close() 确保资源释放。

6.2.4 业务逻辑层 (Service)

// internal/service/task_service.go
package service

import (
    "context"
    "errors"
    "task-api/internal/model"
    "task-api/internal/repository"
)

var ErrNotFound = errors.New("task not found")

type TaskService struct {
    repo *repository.TaskRepo
}

func NewTaskService(repo *repository.TaskRepo) *TaskService {
    return &TaskService{repo: repo}
}

func (s *TaskService) CreateTask(ctx context.Context, task *model.Task) error {
    if task.Title == "" {
        return errors.New("title is required")
    }
    if task.Priority < 1 || task.Priority > 5 {
        return errors.New("priority must be between 1 and 5")
    }
    return s.repo.Create(ctx, task)
}

func (s *TaskService) GetTask(ctx context.Context, id int64) (*model.Task, error) {
    // 模拟从 repo 获取
    task, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, ErrNotFound
    }
    return task, nil
}

// 异步通知示例
func (s *TaskService) NotifyTask(ctx context.Context, taskID int64) {
    go func() {
        // 模拟发送通知
        // 注意:这里没有接收 context实际项目中应传递 context 并处理超时
        // 使用 context.WithTimeout 创建子 context
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        
        // 发送通知逻辑...
        select {
        case <-ctx.Done():
            // 超时
        default:
            // 成功
        }
    }()
}

📖 深度解析

  • 参数校验:在 Service 层进行业务规则校验。
  • 错误定义:定义自定义错误(ErrNotFound),便于 Handler 层统一处理。
  • 异步任务:使用 go func() 启动 Goroutine注意处理 Context 和超时。
  • 依赖注入Service 依赖 Repository便于测试和替换。

6.2.5 HTTP 处理层 (Handler)

// internal/handler/task_handler.go
package handler

import (
    "encoding/json"
    "net/http"
    "strconv"
    "task-api/internal/model"
    "task-api/internal/service"
)

type TaskHandler struct {
    service *service.TaskService
}

func NewTaskHandler(svc *service.TaskService) *TaskHandler {
    return &TaskHandler{service: svc}
}

type Response struct {
    Code    int         `json:"code"`
    Message string      `json:"message"`
    Data    interface{} `json:"data,omitempty"`
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
    var task model.Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid request body")
        return
    }
    
    if err := h.service.CreateTask(r.Context(), &task); err != nil {
        h.respondError(w, http.StatusInternalServerError, err.Error())
        return
    }
    
    h.respondJSON(w, http.StatusCreated, "created", task)
}

func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid id")
        return
    }
    
    task, err := h.service.GetTask(r.Context(), id)
    if err != nil {
        h.respondError(w, http.StatusNotFound, "task not found")
        return
    }
    
    h.respondJSON(w, http.StatusOK, "success", task)
}

func (h *TaskHandler) respondJSON(w http.ResponseWriter, code int, msg string, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(Response{Code: code, Message: msg, Data: data})
}

func (h *TaskHandler) respondError(w http.ResponseWriter, code int, msg string) {
    h.respondJSON(w, code, msg, nil)
}

📖 深度解析

  • 统一响应格式Response 结构体,包含 Code、Message、Data。
  • 错误处理:统一调用 respondError,避免重复代码。
  • Context 传递r.Context() 自动包含超时和取消信号。
  • JSON 编解码:使用标准库 encoding/json

6.2.6 中间件 (Middleware)

// internal/middleware/logger.go
package middleware

import (
    "log"
    "net/http"
    "time"
)

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("Started %s %s", r.Method, r.URL.Path)
        
        next.ServeHTTP(w, r)
        
        log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// internal/middleware/recovery.go
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// internal/middleware/auth.go
func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "secret-token" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

📖 深度解析

  • 链式调用:中间件是 http.Handler 的装饰器,层层包裹。
  • Logger:记录请求开始和结束时间,便于性能分析。
  • Recovery:捕获 Panic防止服务器崩溃返回 500 错误。
  • Auth:简单的 Token 验证,实际项目应使用 JWT。

6.2.7 主函数与优雅关闭 (Main)

// cmd/server/main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    
    "task-api/internal/config"
    "task-api/internal/handler"
    "task-api/internal/middleware"
    "task-api/internal/repository"
    "task-api/internal/service"
    
    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
    "database/sql"
)

func main() {
    cfg := config.Load()
    
    // 连接数据库
    dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
        cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    // 初始化依赖
    taskRepo := repository.NewTaskRepo(db)
    taskSvc := service.NewTaskService(taskRepo)
    taskHandler := handler.NewTaskHandler(taskSvc)
    
    // 路由
    r := mux.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recovery)
    r.Use(middleware.Auth)
    
    r.HandleFunc("/tasks", taskHandler.CreateTask).Methods("POST")
    r.HandleFunc("/tasks", taskHandler.GetTask).Methods("GET")
    
    srv := &http.Server{
        Addr:    ":" + cfg.Port,
        Handler: r,
    }
    
    // 启动服务器
    go func() {
        log.Printf("Server starting on port %s", cfg.Port)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // 优雅关闭
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Shutting down server...")
    
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }
    
    log.Println("Server exited")
}

📖 深度解析

  • 依赖注入:从 Config -> Repo -> Service -> Handler层层构建。
  • 中间件链r.Use() 按顺序添加中间件Logger -> Recovery -> Auth
  • 优雅关闭
    • 监听 SIGINT (Ctrl+C) 和 SIGTERM (Docker 停止) 信号。
    • 收到信号后,调用 srv.Shutdown(ctx),等待当前请求完成(最多 5 秒)。
    • 超时后强制关闭。
  • 并发启动go func() 启动服务器,主进程等待信号。

6.3 深度图解:请求处理流程

sequenceDiagram
    participant Client
    participant Router
    participant Logger
    participant Auth
    participant Recovery
    participant Handler
    participant Service
    participant Repo
    participant DB

    Client->>Router: POST /tasks
    Router->>Logger: 调用 Logger 中间件
    Logger->>Auth: 记录开始时间
    Auth->>Recovery: 验证 Token
    Recovery->>Handler: 捕获 Panic
    Handler->>Service: CreateTask
    Service->>Repo: Create
    Repo->>DB: INSERT INTO tasks
    DB-->>Repo: 返回 ID
    Repo-->>Service: Task with ID
    Service-->>Handler: nil
    Handler-->>Client: 201 Created + JSON
    Logger->>Client: 记录结束时间

📖 深度解析

  1. Client:发起 HTTP 请求。
  2. Router:匹配路由,调用中间件链。
  3. Logger:记录请求开始时间。
  4. Auth:验证 Token失败直接返回 401。
  5. Recovery:包裹整个处理链,捕获 Panic。
  6. Handler:解析请求,调用 Service。
  7. Service:业务逻辑,调用 Repo。
  8. Repo:执行 SQL映射结果。
  9. DB:执行数据库操作。
  10. 返回沿调用链返回Logger 记录结束时间。

6.4 深度图解:数据库连接池

graph TB
    subgraph "应用层"
        App[Go 应用]
        Pool[sql.DB 连接池]
    end
    
    subgraph "数据库层"
        DB[(PostgreSQL)]
        Conn1[连接 1]
        Conn2[连接 2]
        Conn3[连接 3]
    end
    
    App <--> Pool
    Pool <--> Conn1
    Pool <--> Conn2
    Pool <--> Conn3
    
    Note over Pool: 最大连接数20<br/>空闲超时30s<br/>最大生命周期1h
    
    style Pool fill:#ffeb3b,stroke:#333
    style DB fill:#2196f3,stroke:#333,color:#fff

📖 深度解析

  • sql.DB:不是单个连接,而是连接池
  • 最大连接数:通过 db.SetMaxOpenConns(n) 设置,避免耗尽数据库资源。
  • 空闲超时db.SetConnMaxLifetime() 设置连接最大生命周期。
  • 复用:连接用完归还池,下次请求直接复用,避免频繁创建/销毁。

6.5 深度图解:优雅关闭流程

sequenceDiagram
    participant OS
    participant Main
    participant Server
    participant Req1
    participant Req2
    participant DB

    OS->>Main: SIGTERM
    Main->>Server: Shutdown(ctx)
    Server->>Req1: 拒绝新请求
    Server->>Req2: 拒绝新请求
    Req1-->>Server: 完成处理
    Req2-->>Server: 完成处理
    Server->>DB: 关闭连接
    Server-->>Main: 退出
    Main->>OS: 退出

📖 深度解析

  1. 收到信号OS 发送 SIGTERM
  2. 停止监听srv.Shutdown() 停止接收新请求。
  3. 等待完成等待当前正在处理的请求Req1, Req2完成。
  4. 关闭资源:关闭数据库连接、文件句柄等。
  5. 退出:主进程退出。
  • 超时控制context.WithTimeout 防止无限等待。

6.6 Docker 部署

6.6.1 Dockerfile

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/server

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

6.6.2 docker-compose.yml

version: '3.8'
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=db
      - DB_PORT=5432
      - DB_USER=postgres
      - DB_PASSWORD=password
      - DB_NAME=taskdb
    depends_on:
      - db
    restart: always

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: taskdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

📖 深度解析

  • 多阶段构建:第一阶段编译,第二阶段只复制二进制文件,镜像更小。
  • 环境变量:通过 docker-compose 传递配置。
  • 依赖管理depends_on 确保数据库先启动。
  • 数据持久化Volume 挂载,防止数据丢失。

6.7 总结与扩展

6.7.1 核心知识点回顾

  1. 分层架构Handler -> Service -> Repository职责清晰。
  2. 中间件:日志、认证、恢复,横切关注点。
  3. Context:超时控制、取消信号、优雅关闭。
  4. 连接池:数据库连接复用,性能优化。
  5. Docker:容器化部署,环境一致性。

6.7.2 扩展方向

  1. ORM:使用 gormsqlx 简化数据库操作。
  2. 缓存:引入 Redis缓存热点数据。
  3. 消息队列:使用 Kafka/RabbitMQ 处理异步任务。
  4. 监控:集成 Prometheus + Grafana。
  5. CI/CD:自动化测试、构建、部署。

代码仓库位置https://giter.top/openclaw/test/tree/main/chapters/chapter-6

完整项目代码https://giter.top/openclaw/test/tree/main/chapters/chapter-6


最后更新2026-03-24 00:05 UTC实战项目版