diff --git a/chapters/chapter-6-web-api.md b/chapters/chapter-6-web-api.md new file mode 100644 index 0000000..f5fa374 --- /dev/null +++ b/chapters/chapter-6-web-api.md @@ -0,0 +1,738 @@ +# 第六章:实战项目 —— 构建一个简易 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) + +```go +// 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) + +```go +// 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) + +```go +// 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) + +```go +// 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) + +```go +// 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) + +```go +// 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) + +```go +// 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 深度图解:请求处理流程 + +```mermaid +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 深度图解:数据库连接池 + +```mermaid +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
空闲超时:30s
最大生命周期:1h + + style Pool fill:#ffeb3b,stroke:#333 + style DB fill:#2196f3,stroke:#333,color:#fff +``` + +**📖 深度解析**: +- **sql.DB**:不是单个连接,而是**连接池**。 +- **最大连接数**:通过 `db.SetMaxOpenConns(n)` 设置,避免耗尽数据库资源。 +- **空闲超时**:`db.SetConnMaxLifetime()` 设置连接最大生命周期。 +- **复用**:连接用完归还池,下次请求直接复用,避免频繁创建/销毁。 + +--- + +## 6.5 深度图解:优雅关闭流程 + +```mermaid +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 + +```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 + +```yaml +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**:使用 `gorm` 或 `sqlx` 简化数据库操作。 +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(实战项目版)* diff --git a/chapters/chapter-6/go.mod b/chapters/chapter-6/go.mod new file mode 100644 index 0000000..335e3a8 --- /dev/null +++ b/chapters/chapter-6/go.mod @@ -0,0 +1,3 @@ +module task-api + +go 1.21 diff --git a/chapters/chapter-6/main.go b/chapters/chapter-6/main.go new file mode 100644 index 0000000..cf72422 --- /dev/null +++ b/chapters/chapter-6/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +// 模型 +type Task struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// 响应 +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// 内存存储(模拟数据库) +var tasks = make(map[int64]*Task) +var nextID int64 = 1 + +// Handler +func createTask(w http.ResponseWriter, r *http.Request) { + var task Task + if err := json.NewDecoder(r.Body).Decode(&task); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if task.Title == "" { + respondError(w, http.StatusBadRequest, "title is required") + return + } + + task.ID = nextID + nextID++ + task.Status = "todo" + task.CreatedAt = time.Now() + task.UpdatedAt = time.Now() + tasks[task.ID] = &task + + respondJSON(w, http.StatusCreated, "created", task) +} + +func getTasks(w http.ResponseWriter, r *http.Request) { + taskList := make([]*Task, 0, len(tasks)) + for _, t := range tasks { + taskList = append(taskList, t) + } + respondJSON(w, http.StatusOK, "success", taskList) +} + +func 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 respondError(w http.ResponseWriter, code int, msg string) { + respondJSON(w, code, msg, nil) +} + +// 中间件 +func loggerMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + log.Printf("Started %s %s", r.Method, r.URL.Path) + next(w, r) + log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, time.Since(start)) + } +} + +func recoveryMiddleware(next http.HandlerFunc) http.HandlerFunc { + return 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(w, r) + } +} + +func main() { + // 路由 + http.HandleFunc("/tasks", recoveryMiddleware(loggerMiddleware(createTask))) + http.HandleFunc("/tasks", recoveryMiddleware(loggerMiddleware(getTasks))) + + srv := &http.Server{ + Addr: ":8080", + Handler: nil, // 使用默认 ServeMux + } + + // 启动服务器 + 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") +}