1. 前言

作为一名 Go 初学者,我刷过不少算法题,能熟练写出 LRU 缓存、二叉树遍历、动态规划。但当我第一次尝试写一个“真正的”后端服务时,却完全不知道从哪里下手——数据库怎么连?配置放哪?日志怎么打?服务怎么优雅停机?

这篇文章记录了我在 Linux 环境下,将一个仅存在于内存中的算法题模型,一步步改造成可配置、可持久化、可观测、可平滑关闭的生产级 Web 服务的完整过程。如果你也有类似的困惑,希望这篇实战记录能帮你跨过“刷题”到“工程”的那道坎。

项目完整代码已托管在 GitHub。

2. 最终成果预览

我们实现了一个简单的待办事项(Todo)API,具备以下能力:

  • ✅ 基于 Gin 的 RESTful 接口(创建、查询所有)

  • ✅ 配置与代码分离(viper + YAML)

  • ✅ 数据持久化(SQLite + GORM,可轻松替换为 MySQL/PostgreSQL)

  • ✅ 分层架构(Handler → Service → Repository)

  • ✅ 结构化日志(标准库 slog,JSON 格式)

  • ✅ 优雅关闭(处理 SIGTERM/SIGINT,不丢失正在处理的请求)

启动服务后,你可以这样使用:

bash

# 创建一条 Todo
curl -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "学习 Go 生产部署"}'

# 查询所有 Todo
curl http://localhost:8080/todos

3. 从「算法题」到「生产代码」思维转变

算法题模式 生产代码模式
所有数据都在内存,进程结束就丢失 数据持久化到数据库,重启不丢
只有一个 main 函数,所有逻辑堆在一起 分层架构,各司其职,便于测试和维护
配置硬编码(lru := NewLRUCache(100) 配置文件 + 环境变量,修改无需重新编译
fmt.Println 调试 结构化日志(级别、时间戳、JSON 格式)
go run main.go 按 Ctrl+C 直接死 优雅关闭,处理完现有请求再退出

下面的每个步骤,我都会先说明“为什么要这么做”,然后给出可运行的代码,并解释关键点。

4. 一步一步构建生产级 Todo 服务

第 0 步:环境准备(Linux)

bash

mkdir ~/todo-api && cd ~/todo-api
go mod init todo-api

第 1 步:最简 HTTP 服务(排除干扰)

目标:确认 Go 环境正常,能启动一个 Web 服务。

go

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "pong"})
    })
    r.Run(":8080")
}

为什么先做这一步?
先不引入数据库、配置等复杂依赖,只要 curl localhost:8080/ping 能返回 pong,就说明网络和 Gin 框架没问题。

第 2 步:引入配置管理(viper)

将端口号等配置抽到 configs/config.yaml

yaml

server:
  port: 8080

修改 main.go,使用 viper 读取配置:

go

viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
if err := viper.ReadInConfig(); err != nil {
    log.Fatalf("读取配置文件失败: %v", err)
}
port := viper.GetString("server.port")
r.Run(":" + port)

生产意义:运维人员直接改 YAML 就能换端口,不用重新编译。

第 3 步:接入数据库(SQLite + GORM)

定义 Todo 模型(model/todo.go):

go

type Todo struct {
    ID        uint      `gorm:"primaryKey"`
    Title     string    `gorm:"not null"`
    Completed bool      `gorm:"default:false"`
    CreatedAt time.Time
    UpdatedAt time.Time
}

在 main.go 中连接 SQLite 并自动迁移:

go

db, err := gorm.Open(sqlite.Open("todo.db"), &gorm.Config{})
db.AutoMigrate(&model.Todo{})

然后直接在路由闭包里写数据库操作(作为临时验证)。这一步先让数据持久化跑通,哪怕代码写得丑一点也没关系。

第 4 步:分层架构(Handler → Service → Repository)

为什么要分层?
把所有逻辑塞在 main.go 里,很快就会变成“意大利面条式代码”。分层之后:

  • Repository:只负责数据库的 CRUD。

  • Service:只负责业务逻辑(如标题不能为空)。

  • Handler:只负责 HTTP 请求解析和响应。

4.1 Repository 层

go

// repository/todo_repo.go
type TodoRepository interface {
    Create(todo *model.Todo) error
    GetAll() ([]model.Todo, error)
}

type todoRepo struct { db *gorm.DB }

func NewTodoRepository(db *gorm.DB) TodoRepository {
    return &todoRepo{db: db}
}

func (r *todoRepo) Create(todo *model.Todo) error {
    return r.db.Create(todo).Error
}

func (r *todoRepo) GetAll() ([]model.Todo, error) {
    var todos []model.Todo
    err := r.db.Find(&todos).Error
    return todos, err
}
4.2 Service 层

go

// service/todo_service.go
type TodoService interface {
    CreateTodo(title string) (*model.Todo, error)
    ListTodos() ([]model.Todo, error)
}

type todoService struct { repo repository.TodoRepository }

func NewTodoService(repo repository.TodoRepository) TodoService {
    return &todoService{repo: repo}
}

func (s *todoService) CreateTodo(title string) (*model.Todo, error) {
    if title == "" {
        return nil, errors.New("title cannot be empty")
    }
    todo := &model.Todo{Title: title, Completed: false}
    err := s.repo.Create(todo)
    return todo, err
}

func (s *todoService) ListTodos() ([]model.Todo, error) {
    return s.repo.GetAll()
}
4.3 Handler 层

go

// handler/todo_handler.go
type TodoHandler struct { svc service.TodoService }

func NewTodoHandler(svc service.TodoService) *TodoHandler {
    return &TodoHandler{svc: svc}
}

func (h *TodoHandler) Create(c *gin.Context) {
    var req struct { Title string `json:"title" binding:"required"` }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    todo, err := h.svc.CreateTodo(req.Title)
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(201, todo)
}

func (h *TodoHandler) List(c *gin.Context) {
    todos, err := h.svc.ListTodos()
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, todos)
}
4.4 在 main.go 中组装依赖

go

db := initDB()                                 // 连接数据库
todoRepo := repository.NewTodoRepository(db)   // 创建 Repository
todoSvc := service.NewTodoService(todoRepo)    // 注入 Repository 到 Service
todoHandler := handler.NewTodoHandler(todoSvc) // 注入 Service 到 Handler

r.POST("/todos", todoHandler.Create)
r.GET("/todos", todoHandler.List)

这种写法就是依赖注入:每一层需要的依赖由外部传入,而不是自己创建。这带来了极大的灵活性:更换数据库实现时,只需要修改 Repository 和 main.go,Service 和 Handler 完全不用动。

第 5 步:结构化日志(slog)

生产环境需要可检索、可分级的日志。Go 1.21+ 的标准库 log/slog 直接支持 JSON 格式。

在 main.go 中初始化:

go

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

然后在各层添加合适的日志:

go

// Handler
slog.Info("收到创建 Todo 请求", "title", req.Title)

// Service
slog.Info("业务层创建 Todo", "title", title)

// Repository 出错时
slog.Error("数据库插入失败", "error", err, "todo", todo)

最终日志输出示例:

json

{"time":"2026-05-25T10:00:00Z","level":"INFO","msg":"收到创建 Todo 请求","title":"买牛奶"}
{"time":"...","level":"INFO","msg":"Todo 创建成功","id":1}

第 6 步:优雅关闭

当服务收到 SIGTERM(K8s 停止 Pod)或 Ctrl+C 时,不应该立即杀死进程,而应该:

  1. 停止接收新请求

  2. 处理完已经在处理中的请求

  3. 关闭数据库连接等资源

实现方式(在 main.go 中):

go

srv := &http.Server{
    Addr:    ":" + port,
    Handler: r,
}

go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("listen: %s", err)
    }
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("Shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    slog.Error("Server forced shutdown", "error", err)
}
slog.Info("Server exited")

现在,即使你按 Ctrl+C,服务也会等待最多 5 秒让正在进行的请求完成。

5. 回顾:我们解决了哪些“生产级”问题?

问题 算法题做法 生产级解决方案
数据持久化 内存 map SQLite/GORM,后续可换 PostgreSQL
配置修改 改代码重新编译 viper 读 YAML / 环境变量
代码组织 所有逻辑在 main 三层架构 + 依赖注入
调试信息 fmt.Println slog 结构化日志,JSON 输出
进程终止 直接 kill 优雅关闭,不丢请求

6. 下一步还可以做什么?

如果你想让这个服务更“生产”,可以继续扩展:

  • Docker 容器化:编写 Dockerfile,使用多阶段构建。

  • Redis 缓存:为 GetAll 接口增加缓存,减少数据库压力。

  • JWT 鉴权:保护 API,只有登录用户才能操作。

  • 单元测试:为 Service 层编写 mock 测试,不依赖真实数据库。

  • 请求限流:使用 gin-rate-limit 防止恶意刷接口。

7. 总结

从写出一道 LRU 缓存算法题,到搭建一个可维护、可配置、可观测的 Web 服务,中间差的不是更多算法知识,而是工程化思维对常见组件的熟悉

这篇文章展示的每一步都是我自己踩过坑后的总结,希望对正在从“刷题”迈向“生产”的你有所帮助。当你再看到“生产级代码”这个词时,希望你能想起:分层、配置、日志、优雅关闭——这些并不是什么高深的概念,而是一行行可以亲手写出来的代码。

项目代码仓库Yao-2005/todo-api

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐