Go / go-zero 的分层目录规范:MVC、handler / logic / model / service / middleware 与文件后缀

「MVC 目录怎么分?model / controller(handler) / service(logic) 各放哪?Middleware 目录啥规范?文件要不要 _logic / _model / _handler 后缀?」一篇讲清 go-zero 与主流 Go 框架的差异与踩坑。

一句话结论 术语对照 请求分层链路 go-zero 目录 go-zero 后缀 其他框架 要不要后缀 Middleware 目录 规范清单 速查表

Go 没有官方 MVC,但「分层」是共识;后缀因框架而异

三条最关键结论,先记住再展开。

① Go 没有语言级/官方 MVC 强制。所谓「MVC」在 Go Web 里其实是分层架构:接入层(Handler/Controller)→ 业务层(Logic/Service)→ 数据层(Model/DAO)。
② 术语被「翻译」了。因为 Go 标准库叫 http.Handler,Go 圈更习惯说 Handler 而非 Controller;业务层 go-zero 叫 logic(其他框架常叫 service);数据层叫 model
③ 文件后缀分两派。go-zero 用 goctl 代码生成,文件必须带 handler.go / logic.go / middleware.go / model.go 角色后缀(注意:没有下划线,是 loginhandler.go);Gin/Echo/手写 不强制后缀,目录/包名已表意,后缀只是团队风格。

经典 MVC ↔ Go 各框架的叫法

同一个职责,不同框架叫法不同;搞清楚映射才不会被名词绕晕。

MVC 角色职责go-zero其他 Go 框架(Gin/Echo/Fiber)标准库 net/http
Controller接收请求、解析参数、返回响应handler /controllers/ 或 handlers/handler 函数(无目录约定)
Service / Logic业务逻辑、编排logic /services/ 或 service/自定(常叫 service 包)
Model数据结构 + 数据访问(DAO)model /models/ 或 repository/ 或 store/自定(常叫 model/store)
View渲染输出types/(DTO)+ JSON 响应;无模板层若 SSR 用 templates/,否则也是 JSON自定
Middleware横切逻辑(鉴权/日志/跨域)middleware /middleware/手写中间件函数
为什么 Go 不爱说「Controller」?因为标准库的类型就叫 http.Handler / http.HandlerFunc,路由框架(Gin/Echo/go-zero)都围绕它设计。所以「控制器的动作」在 Go 里天然就是「一个 Handler 函数」。go-zero 直接把目录叫 handler

一次 HTTP 请求穿过哪些目录

Middleware → Handler(薄壳)→ Logic(业务)→ Model/DAO(数据)→ DB。箭头即目录边界。

Middleware 鉴权/日志 Handler 解析+返回 Logic 业务逻辑 Model/DAO 数据访问 DB / 下游 MySQL/Redis/RPC
Handler 越薄越好
只做:取 ctx、httpx.Parse 解析、调 logic、httpx.OkJson 返回。不放业务。
Logic 是业务核心
通过 svcCtx 拿依赖,编排 model 调用,可独立单测。
Model 只管数据
SQL 拼装、行映射、缓存读写;不写业务规则。
依赖只自上而下
Handler→Logic→Model 单向;Model 不反向 import Handler。反转靠 svc 注入。

go-zero 的「MVC」长这样(goctl 生成的骨架)

API 服务:handler / logic / svc / types / config / middleware;数据层 model 由 goctl model 生成。RPC 服务对应 server / logic / svc。

user-api/ ├── etc/ │ └── user-api.yaml # 端口/DB/Redis/等配置(Host:Port 在这) ├── user.api # API 定义(DSL),goctl 据此生成代码 ├── user.go # main:解析配置→建 svc→注册路由→Start └── internal/ ├── config/ # 配置 struct(内嵌 rest.RestConf 等) │ └── config.go ├── handler/ # ★ Controller 层(薄壳) │ ├── routes.go # 路由注册 + 中间件挂载 │ ├── loginhandler.go # 每个接口一个文件 │ └── profilehandler.go ├── logic/ # ★ Service 层(业务) │ ├── loginlogic.go │ └── profilelogic.go ├── svc/ # 组合根:依赖装配 │ └── servicecontext.go ├── types/ # ★ View 层替代物:请求/响应 DTO │ └── types.go └── middleware/ # ★ 横切逻辑(手写或 .api 声明生成) ├── jwtmiddleware.go └── corsmiddleware.go user-rpc/(RPC 服务变体) └── internal/ ├── server/ # 服务端实现(≈ handler) ├── logic/ # 业务(同 API) ├── svc/ # 装配 └── middleware/ # 拦截器放这
目录对应 MVC 角色装什么谁生成
handler/Controller路由+薄壳,调 logic、写 JSONgoctl(按 .api)
logic/Service业务逻辑,消费 svcCtxgoctl(按 .api)
types/View(DTO)请求/响应结构体goctl(按 .api)
svc/依赖装配(组合根)goctl
config/配置类型goctl
middleware/MiddlewareHTTP 中间件 / RPC 拦截器手写 / .api 声明
model/Model(DAO)表模型 + 数据访问方法goctl model mysql
go-zero 把「数据层 model」和「业务层 logic」分开生成:API 的 handler/logic 由 .api 生成;数据层 model 由 goctl model mysql ddl 生成(带 _gen.go 后缀,见下节)。两者通过 svc 注入串起来。

go-zero 的文件:必须带「角色后缀」,但没有下划线

goctl 的命名模板是「接口名 + 角色名 + .go」。所以文件是 loginhandler.go,不是 login_handler.go

目录文件后缀(goctl 约定)示例说明
handler/handler.gologinhandler.go, profilehandler.go每个 @handler 生成一个
logic/logic.gologinlogic.go, profilelogic.go与 handler 一一配对
middleware/middleware.gojwtmiddleware.go, corsmiddleware.go也可写成 jwtmw.go,团队统一即可
model/model.go + model_gen.gousermodel.go, usermodel_gen.go_gen 是代码生成、可覆盖;手写扩展放 usermodel.go
types/通常 types.gotypes.go所有 DTO 集中一处
svc/servicecontext.goservicecontext.go固定文件名,goctl 必生成
关于「下划线」的澄清:你提到的 _handler / _logic / _model 带下划线的写法,在 go-zero 里并不存在——goctl 生成的是连写的 loginhandler.go。带下划线的 login_handler.goGin 等手写项目的常见风格(见第 6、7 节),两派别混。
为什么 go-zero 要这套后缀?因为 goctl 是「代码生成器」:它要能重新生成而不覆盖你的手写逻辑。于是把「自动生成」与「手写」拆成不同文件(如 model 的 _gen.go vs 手写扩展),把 handler/logic 按接口名配对,后缀就是这套机制的命名锚点。手动加文件时,建议沿用同后缀,否则 goctl 再生成时容易冲突或你 grep 不到。

Gin / Echo / Fiber / 标准库 的目录约定

这些框架不生成代码,所以目录是「团队约定」,没有 go-zero 那种固定骨架。下面是一份社区常见的落地方式。

① Gin / Echo(手写,常见 MVC 风格)

myapp/ ├── main.go ├── router/ # 路由注册(等效 go-zero 的 routes.go) │ └── router.go ├── controllers/ # ★ Controller:接收请求 │ ├── user_controller.go │ └── order_controller.go ├── services/ # ★ Service:业务逻辑 │ ├── user_service.go │ └── order_service.go ├── models/ # ★ Model:结构体 + DAO │ ├── user_model.go │ └── order_model.go ├── middleware/ # ★ 中间件 │ ├── jwt.go │ └── cors.go ├── dto/ 或 requests/ # 请求/响应结构体 └── pkg/ 或 internal/ # 公共/私有工具

② 标准库 net/http / Fiber(更轻)

myapp/ ├── main.go ├── handlers/ # 或 controllers/;Fiber 叫 handlers 居多 │ └── user.go ├── service/ # 或 services/ │ └── user.go ├── store/ # 数据层常叫 store/repository(而非 model) │ └── user_store.go └── middleware/ └── auth.go
目录结构自由
没有 goctl 强制。小项目甚至「一个 main.go + 几个 handler 函数」就够了,不必硬套目录。
数据层叫法多
models/(Gin 常见)、repository/(DDD)、store/(标准库风)、dao/(Java 味)。语义相同。
依赖注入靠手
wire、fx,或简单的「App 结构体挂依赖」在 main 装配;没有 go-zero 的 svc 固定位。
Controller vs Handler
Gin 圈常称 controllers/(沿用 MVC 词);纯标准库/Go 味项目称 handlers/。两者等价。
别照搬 Spring 的「每个 Controller 一个目录、一堆注解」思维。Go 更喜欢「按层分目录 + 同 feature 的文件放一起」。两种都能用,但一个项目只选一种,别混。

文件到底要不要 _logic / _model / _handler 后缀?

这是你最关心的问题。答案:取决于你是否用代码生成工具。

分两派

场景要不要后缀原因
go-zero(goctl 生成)要,且是硬约定goctl 靠后缀定位/配对/重生成;loginhandler.go / loginlogic.go。手写新文件也建议沿用,避免 goctl 再生成时冲突。
Gin/Echo/手写不强制目录/包名已经表意(package handler 里的 user.go 一看就是 handler)。后缀是可选风格
混合(手写也想要后缀)可以加若同 feature 跨层文件多、想按名聚合或方便 grep,可写成 user_controller.go / user_service.go / user_model.go。纯团队审美,非强制。

Go 官方倾向:不加冗余后缀。因为「包名已经表达了角色」——handler/user.gohandler/user_handler.go 更符合 Go 命名哲学(见《Go 命名规范》篇:包名即前缀,别再带角色词)。但代码生成工具是例外:它必须用稳定后缀来锚定文件。

go-zero(goctl 生成) handler/loginhandler.go logic/loginlogic.go model/usermodel_gen.go 角色后缀 = 生成器锚点(必须) Gin / 手写(包名表意) handler/user.go service/user.go model/user.go 包名已表意,后缀可选(非必须) 风格不同,非对错
结论速记:用 go-zero → 跟 goctl 的后缀(loginhandler.go 这种,无下划线);用手写框架 → 可省后缀,包名够了;想统一 grep/聚合 → 团队约定加后缀也行。「要不要后缀」不是 Go 语言的规则,是「是否用代码生成」的规则。

Middleware 目录规范(各框架一致:单独成包)

不管哪个框架,横切逻辑都建议独立成一个包,不要塞进 handler 或 logic。

框架目录文件/命名约定注册方式
go-zerointernal/middleware/jwtmiddleware.go(类型 JwtMiddleware + Handle 方法)server.Use() 全局 / rest.WithMiddlewares() 路由级 / .api 声明
Ginmiddleware/jwt.go(返回 gin.HandlerFuncr.Use(...) 全局 / r.Group().Use() 路由级
Echomiddleware/jwt.goecho.MiddlewareFunce.Use(...) / g.Use(...)
标准库middleware/auth.gofunc(next http.Handler) http.Handleralice 链式或手动嵌套
go-zero 的 HTTP 中间件签名是 rest.Middleware = func(next http.HandlerFunc) http.HandlerFunc;RPC 侧对应物叫拦截器 InterceptorUnaryServerInterceptor),目录约定相同。鉴权类优先用内置 jwt: Auth,只有自定义横切逻辑才手写。
Middleware 别放 handler 包里。Handler 应是薄壳、Logic 只管业务;鉴权/日志/跨域/CORS 这类横切关注点收口到 middleware/,才好复用、好测试、好按需挂载到不同路由组。

落地的「Do / Don't」清单

✅ 按层分目录
handler / logic / model / middleware 职责清晰分离;别把业务写进 handler、把 SQL 写进 logic。
✅ go-zero 跟 goctl 后缀
loginhandler.go / loginlogic.go / usermodel_gen.go,无下划线;手写新文件沿用同后缀。
✅ 依赖靠注入
go-zero 走 svc 装配后下发;手写框架用 App 结构体 / wire / fx。别全局变量满天飞。
✅ 同一项目只选一种风格
「按层分目录」或「按 feature 分目录」择一;controllers/ 或 handlers/ 择一;后缀加或不加择一。
🚫 不反向依赖
Model 不 import Handler;svc 不 import 业务包。依赖单向(请求自上而下,资源经 svc 注入)。
🚫 别硬套 MVC 目录
小服务不必建 5 个空目录;没有 View 的纯 API 服务,types/ 就是「View 层替代物」。
🚫 go-zero 别手写覆盖 _gen
model 的 _gen.go 是生成物,改表后会被覆盖;自定义方法放同目录非 _gen 文件。
🚫 中间件别塞 handler
横切逻辑统一收口 middleware/,便于复用与分组挂载。

一页速查:目录 / 角色 / 后缀

职责go-zero 目录其他框架目录go-zero 文件后缀是否强制
接入层handler/controllers/ 或 handlers/xxxhandler.gogo-zero 强制;其他可选
业务层logic/services/ 或 service/xxxlogic.gogo-zero 强制;其他可选
数据层model/models/ repository/ store/ dao/xxxmodel.go / _gen.gogo-zero 强制;其他可选
DTO/响应types/dto/ requests/types.gogo-zero 固定;其他随意
横切逻辑middleware/middleware/xxxmiddleware.gogo-zero 强制;其他可选
依赖装配svc/main / wire / fx / containerservicecontext.gogo-zero 固定位置
配置config/ + etc/config/config.gogo-zero 固定;其他随意
记住「后缀强制与否」的本质:凡有代码生成器(goctl)参与,后缀就是契约,必须守;纯手写,后缀是审美,可省。「要不要 _logic/_model/_handler」不是 Go 语言规则,是「是否用代码生成」的规则。

延伸阅读(本仓库)

· go-zero 的 svc 目录结构 —— 依赖装配「组合根」详解
· go-zero middleware 放哪 —— 中间件目录与 HTTP/RPC 写法
· go-zero 服务启动 —— main 到端口监听
· Go 命名与目录结构规范全解 —— 文件/包/变量命名红线(含「包名即前缀,别带角色词」)