项目结构不是越复杂越专业
Go 新手很容易被各种项目模板吓到:cmd、internal、pkg、api、configs、deployments、scripts、services、repositories。有些结构来自大型项目,有些只是团队习惯。如果你刚写一个小服务,一开始就照搬复杂模板,反而会让代码分散、跳转困难。
Go 的项目结构应该随着需求自然长出来。一个命令行工具可以只有 main.go;一个小 Web 服务可以先有 main.go、handler.go、store.go;当可执行入口变多,再引入 cmd;当内部包不想被外部引用,再放进 internal;当业务边界复杂,再拆 handler、service、store。
这篇文章不提供万能模板,而是讲判断依据。你会看到一个小服务如何从简单结构逐步演进,什么时候拆目录,什么时候先别拆。
最小结构就够用
刚开始:
notes/
├── go.mod
├── main.go
├── handler.go
└── store.go
所有文件都属于 package main。这对几百行以内的小服务完全可以接受。main.go 启动服务,handler.go 处理 HTTP,store.go 管理数据。
不要因为看到别人有很多目录,就急着拆包。拆包会带来导出规则、依赖方向和命名成本。如果代码还很少,保持一个包反而更清楚。
当你发现 main 包里出现几类明显职责,并且文件之间关系开始混乱,再考虑拆。
引入 cmd:多个可执行入口
如果项目有多个命令,比如一个 Web 服务和一个迁移工具:
notes/
├── go.mod
├── cmd/
│ ├── notes-server/
│ │ └── main.go
│ └── notes-migrate/
│ └── main.go
└── internal/
└── app/
└── app.go
cmd 下面每个目录是一个可执行程序。构建:
go build ./cmd/notes-server
go build ./cmd/notes-migrate
cmd/notes-server/main.go 应该很薄:
package main
import (
"log"
"example.com/notes/internal/app"
)
func main() {
if err := app.RunServer(); err != nil {
log.Fatal(err)
}
}
真正逻辑放在内部包里。这样多个入口可以复用配置、数据库和业务代码。
如果只有一个入口,cmd 不是必须。直接根目录 main.go 更简单。
internal 的意义
internal 是 Go 语言工具链支持的特殊目录。放在 internal 下的包,只能被父目录及其子目录引用,外部模块无法 import。
notes/
└── internal/
├── handler/
├── service/
└── store/
这适合应用内部代码。你不希望别人 import 你的 handler 或 store,因为它们不是公共库 API,只服务当前应用。
示例:
package service
type Note struct {
ID int64
Title string
}
type Store interface {
Create(title string) (Note, error)
List() ([]Note, error)
}
type Service struct {
store Store
}
func New(store Store) *Service {
return &Service{store: store}
}
internal/service 可以被 cmd/notes-server 引用,但不能被其他模块引用。这能防止内部结构无意中变成外部依赖。
handler、service、store 怎么分
一个常见小型 Web 服务结构:
internal/
├── handler/
├── service/
└── store/
handler 关心 HTTP:
func (h *Handler) createNote(w http.ResponseWriter, r *http.Request) {
var req CreateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
note, err := h.service.Create(r.Context(), req.Title)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, note)
}
service 关心业务规则:
func (s *Service) Create(ctx context.Context, title string) (Note, error) {
title = strings.TrimSpace(title)
if title == "" {
return Note{}, fmt.Errorf("title is required")
}
return s.store.Create(ctx, title)
}
store 关心数据访问:
func (s *MemoryStore) Create(ctx context.Context, title string) (service.Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
note := service.Note{ID: s.nextID, Title: title}
s.notes[note.ID] = note
s.nextID++
return note, nil
}
这只是一个方向,不是教条。小项目里 handler 和 service 合在一起也可以。拆分的标准是:这层是否有独立职责,是否降低了复杂度。
pkg 目录要谨慎使用
很多模板有 pkg 目录,表示可被外部项目引用的公共库代码。但很多应用项目并不需要对外提供库。初学者常把所有代码都塞进 pkg,反而让内部实现看起来像公共 API。
如果你的包只是当前应用使用,放 internal 更合适。如果你真的写了可复用库,比如通用分页、日志格式化、协议客户端,并愿意让其他模块 import,再考虑 pkg 或直接放在根目录下的命名包。
简单规则:不确定是否要对外复用时,先不要放 pkg。
配置、脚本和文档
项目稍大后可以有:
configs/
scripts/
docs/
但这些目录不是 Go 语言要求。配置示例可以放 configs/,运维脚本放 scripts/,设计文档放 docs/。不要让目录名替你做架构决策。
真正影响 Go 编译和引用的是包目录、go.mod、internal 规则和 import 路径。其他目录更多是团队约定。
小结
Go 项目结构应该服务于当前复杂度。一个入口、几百行代码时,根目录几个文件就够了;多个可执行程序时,引入 cmd;应用内部包不想被外部引用时,用 internal;业务复杂后,再按 handler、service、store 拆职责。
不要一开始就套大型模板,也不要把所有代码堆到无法维护。最好的结构是读者能顺着启动入口,一路看到依赖如何组装、请求如何进入、业务规则在哪里、数据如何保存。
Go 的简单不是没有结构,而是结构要显式、自然、必要。每次拆目录前问一句:它是否让代码更容易读、更容易测、更容易改?如果答案不明确,先保持简单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。