从「LeetCode LRU 缓存」到「生产级 Go Web 服务」:我如何迈出工程化第一步
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 时,不应该立即杀死进程,而应该:
-
停止接收新请求
-
处理完已经在处理中的请求
-
关闭数据库连接等资源
实现方式(在 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
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)