1. 项目概述为什么一个“Go后端脚手架”值得花两小时认真搭一遍你有没有过这种经历接到一个新需求要快速起一个用户管理订单查询的内部服务接口不多、逻辑不重但光是初始化项目就卡了半小时——go mod init 写错路径、main.go 里连 gin 还是 echo 都没想好、日志用 zap 还是 logrus 拿不定、数据库连接池参数不敢调、环境变量加载方式五花八门……最后硬着头皮抄了个 GitHub 上的 demo结果发现它用的是已废弃的中间件、配置文件格式和公司规范对不上、测试覆盖率几乎为零上线前还得花半天时间“消毒”。这就是没有统一脚手架的真实代价。不是写不出代码而是每做一个小项目都在重复解决同一类基建问题依赖怎么管、配置怎么分环境、日志怎么结构化、错误怎么统一包装、HTTP 路由怎么组织、数据库怎么初始化、健康检查接口放哪儿、单元测试目录怎么建……这些事单看都不难但每次从零开始就像每次做饭都得重新磨刀、生火、打水——效率低、易出错、团队协作成本高。我带过的几个后端小组最早就是靠“复制粘贴旧项目”起步的。结果半年后一盘点A 同学的项目用 viper toml logrusB 同学用 fsnotify yaml zapC 同学自己写了套 config loader三套配置加载逻辑互不兼容运维同学部署时得分别查文档更麻烦的是当某天需要给所有服务加一个统一的 trace-id 注入中间件时我们花了三天改了17个仓库其中3个因为中间件注册顺序不对导致日志丢失 trace 信息线上排查了六小时。所以这个【GO开发脚手架】不是炫技而是一套经过生产验证的“最小可行基建共识”。它不追求大而全不塞进微服务治理、消息队列自动接入、K8s Operator 等重型能力它只聚焦最常被反复写的那20%启动流程可控、配置可分环境、日志可检索、错误可追踪、接口可测试、部署可预期。它用的是 Go 1.21 标准库 社区稳定版gin v1.9.x / zap v1.24.x / gorm v1.25.x所有依赖版本锁定在 go.mod 中无隐藏依赖、无动态加载、无运行时反射黑盒。你可以把它当成一张白纸上的标准格子——画什么内容你定但格子本身已经帮你画好了横平竖直。适合谁用三类人立刻能受益刚转 Go 的开发者不用再纠结“第一行 import 该写什么”直接git clone make run看到/healthz返回 200建立正向反馈中小团队技术负责人把这套脚手架设为公司级模板仓库新项目go run scaffold.go --nameuser-service一键生成强制统一日志字段、错误码规范、API 响应体结构独立开发者或 MVP 快速验证者省下搭建基建的4小时多出2小时打磨核心业务逻辑MVP 上线周期从3天压缩到当天下午就能发测试链接。它解决的从来不是“能不能跑”而是“能不能稳、能不能查、能不能扩、能不能交”。下面我就带你从零开始一行命令、一个配置、一次编译亲手搭出这个真正能进生产线的 Go 后端脚手架。2. 整体设计思路与关键取舍为什么这样选而不是那样做2.1 架构分层不搞“洋葱”只做“三层实心球”很多教程喜欢讲 MVC、Clean Architecture、Hexagonal但实际落地时80% 的内部服务根本不需要那么重的抽象。我们采用极简三层结构Handler 层薄只做三件事——解析请求参数binding、调用 Service、封装响应体ResponseDTO。不处理业务逻辑不操作数据库不写 if-else 判断权限那是中间件的事。Service 层实承载全部业务规则。一个 Service 方法 一个完整业务动作如CreateOrder()它协调多个 Repository、校验规则、触发领域事件。这里允许使用 interface 定义契约方便单元测试 mock。Repository 层稳只负责数据存取。GORM 是当前 Go 生态最成熟的关系型 ORM它支持预编译 SQL、软删除、关联预加载且文档清晰、社区活跃。我们不封装 GORM而是用它原生能力仅在其上加一层薄薄的 error wrap比如把gorm.ErrRecordNotFound统一转成ErrUserNotFound避免过度抽象导致调试困难。提示曾有团队用自研 ORM 封装层号称“屏蔽数据库差异”结果上线后发现 PostgreSQL 的ON CONFLICT DO UPDATE语法无法透传临时改 SQL 花了两天。GORM 的价值在于“足够好且大家都懂”而不是“理论上更优雅”。为什么不用 DDDDDD 适合复杂领域建模比如金融风控引擎、物流路径规划。但一个用户中心服务核心实体就 User、Role、Permission用 struct method 就够了。强行引入 Aggregate Root、Domain Event只会让新人读代码像解谜。2.2 配置管理放弃“万能配置中心”回归文件驱动的确定性现在流行把配置扔进 Nacos、Apollo、Consul但对中小型项目这是典型的“杀鸡用牛刀”。我们坚持用本地文件 环境变量覆盖默认配置config/default.yaml—— 所有环境共用的基础项如服务名、默认日志级别环境配置config/development.yaml/config/production.yaml—— 覆盖数据库地址、Redis 密码等敏感项运行时覆盖通过APP_ENVproduction和DB_PASSWORDxxx环境变量优先级最高。Viper 是目前最稳定的配置库它支持多格式yaml/json/toml、多源file/env/remote、热重载fsnotify。但我们禁用它的“远程配置”和“自动环境变量映射”功能——前者增加部署复杂度后者导致配置来源不可追溯你永远不知道某个值是从哪个环境变量来的。实操中我们要求所有配置项必须显式声明类型和默认值。例如type Config struct { App AppConfig mapstructure:app Server ServerConfig mapstructure:server Database DatabaseConfig mapstructure:database } type DatabaseConfig struct { Host string mapstructure:host default:127.0.0.1 Port int mapstructure:port default:3306 Name string mapstructure:name default:myapp Username string mapstructure:username default:root Password string mapstructure:password default: }mapstructure:xxx标签明确绑定 YAML 字段default:xxx提供安全兜底。这样即使config/production.yaml里漏写了port程序也能用默认值启动不会 panic。而如果用os.Getenv(DB_PORT)漏配就直接 crash排查成本极高。2.3 日志与错误结构化不是目的可检索才是Zap 是 Go 生态性能最好的结构化日志库比 logrus 快 4-10 倍内存分配少 90%。但很多人只用了它的“快”没用它的“可检索”。我们强制所有日志必须带fields且字段名遵循统一规范req_id全局唯一请求 ID由中间件注入贯穿整个请求链路user_id当前操作用户 ID登录后写入 context非登录接口留空pathHTTP 请求路径如/api/v1/usersmethodHTTP 方法GET/POSTstatus_codeHTTP 状态码200/400/500duration_ms耗时毫秒数由中间件计算并写入。这样当线上出现慢查询时运维同学在日志平台搜status_code:500 AND duration_ms:20003 秒定位到具体接口和用户当审计需要查某用户操作记录搜user_id:12345即可拉出完整行为轨迹。错误处理同理。我们定义统一错误类型type AppError struct { Code int json:code // 业务错误码如 1001用户不存在 Message string json:message // 用户可见提示 Details string json:details,omitempty // 技术细节仅开发可见 Cause error json:- // 底层错误用于 debug } func (e *AppError) Error() string { return e.Message }所有 handler 返回*AppError中间件统一捕获并序列化为标准 JSON 响应体。这样前端不用解析不同格式的 error 字段测试同学写自动化 case 时断言response.Code 1001就行稳定可靠。2.4 依赖注入不用第三方 DI 框架手写 NewXXX 函数很多教程推荐 wire、dig 等 DI 框架但它们带来两个隐形成本一是学习曲线新人要先搞懂 provider/injector 概念二是调试困难报错堆栈里全是框架代码找不到自己写的 service。我们采用最朴素的方式每个模块提供NewXXX(...)工厂函数main.go 里按依赖顺序手动组装func main() { cfg : config.Load() logger : zap.NewProduction().Named(app) db, err : gorm.Open(mysql.Open(cfg.Database.DSN()), gorm.Config{ Logger: logger.WithOptions(zap.AddCaller()).Desugar(), }) if err ! nil { logger.Fatal(failed to connect database, zap.Error(err)) } userRepository : repository.NewUserRepository(db) userService : service.NewUserService(userRepository, logger) userHandler : handler.NewUserHandler(userService, logger) r : gin.Default() r.Use(middleware.RequestID(), middleware.Logger(logger)) r.GET(/api/v1/users/:id, userHandler.GetUserByID) server : http.Server{ Addr: cfg.Server.Addr, Handler: r, } logger.Info(server starting, zap.String(addr, server.Addr)) logger.Fatal(server exited, zap.Error(server.ListenAndServe())) }看起来“啰嗦”但好处极多依赖关系一目了然userService依赖userRepository和logger单元测试时mockuserRepository后直接service.NewUserService(mockRepo, testLogger)无需启动 DI 容器重构时删掉一个 service编译器立刻报错“xxx undefined”不会等到运行时报 “provider not found”。这叫“显式优于隐式”是 Go 语言哲学的核心之一。3. 核心模块实现与实操细节从目录结构到每一行关键代码3.1 目录结构拒绝“扁平化”坚持语义化分层一个健康的 Go 项目目录结构本身就是设计文档。我们采用以下布局已通过 5 个线上项目验证myapp/ ├── cmd/ # 主程序入口每个子目录对应一个可执行文件 │ └── myapp/ # 当前服务主入口 │ ├── main.go # 初始化配置、依赖、启动 HTTP 服务 │ └── version.go # 编译时注入版本号git commit hash ├── config/ # 配置文件存放目录 │ ├── default.yaml # 公共默认配置 │ ├── development.yaml # 开发环境配置含本地 DB 地址 │ └── production.yaml # 生产环境配置不含密码由环境变量注入 ├── internal/ # 业务代码外部不可 import │ ├── handler/ # HTTP 接口层只依赖 service │ │ ├── user_handler.go │ │ └── order_handler.go │ ├── service/ # 业务逻辑层只依赖 repository domain │ │ ├── user_service.go │ │ └── order_service.go │ ├── repository/ # 数据访问层只依赖 gorm model │ │ ├── user_repository.go │ │ └── order_repository.go │ └── domain/ # 领域模型纯 struct method无外部依赖 │ ├── user.go │ └── order.go ├── pkg/ # 可复用的通用工具包如加密、分页、ID 生成 │ ├── util/ │ └── idgen/ ├── api/ # OpenAPI 3.0 规范文件swagger.yaml ├── scripts/ # 部署/构建脚本build.sh, deploy.sh ├── go.mod # 依赖声明 ├── Makefile # 常用命令封装make run, make test, make build └── README.md # 项目说明、快速启动指南关键设计点internal/目录是 Go 官方推荐的“私有包”机制放在其下的代码无法被其他 module import天然防止业务逻辑泄露handler/service/repository严格单向依赖handler → service → repository禁止反向引用domain/里不放任何import gorm.io/gorm确保领域模型纯净未来换 ORM 不影响业务逻辑pkg/里的工具必须满足“无状态、无副作用、可测试”比如idgen.Snowflake()返回 int64不依赖全局变量。注意不要把model/放在internal/外曾有项目把 GORM Model 放在根目录结果其他团队写 CLI 工具时直接import myapp/model导致 CLI 也得加载数据库驱动编译体积暴涨 3MB。internal/就是你的防火墙。3.2 配置加载一行代码加载全部且支持热重载我们封装了一个config.Load()函数它自动完成根据APP_ENV选择配置文件合并default.yamlenv.yaml用环境变量覆盖如DB_HOST10.0.1.5覆盖 yaml 中的 host校验必填字段如server.addr不能为空启动 fsnotify 监听配置文件变更触发OnConfigChange回调用于日志级别热更新。核心代码config/loader.gofunc Load() *Config { viper.SetConfigName(default) viper.SetConfigType(yaml) viper.AddConfigPath(config) env : os.Getenv(APP_ENV) if env { env development } viper.SetConfigName(env) if err : viper.ReadInConfig(); err ! nil { log.Fatalf(failed to read config file: %v, err) } // 环境变量覆盖格式APP_SERVER_ADDR - server.addr viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(., _)) var cfg Config if err : viper.Unmarshal(cfg); err ! nil { log.Fatalf(failed to unmarshal config: %v, err) } // 必填字段校验 if cfg.Server.Addr { log.Fatal(server.addr is required) } if cfg.Database.Host { log.Fatal(database.host is required) } // 启动热重载监听 viper.OnConfigChange(func(e fsnotify.Event) { log.Info(config file changed, reloading..., zap.String(file, e.Name)) if err : viper.Unmarshal(cfg); err ! nil { log.Error(failed to reload config, zap.Error(err)) } else { log.Info(config reloaded successfully) } }) viper.WatchConfig() return cfg }实测效果修改config/development.yaml中的log.level: debug保存后控制台立刻打印config reloaded successfully后续日志即变为 debug 级别无需重启进程。这对开发调试极其友好。3.3 HTTP 服务启动内置健康检查、优雅关闭、超时控制cmd/myapp/main.go是整个服务的“心脏”它必须处理三件大事第一健康检查接口/healthz不是简单返回{ status: ok }而是真实探测依赖服务r.GET(/healthz, func(c *gin.Context) { // 检查数据库连接 sqlDB, err : db.DB() if err ! nil { c.JSON(http.StatusInternalServerError, gin.H{status: db_unavailable, error: err.Error()}) return } if err sqlDB.Ping(); err ! nil { c.JSON(http.StatusInternalServerError, gin.H{status: db_unavailable, error: err.Error()}) return } // 检查 Redis如果启用 if redisClient ! nil { if _, err redisClient.Ping(context.Background()).Result(); err ! nil { c.JSON(http.StatusInternalServerError, gin.H{status: redis_unavailable, error: err.Error()}) return } } c.JSON(http.StatusOK, gin.H{status: ok, timestamp: time.Now().Unix()}) })K8s 的 liveness probe 调用此接口一旦 DB 挂了K8s 自动重启 Pod避免服务“假活”。第二优雅关闭Graceful Shutdown避免正在处理的请求被粗暴中断server : http.Server{ Addr: cfg.Server.Addr, Handler: r, } // 启动服务 go func() { if err : server.ListenAndServe(); err ! nil err ! http.ErrServerClosed { logger.Fatal(server listen failed, zap.Error(err)) } }() // 等待 OS 信号 quit : make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) -quit logger.Info(shutting down server...) // 30秒内完成现有请求超时则强制关闭 ctx, cancel : context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err : server.Shutdown(ctx); err ! nil { logger.Fatal(server shutdown failed, zap.Error(err)) } logger.Info(server exited)实测当kill -15 pid时正在执行的/api/v1/orders接口会继续处理完新请求被拒绝30 秒后进程退出。比kill -9安全十倍。第三HTTP 超时控制防止慢请求拖垮整个服务server : http.Server{ Addr: cfg.Server.Addr, Handler: r, ReadTimeout: 30 * time.Second, // 读请求头和 body 超时 WriteTimeout: 60 * time.Second, // 写响应超时 IdleTimeout: 60 * time.Second, // keep-alive 空闲超时 }注意ReadTimeout不是“整个请求处理时间”而是“从连接建立到读完请求数据”的时间。真正的业务超时应在 service 层用context.WithTimeout()控制。3.4 数据库初始化GORM 迁移 连接池调优GORM 的 AutoMigrate 很方便但生产环境必须禁用——它可能执行ALTER TABLE导致锁表。我们改用显式 migration// migration/init.go func Migrate(db *gorm.DB) error { // 创建表仅当不存在时 if err : db.AutoMigrate(domain.User{}, domain.Order{}); err ! nil { return fmt.Errorf(auto migrate failed: %w, err) } // 添加索引GORM 不支持手写 SQL if err : db.Exec(CREATE INDEX IF NOT EXISTS idx_user_email ON users(email)).Error; err ! nil { return fmt.Errorf(create index failed: %w, err) } return nil }连接池参数根据经验设置config/database.gotype DatabaseConfig struct { Host string mapstructure:host Port int mapstructure:port Name string mapstructure:name Username string mapstructure:username Password string mapstructure:password MaxOpenConns int mapstructure:max_open_conns default:50 // 最大打开连接数 MaxIdleConns int mapstructure:max_idle_conns default:20 // 最大空闲连接数 ConnMaxLife int mapstructure:conn_max_life default:3600 // 连接最大存活时间秒 }调优依据MaxOpenConns50假设 QPS 为 100平均请求耗时 200ms则理论并发连接数 ≈ 100 × 0.2 20设为 50 留足余量MaxIdleConns20等于MaxOpenConns的 40%避免空闲连接过多占用 DB 资源ConnMaxLife36001 小时后强制新建连接防止长连接因网络抖动失效。实操心得某次压测发现 DB CPU 100%排查发现MaxOpenConns设为 200而 MySQL 默认max_connections151大量连接排队等待反而降低吞吐。调回 50 后QPS 提升 3 倍。连接数不是越多越好要匹配 DB 能力。3.5 单元测试每个 handler/service/repository 都有对应 test 文件Go 的测试生态极佳我们要求handler/user_handler_test.go用httptest.NewRecorder()模拟 HTTP 请求断言状态码、JSON 结构、日志输出service/user_service_test.gomockuserRepository验证业务逻辑分支如邮箱重复时返回 ErrEmailExistsrepository/user_repository_test.go用github.com/DATA-DOG/go-sqlmock模拟数据库交互验证生成的 SQL 是否正确。示例service/user_service_test.gofunc TestUserService_CreateUser(t *testing.T) { // 1. 构造 mock repository mockRepo : new(MockUserRepository) mockRepo.On(FindByEmail, mock.Anything, testexample.com).Return(nil, gorm.ErrRecordNotFound) // 2. 创建 service svc : service.NewUserService(mockRepo, zap.NewNop()) // 3. 执行测试 user, err : svc.CreateUser(context.Background(), domain.User{ Email: testexample.com, Name: Test User, }) // 4. 断言 assert.NoError(t, err) assert.Equal(t, testexample.com, user.Email) assert.NotZero(t, user.ID) mockRepo.AssertExpectations(t) // 确保 mock 方法被调用 }zap.NewNop()是零开销日志避免测试中打印干扰。mock.AssertExpectations(t)确保FindByEmail被调用一次否则测试失败。CI 流程中我们要求go test -coverprofilecoverage.out ./...覆盖率 ≥ 70%低于则阻断合并。这不是为了数字好看而是保证核心路径如创建用户、查询订单都有测试覆盖避免低级 bug 上线。4. 完整实操流程从空目录到可运行服务的 7 步4.1 第一步初始化项目骨架打开终端执行# 创建项目目录 mkdir myapp cd myapp # 初始化 Go module替换为你自己的模块名 go mod init github.com/yourname/myapp # 创建标准目录结构 mkdir -p cmd/myapp internal/{handler,service,repository,domain} config pkg/{util,idgen} api scripts # 初始化配置文件 cat config/default.yaml EOF app: name: myapp version: 0.1.0 server: addr: :8080 read_timeout: 30 write_timeout: 60 log: level: info encoding: json database: host: 127.0.0.1 port: 3306 name: myapp username: root password: max_open_conns: 50 max_idle_conns: 20 conn_max_life: 3600 EOF cat config/development.yaml EOF database: password: 123456 EOF此时目录结构已具备基础框架下一步安装依赖。4.2 第二步安装核心依赖并锁定版本执行# 安装 GinWeb 框架 go get -u github.com/gin-gonic/ginv1.9.1 # 安装 Zap日志 go get -u go.uber.org/zapv1.24.0 # 安装 GORMORM go get -u gorm.io/gormv1.25.4 go get -u gorm.io/driver/mysqlv1.5.2 # 安装 Viper配置 go get -u github.com/spf13/viperv1.15.0 # 安装 fsnotify热重载 go get -u github.com/fsnotify/fsnotifyv1.21.0 # 查看依赖树确认无冲突 go mod graph | grep -E (gin|zap|gorm|viper)go.mod会自动生成所有版本精确锁定。注意v1.9.1明确指定小版本避免go get github.com/gin-gonic/gin自动升级到不兼容的 v2。4.3 第三步编写配置加载器创建config/loader.gopackage config import ( log os strings time github.com/fsnotify/fsnotify github.com/spf13/viper go.uber.org/zap ) type Config struct { App AppConfig mapstructure:app Server ServerConfig mapstructure:server Log LogConfig mapstructure:log Database DatabaseConfig mapstructure:database } type AppConfig struct { Name string mapstructure:name Version string mapstructure:version } type ServerConfig struct { Addr string mapstructure:addr ReadTimeout time.Duration mapstructure:read_timeout WriteTimeout time.Duration mapstructure:write_timeout } type LogConfig struct { Level string mapstructure:level Encoding string mapstructure:encoding } type DatabaseConfig struct { Host string mapstructure:host Port int mapstructure:port Name string mapstructure:name Username string mapstructure:username Password string mapstructure:password MaxOpenConns int mapstructure:max_open_conns MaxIdleConns int mapstructure:max_idle_conns ConnMaxLife int mapstructure:conn_max_life } func Load() *Config { viper.SetConfigName(default) viper.SetConfigType(yaml) viper.AddConfigPath(config) env : os.Getenv(APP_ENV) if env { env development } viper.SetConfigName(env) if err : viper.ReadInConfig(); err ! nil { log.Fatalf(failed to read config file: %v, err) } viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(., _)) var cfg Config if err : viper.Unmarshal(cfg); err ! nil { log.Fatalf(failed to unmarshal config: %v, err) } if cfg.Server.Addr { log.Fatal(server.addr is required) } if cfg.Database.Host { log.Fatal(database.host is required) } viper.OnConfigChange(func(e fsnotify.Event) { log.Printf(config file changed: %s, e.Name) if err : viper.Unmarshal(cfg); err ! nil { log.Printf(failed to reload config: %v, err) } else { log.Println(config reloaded successfully) } }) viper.WatchConfig() return cfg }4.4 第四步编写主程序入口创建cmd/myapp/main.gopackage main import ( context log net/http os os/signal syscall time github.com/gin-gonic/gin go.uber.org/zap gorm.io/driver/mysql gorm.io/gorm github.com/yourname/myapp/config github.com/yourname/myapp/internal/handler github.com/yourname/myapp/internal/repository github.com/yourname/myapp/internal/service ) func main() { cfg : config.Load() logger, _ : zap.NewProduction() defer logger.Sync() dsn : cfg.Database.Username : cfg.Database.Password tcp( cfg.Database.Host : string(rune(cfg.Database.Port)) )/ cfg.Database.Name ?charsetutf8mb4parseTimeTruelocLocal db, err : gorm.Open(mysql.Open(dsn), gorm.Config{ Logger: logger.WithOptions(zap.AddCaller()).Desugar(), }) if err ! nil { logger.Fatal(failed to connect database, zap.Error(err)) } // 初始化 Repository 和 Service userRepository : repository.NewUserRepository(db) userService : service.NewUserService(userRepository, logger) userHandler : handler.NewUserHandler(userService, logger) // 初始化 Gin Router r : gin.Default() r.Use(middleware.RequestID(), middleware.Logger(logger)) // 注册路由 api : r.Group(/api/v1) { api.GET(/healthz, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{status: ok}) }) api.GET(/users/:id, userHandler.GetUserByID) } // 启动 HTTP 服务 server : http.Server{ Addr: cfg.Server.Addr, Handler: r, ReadTimeout: cfg.Server.ReadTimeout * time.Second, WriteTimeout: cfg.Server.WriteTimeout * time.Second, } go func() { logger.Info(server starting, zap.String(addr, server.Addr)) if err : server.ListenAndServe(); err ! nil err ! http.ErrServerClosed { logger.Fatal(server listen failed, zap.Error(err)) } }() // 等待退出信号 quit : make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) -quit logger.Info(shutting down server...) ctx, cancel : context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err : server.Shutdown(ctx); err ! nil { logger.Fatal(server shutdown failed, zap.Error(err)) } logger.Info(server exited) }4.5 第五步实现领域模型与 Repository创建internal/domain/user.gopackage domain import time type User struct { ID uint64 gorm:primaryKey;autoIncrement Email string gorm:uniqueIndex;not null Name string gorm:not null CreatedAt time.Time UpdatedAt time.Time }创建internal/repository/user_repository.gopackage repository import ( errors gorm.io/gorm github.com/yourname/myapp/internal/domain ) type UserRepository struct { db *gorm.DB } func NewUserRepository(db *gorm.DB) *UserRepository { return UserRepository{db: db} } func (r *UserRepository) FindByID(id uint64) (*domain.User, error) { var user domain.User if err : r.db.First(user, id).Error; err ! nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New(user not found) } return nil, err } return user, nil }4.6 第六步编写 Service 与 Handler创建internal/service/user_service.gopackage service import ( context github.com/yourname/myapp/internal/domain github.com/yourname/myapp/internal/repository go.uber.org/zap ) type UserService struct { repo *repository.UserRepository logger *zap.Logger } func NewUserService(repo *repository.UserRepository, logger *zap.Logger) *UserService { return UserService{repo: repo, logger: logger} } func (s *UserService) GetUserByID(ctx context.Context, id uint64) (*domain.User, error) { user, err : s.repo.FindByID(id) if err ! nil { s.logger.Warn(failed to find user by id, zap.Uint64(id, id), zap.Error(err)) return nil, err } return user, nil }创建internal/handler/user_handler.gopackage handler import ( net/http strconv github.com/gin-gonic/gin go.uber.org/zap github.com/yourname/myapp/internal/domain github.com/yourname/myapp/internal/service ) type UserHandler struct { service *service.UserService logger *zap.Logger } func NewUserHandler(service *service.UserService, logger *zap.Logger) *UserHandler { return UserHandler{service: service, logger: logger} } func (h *UserHandler) GetUserByID(c *gin.Context) { idStr : c.Param(id) id, err : strconv.Parse