从空目录到能跑:Go 博客骨架搭建实录
从空目录到能跑:Go 博客骨架搭建实录
项目第一阶段我只定了一个验收标准:
curl http://localhost:8080/healthz
能稳定返回 200,并且修改代码后服务自动重启。
听起来有点寒酸,但这个阶段真正要验证的不是业务,而是开发循环:
改代码 -> 编译 -> 重启 -> 发请求 -> 看日志
如果这条链路不顺,后面每加一个功能都要交一次重复劳动税。
一、目录先按业务分,不按技术堆
最早的目录是传统三层:
internal/
├── handler/
├── service/
└── repository/
功能少时很清楚,功能多后会变成:
handler 里放所有 HTTP
service 里放所有业务
repository 里放所有 SQL
找一段评论逻辑需要横跨三个大目录。后来改成按业务域组织:
internal/
├── app/
├── auth/
│ ├── handler/
│ ├── service/
│ └── repository/
├── blog/
│ ├── handler/
│ ├── service/
│ └── repository/
├── tool/
├── config/
└── web/
三层仍然存在,但被限制在各自模块里。新增作品集时,只需要增加一个 internal/project,而不是继续往三个公共抽屉里塞东西。
二、配置加载:YAML 描述结构,环境变量保存秘密
开发配置:
environment: development
server:
host: 127.0.0.1
port: 8080
database:
host: localhost
port: 5432
user: cairn
password: ${DB_PASSWORD}
name: cairn
sslmode: disable
对应结构:
type Config struct {
Environment string `mapstructure:"environment"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
}
这里踩了第一个坑:Viper 的 AutomaticEnv() 不会自动展开 YAML 字符串中的 ${DB_PASSWORD}。
错误现象是数据库认证失败,但配置文件看起来完全正确。最后发现传给 PostgreSQL 的密码真的是字面量:
${DB_PASSWORD}
解决方式:
cfg.Database.Password = os.ExpandEnv(cfg.Database.Password)
加载后还要做校验:
if cfg.Database.Password == "" {
return nil, errors.New("database.password is required")
}
配置错误应该在启动阶段直接失败,而不是等第一个用户访问时再给他一张 500 页面作为欢迎礼物。
三、PostgreSQL 用 Docker,Go 不用
本地数据库配置:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: cairn
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: cairn
ports:
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
数据库放容器里有几个好处:
- 不污染宿主机
- 版本固定
- 数据卷可控
- 重置环境方便
Go 应用直接运行在宿主机,因为 Air 监听文件、重新编译和传递信号都更简单。
Air -> Go -> PostgreSQL 容器
本地开发和生产部署不必在每个细节上完全一致。需要一致的是行为和依赖版本,不是连敲命令的姿势都相同。
四、应用入口只负责生命周期
入口文件不应该知道每个 repository 怎么创建。它只负责:
- 初始化日志。
- 创建应用。
- 启动 HTTP Server。
- 监听退出信号。
- 优雅关闭。
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
application, err := app.New(context.Background(), logger)
if err != nil {
logger.Error("initialize app", "error", err)
os.Exit(1)
}
defer application.Close()
srv := &http.Server{
Addr: "127.0.0.1:8080",
Handler: application.Router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server stopped", "error", err)
}
}
Repository、Service、Handler 的组装放在 internal/app。这样入口不会随着功能增加变成一份两百行的零件清单。
五、数据库连接为什么要 Ping
pgxpool.New 成功不代表数据库真的能访问。它主要创建连接池配置,连接可能在第一次查询时才建立。
因此启动阶段需要:
db, err := pgxpool.New(ctx, cfg.Database.DSN())
if err != nil {
return nil, err
}
if err := db.Ping(ctx); err != nil {
db.Close()
return nil, err
}
如果密码错误、端口没开或容器没启动,应用应该立即退出,让 systemd 或开发者看到明确错误。
“先启动,等有人访问再爆炸”不算容错,只算延迟公布坏消息。
六、健康检查要简单
健康检查:
func healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","version":"dev"}`))
}
它的用途:
- 手工确认服务存活
- 反向代理探测
- 部署后验证
- 监控平台定时请求
健康接口不要渲染模板,也不要做昂贵查询。它应该像敲门:快速告诉外面“服务还活着”。
七、接入 Air 热重载
安装:
go install github.com/air-verse/air@latest
启动:
DB_PASSWORD=本地密码 air
Air 会监听 Go 文件,重新构建并启动临时二进制。
常见问题一:命令找不到。
go env GOPATH
go install 默认把工具放进:
$(go env GOPATH)/bin
把这个目录加入 PATH,或者直接使用完整路径。
常见问题二:旧版配置字段失效。工具升级后如果出现 deprecated 警告,不要假装日志没写。警告通常只是 Bug 的预告片。
八、国内网络下的依赖下载
Go 模块下载超时时:
GOPROXY=https://goproxy.cn,direct go mod tidy
Docker 镜像无法拉取时,应先确认是仓库网络问题,而不是反复修改 Compose 文件。
排查顺序:
docker info
docker pull postgres:16
docker compose config
docker compose up -d
docker compose ps
把“配置解析失败”“镜像下载失败”“容器启动失败”分开看,会少走很多弯路。
九、最终验证
启动数据库:
docker compose up -d
启动应用:
DB_PASSWORD=本地密码 air
验证:
curl -i http://localhost:8080/healthz
确认:
- HTTP 状态是 200
- 日志为结构化 JSON
- 修改 handler 后 Air 会自动重启
- 停止 PostgreSQL 后应用启动会明确失败
Ctrl+C能触发优雅关闭
这个阶段没有文章,没有用户,也没有漂亮页面。但它建立了一条可信的开发链路。
项目后面能跑多远,往往取决于最开始这条链路有多无聊、多稳定。
评论
暂无评论,来说点什么吧。
请先登录后再发表评论
去登录