Files
test/chapters/chapter-6-revised.md

16 KiB
Raw Permalink Blame History

第六章:实战项目 —— 构建一个 Web API 服务(完整实战版)

👋 回顾一下:

前四章,我们学会了基础语法、数据结构、函数和接口。第五章,我们攻克了并发编程的难关。现在,你已经掌握了 Go 语言的所有核心技能

🤔 但是,光会理论可不够,我们要“学以致用”!

想象一下,你开了一家**“任务管理餐厅”**。

  • 顾客(客户端)点菜(发送 HTTP 请求)。
  • 服务员Handler接收订单传给厨师Service
  • 厨师Service根据仓库Repository的食材做出菜品业务逻辑
  • 服务员Handler把菜品端给顾客返回 HTTP 响应)。

这就是一个典型的 Web API 服务

🎯 这一章,我们要亲手搭建这家“餐厅”:

  1. 项目架构分层设计Handler -> Service -> Repository
  2. 核心功能创建、查询、更新、删除任务CRUD
  3. 并发处理:用 Goroutine 处理异步通知,用 Mutex 保护共享状态。
  4. 优雅关闭:收到信号后,优雅地停止服务。
  5. Docker 部署:把餐厅“打包”成集装箱,随时随地运行。

别担心,我会一步步带你完成,让你体验“从零到一”的成就感!


6.1 项目架构设计 —— 餐厅的“布局图”

💡 想象一下: 一家好的餐厅,布局一定要清晰:厨房、仓库、大厅各司其职。 我们的代码也要这样,分层管理,互不干扰。

📂 目录结构

task-api/
├── cmd/
│   └── server/
│       └── main.go          # 餐厅大门(入口)
├── internal/
│   ├── handler/             # 服务员(处理 HTTP 请求)
│   │   └── task_handler.go
│   ├── service/             # 厨师(业务逻辑)
│   │   └── task_service.go
│   ├── repository/          # 仓库(数据访问)
│   │   └── task_repo.go
│   ├── middleware/          # 保安、清洁工(中间件)
│   │   ├── logger.go
│   │   └── recovery.go
│   └── model/               # 菜单(数据模型)
│       └── task.go
├── go.mod
└── main.go

💡 老师的小揭秘:

  • cmd/:程序入口,负责初始化(加载配置、连接数据库、启动服务)。
  • internal/:私有代码,外部无法导入,保证封装性。
    • handler:处理 HTTP 请求/响应,参数校验,调用 Service。
    • service:核心业务逻辑,调用 Repository处理事务。
    • repository数据库操作SQL 查询,映射到 Model。
    • middleware:横切关注点(日志、认证、恢复)。
  • model:数据模型,定义数据结构。

6.2 数据模型 (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:自定义类型,避免魔法字符串。
  • JSON 标签:明确字段映射,前端看到的字段名。
  • 时间字段time.Time 自动序列化为 ISO 8601 格式。

6.3 仓库层 (Repository) —— 食材的“存取”

💡 想象一下: 厨师要炒菜,得先去仓库拿食材。 Repository 就是数据仓库,负责从数据库(或内存)存取数据。

📝 内存存储(简化版,方便运行)

// internal/repository/task_repo.go
package repository

import (
    "context"
    "fmt"
    "sync"
    "time"
    "task-api/internal/model"
)

type TaskRepo struct {
    mu    sync.Mutex
    tasks map[int64]*model.Task
    nextID int64
}

func NewTaskRepo() *TaskRepo {
    return &TaskRepo{
        tasks:  make(map[int64]*model.Task),
        nextID: 1,
    }
}

func (r *TaskRepo) Create(ctx context.Context, task *model.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    task.ID = r.nextID
    r.nextID++
    task.CreatedAt = time.Now()
    task.UpdatedAt = time.Now()
    r.tasks[task.ID] = task
    return nil
}

func (r *TaskRepo) GetByID(ctx context.Context, id int64) (*model.Task, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    task, ok := r.tasks[id]
    if !ok {
        return nil, fmt.Errorf("task not found")
    }
    return task, nil
}

func (r *TaskRepo) List(ctx context.Context) []*model.Task {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    tasks := make([]*model.Task, 0, len(r.tasks))
    for _, t := range r.tasks {
        tasks = append(tasks, t)
    }
    return tasks
}

func (r *TaskRepo) Update(ctx context.Context, task *model.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    if _, ok := r.tasks[task.ID]; !ok {
        return fmt.Errorf("task not found")
    }
    task.UpdatedAt = time.Now()
    r.tasks[task.ID] = task
    return nil
}

func (r *TaskRepo) Delete(ctx context.Context, id int64) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    if _, ok := r.tasks[id]; !ok {
        return fmt.Errorf("task not found")
    }
    delete(r.tasks, id)
    return nil
}

💡 老师的小揭秘:

  • Mutex:保护共享的 tasks 映射,避免并发读写冲突。
  • Context:虽然内存存储不需要超时,但保持接口一致性,方便以后切换数据库。
  • 错误处理:返回明确的错误信息,便于上层处理。

6.4 服务层 (Service) —— 厨师的“烹饪”

💡 想象一下: 厨师接到订单,检查食材是否足够,然后开始烹饪。 Service 层负责业务逻辑:参数校验、数据转换、调用 Repository。

📝 业务逻辑

// internal/service/task_service.go
package service

import (
    "context"
    "errors"
    "time"
    "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) {
    task, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, ErrNotFound
    }
    return task, nil
}

func (s *TaskService) ListTasks(ctx context.Context) []*model.Task {
    return s.repo.List(ctx)
}

func (s *TaskService) UpdateTask(ctx context.Context, task *model.Task) error {
    if task.Title == "" {
        return errors.New("title is required")
    }
    return s.repo.Update(ctx, task)
}

func (s *TaskService) DeleteTask(ctx context.Context, id int64) error {
    return s.repo.Delete(ctx, id)
}

// 异步通知示例
func (s *TaskService) NotifyTask(ctx context.Context, taskID int64) {
    go func() {
        // 模拟发送通知
        // 实际项目中应传递 context 并处理超时
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        
        select {
        case <-ctx.Done():
            // 超时
        default:
            // 成功
        }
    }()
}

💡 老师的小揭秘:

  • 参数校验:在 Service 层进行,确保数据合法。
  • 错误定义:定义自定义错误,便于 Handler 层统一处理。
  • 异步任务:用 go func() 启动 Goroutine注意处理 Context 和超时。

6.5 处理层 (Handler) —— 服务员的“接待”

💡 想象一下: 服务员接待顾客,记录订单,把菜端回来。 Handler 层负责HTTP 请求/响应,参数解析,调用 Service。

📝 HTTP 处理

// 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) ListTasks(w http.ResponseWriter, r *http.Request) {
    tasks := h.service.ListTasks(r.Context())
    h.respondJSON(w, http.StatusOK, "success", tasks)
}

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() 自动包含超时和取消信号。

6.6 中间件 (Middleware) —— 餐厅的“保安与清洁工”

💡 想象一下: 保安检查顾客身份认证清洁工记录顾客进出时间日志万一顾客闹事panic有人处理恢复。 中间件就是这些横切关注点

📝 中间件实现

// 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)
    })
}

💡 老师的小揭秘:

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

6.7 主函数与优雅关闭 —— 餐厅的“打烊”

💡 想象一下: 打烊时,不能直接把顾客赶出去,要等他们吃完,再关灯关门。 优雅关闭就是等待当前请求完成,再停止服务。

📝 主函数

// cmd/server/main.go
package main

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

func main() {
    // 初始化依赖
    taskRepo := repository.NewTaskRepo()
    taskSvc := service.NewTaskService(taskRepo)
    taskHandler := handler.NewTaskHandler(taskSvc)
    
    // 路由
    r := mux.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recovery)
    
    r.HandleFunc("/tasks", taskHandler.CreateTask).Methods("POST")
    r.HandleFunc("/tasks", taskHandler.ListTasks).Methods("GET")
    r.HandleFunc("/tasks", taskHandler.GetTask).Methods("GET").Queries("id", "{id}")
    
    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }
    
    // 启动服务器
    go func() {
        log.Println("Server starting on port 8080")
        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")
}

🔄 优雅关闭流程图

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. 退出:主进程退出。
  6. 超时控制context.WithTimeout 防止无限等待。

6.8 运行与测试 —— 开业大吉!

🎉 现在,让我们启动餐厅!

# 1. 下载依赖
go mod download

# 2. 运行服务
go run cmd/server/main.go

# 3. 测试 API
# 创建任务
curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "学习 Go", "description": "完成第六章", "priority": 5}'

# 查询任务
curl http://localhost:8080/tasks

# 查询单个任务
curl "http://localhost:8080/tasks?id=1"

💡 老师的小提醒:

  • 如果看到 Server starting on port 8080,说明启动成功!
  • curl 或 Postman 测试 API。
  • Ctrl+C 触发优雅关闭,观察日志。

6.9 本章小结

🎯 我们学到了什么?

  1. 分层架构Handler -> Service -> Repository职责清晰。
  2. 数据模型自定义类型、JSON 标签。
  3. 并发安全:用 Mutex 保护共享数据。
  4. 中间件:日志、恢复,横切关注点。
  5. 优雅关闭:信号处理、超时控制。
  6. Docker 部署:容器化,环境一致性。

🚀 恭喜你! 你已经完成了从零基础到实战的全过程!现在,你不仅掌握了 Go 语言的核心技能,还具备了独立开发 Web 服务的能力!

下一步

  • 尝试添加数据库支持PostgreSQL/MySQL
  • 添加用户认证JWT
  • 添加缓存Redis
  • 添加监控Prometheus

Go 的世界很大,你的旅程才刚刚开始!加油! 🚀