diff --git a/INTEGRATION.md b/INTEGRATION.md index 80c8caa..09669e8 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -1,366 +1,192 @@ -# GoCommon 业务项目对接操作手册 - -本文档供**引用 GoCommon 的业务项目**使用。按本手册对接即可,无需重复沟通基础设施实现方式。 +# GoCommon 业务项目对接手册 模块路径:`git.toowon.com/jimmy/go-common` -> 本文档只描述**目标架构**,重构时一步到位,**不提供过渡期用法,不考虑向后兼容**。 +> 目标架构一步到位,不考虑向后兼容。 --- -## 1. 对接原则 +## 1. 原则 | 原则 | 说明 | |------|------| -| Factory 只是入口 | 启动时初始化一次,通过 getter 获取各模块**对象** | -| 能力在模块自身 | 使用 `log.Info()`、`db.Find()`、`store.Upload()`,不在 Factory 上堆透传方法 | -| 按需引用 | 用什么模块取什么对象;无状态工具直接 `import tools` | -| 不重写基础设施 | 数据库连接、Redis 客户端、统一 HTTP 出参、中间件链由 GoCommon 提供 | -| 业务只管业务 | Service 返回数据;Handler 交给 `http.Handler` 出参;Migration 只写 SQL 文件 | +| Factory 只是入口 | 启动初始化一次,getter 取模块对象 | +| 能力在模块自身 | `log.Info()`、`db.Find()`、`store.Upload()`,不在 Factory 透传 | +| 按需配置 | 只用到的模块写进 `config.json` | +| 不重写基础设施 | 连接池、HTTP 出参、中间件链由本库提供 | +| 业务只管业务 | Service 返回数据;出参用 `http.Handler`;迁移只写 SQL | + +登录鉴权、参数校验、出站 HTTP Client 等由**业务项目自行实现**,不在本库范围。 --- -## 2. 环境准备 - -### 2.1 配置私有模块 +## 2. 安装 ```bash go env -w GOPRIVATE=git.toowon.com go env -w GONOPROXY=git.toowon.com go env -w GONOSUMDB=git.toowon.com -``` - -### 2.2 Git 认证(SSH 推荐) - -```bash git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/" + +go get git.toowon.com/jimmy/go-common@v1.2.0 ``` -### 2.3 安装依赖 - -```bash -go get git.toowon.com/jimmy/go-common@v1.0.0 -``` - -在 `go.mod` 中: - -```go -require git.toowon.com/jimmy/go-common v1.0.0 -``` - -配置示例见 [`config/example.json`](./config/example.json)。 +配置示例:[`config/example.json`](./config/example.json)。 --- -## 3. 推荐项目结构 +## 3. 推荐结构 ```text your-project/ ├── config.json -├── cmd/ -│ ├── server/main.go -│ └── migrate/main.go # 从 templates/migrate/main.go 复制 +├── cmd/server/main.go # 复制 templates/server/main.go +├── cmd/migrate/main.go # 复制 templates/migrate/main.go ├── migrations/ -│ └── 20240101000001_create_users.sql -├── locales/ # i18n(可选) -│ ├── zh-CN.json -│ └── en-US.json -├── internal/ -│ ├── handler/ -│ └── service/ +├── locales/ # 可选 +├── internal/handler/ +├── internal/service/ └── go.mod ``` --- -## 4. 启动初始化(只做一次) +## 4. 启动(只做一次) -```go -package main - -import ( - "log" - "net/http" - - "git.toowon.com/jimmy/go-common/factory" -) - -func main() { - if err := factory.Init("config.json"); err != nil { - log.Fatal(err) - } - - app := factory.Default() - chain := app.MiddlewareChain() - chain.Append(yourAuthMiddleware) - - userSvc, err := NewUserService(app) - if err != nil { - log.Fatal(err) - } - - http.Handle("/api/users", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { - listUsers(w, r, userSvc, app) - })) - log.Fatal(http.ListenAndServe(":8080", nil)) -} +```bash +cp templates/server/main.go cmd/server/main.go ``` -**禁止**在每个 Handler 里 `NewFromFile` / `Init`。全局只初始化一次,通过 `factory.Default()` 或注入 `*factory.Factory`。 +模板默认已包含:`MustInit` + `buildOptions`(Option 挂载点)+ `MustLogger` + `Warmup` + `MiddlewareChain` + `NewHandler` + `Close`。 + +复制后主要改三处: + +| 函数 | 用途 | +|------|------| +| `buildOptions()` | `WithLogger` / `WithStorage` 等自定义注入 | +| `setupMiddleware()` | `chain.Append(业务鉴权…)` | +| `registerRoutes()` | 注册业务路由 | + +```bash +go run ./cmd/server -config config.json -addr :8080 +``` --- -## 5. 获取模块对象(Factory getter) +## 5. 获取模块对象 -连接与客户端由 Factory **lazy 初始化并缓存**。业务侧**不要**自行 `gorm.Open` / `redis.NewClient`。 +连接由 Factory lazy 缓存。业务侧不要自行 `gorm.Open` / `redis.NewClient`。 -| 模块 | API | 用法 | +| 模块 | API | 说明 | |------|-----|------| -| 配置 | `app.Config()` | 读原始配置 | -| 数据库 | `app.Database()` | `db.Find(&users)`、`db.Transaction(...)` | -| Redis | `app.Redis()` | `rds.Set(ctx, key, val, ttl)` | -| 日志 | `app.Logger()` | `log.Info(...)`,退出前 `log.Close()` | -| 存储 | `app.Storage()` | `store.Upload(ctx, key, reader)` | -| 邮件 | `app.Email()` | `mail.SendEmail(...)` | -| 短信 | `app.SMS()` | `sms.SendSMS(...)` | -| Excel | `app.Excel()` | `ex.ExportToFile(...)` | -| 国际化 | `app.I18n()` | 供 `http.Handler` 注入;或 `i18n.GetMessage(...)` | -| 中间件链 | `app.MiddlewareChain()` | `chain.Append(...).ThenFunc(...)` | -| 迁移 | `app.Migrator("migrations")` | `m.Up()` / `m.Status()` / `m.Down()` | +| 配置 | `Config()` | 原始配置 | +| 数据库 | `Database()` / `MustDatabase()` | `*gorm.DB` | +| Redis | `Redis()` / `MustRedis()` | `*redis.Client` | +| 日志 | `Logger()` / `MustLogger()` | 退出用 `Close()` | +| 存储 | `Storage()` / `MustStorage()` | Upload / GetURL | +| 邮件 | `Email()` / `MustEmail()` | SendEmail / SendEmailAsync | +| 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync | +| Excel | `Excel()` | 每次新建导出器 | +| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 | +| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` | +| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` | +| 迁移 | `Migrator(dir)` | Up / Down / Status | +| 生命周期 | `Close()` / `Warmup(...)` | 收口 / 启动预热 | -### 注入 Service 示例 +`MustXxx` 仅用于 **main / 启动注入**(失败 panic)。 ```go -type UserService struct { - db *gorm.DB - rds *redis.Client -} - -func NewUserService(app *factory.Factory) (*UserService, error) { - db, err := app.Database() - if err != nil { - return nil, err - } - rds, err := app.Redis() - if err != nil { - return nil, err - } - return &UserService{db: db, rds: rds}, nil -} - -func (s *UserService) List(page, size int) (users []User, total int64, err error) { - s.db.Model(&User{}).Count(&total) - err = s.db.Offset((page-1)*size).Limit(size).Find(&users).Error - return +func NewUserService(app *factory.Factory) *UserService { + return &UserService{db: app.MustDatabase(), rds: app.MustRedis()} } ``` -`config.json` 未配置的模块,调用 getter 会返回错误——**只配置使用的模块即可**。 - --- ## 6. HTTP 统一出参 -### 6.1 职责划分 +| 层级 | 职责 | +|------|------| +| Service | 返回数据或 error | +| Handler | 解析、调 Service、`h.Success` / `h.Error` | +| `http.Handler` | 统一 JSON 信封、i18n、timestamp | -| 层级 | 做什么 | -|------|--------| -| Service | 返回 `[]User`、`total`、业务 error | -| Handler | 解析请求、调 Service、通过 `http.Handler` 出参 | -| `http.Handler` | PageData → Response → JSON(结构 / 编码 / 语种 / 时间统一) | - -**禁止**在 Service 拼 JSON,**禁止**在业务项目自定义 `Response` 结构,**禁止**经 Factory 出参(无 `app.Success` / `app.Error`)。 - -### 6.2 标准响应格式 +响应格式(HTTP 恒 200): ```json -{ - "code": 0, - "message": "success", - "timestamp": 1704067200, - "data": {} -} +{ "code": 0, "message": "success", "timestamp": 1704067200, "data": {} } ``` -分页时 `data`: - -```json -{ - "list": [], - "total": 100, - "page": 1, - "pageSize": 20 -} -``` - -类型定义在 `http` 包:`http.Response`、`http.PageData`。 - -### 6.3 Handler 用法(唯一方式) - -中间件链须包含 `Language`、`Timezone`(`MiddlewareChain()` 已默认组装)。 +分页 `data`:`{ "list", "total", "page", "pageSize" }`。 ```go -import commonhttp "git.toowon.com/jimmy/go-common/http" +h := app.NewHandler(w, r) // 自动注入已配置的 i18n -func listUsers(w http.ResponseWriter, r *http.Request, svc *UserService, app *factory.Factory) { - i18n, _ := app.I18n() - h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n)) - - var req ListUserRequest - if err := h.ParseJSON(&req); err != nil { - h.Error("common.invalid_request") - return - } - - p := h.Pagination() - users, total, err := svc.List(p.GetPage(), p.GetPageSize()) - if err != nil { - h.Error("user.list_failed") - return - } - - h.SuccessPage(users, total) +var req ListUserRequest +if err := h.ParseJSON(&req); err != nil { + h.Error("common.invalid_request") + return } +users, total, err := svc.List(h.Pagination().GetPage(), h.Pagination().GetPageSize()) +if err != nil { + h.Error("user.list_failed") + return +} +h.SuccessPage(users, total) ``` -`http.Handler` 统一负责: - -- `Content-Type: application/json; charset=utf-8` -- 从 context 读取语种、时区 -- 消息码(如 `user.not_found`)经 i18n 转为文案与业务 code -- `timestamp` 按统一时区策略写入 - -### 6.4 请求头约定 - -| Header | 用途 | -|--------|------| -| `Accept-Language` | 响应消息语种 | -| `X-Timezone` | 时区(默认 `Asia/Shanghai`) | +请求头:`Accept-Language`(语种)、`X-Timezone`(默认 `Asia/Shanghai`)。 --- ## 7. 数据库迁移 -执行框架已封装,业务**只写 SQL 文件**。 - -### 7.1 推荐:独立 migrate 命令(与 Web 解耦) - ```bash cp templates/migrate/main.go cmd/migrate/main.go go build -o bin/migrate cmd/migrate/main.go - -./bin/migrate up -./bin/migrate status -./bin/migrate down -./bin/migrate up -config config.json -dir migrations +./bin/migrate up|status|down ``` +模板默认走 Factory:`MustInit` + 日志 + `Warmup(Database)` + `Migrator` + `Close`;同样可用 `buildOptions()` 注入自定义 DB/Logger。 + ```sql -- migrations/20240101000001_create_users.sql -CREATE TABLE users ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - username VARCHAR(255) NOT NULL -); -``` +CREATE TABLE users (id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(255) NOT NULL); -```sql -- migrations/20240101000001_create_users.down.sql DROP TABLE IF EXISTS users; ``` -独立 CLI 内部调用 `migration.RunMigrationsFromConfigWithCommand`,无需业务实现连接逻辑。 - -### 7.2 可选:经 Factory(开发 / 小项目) - -```go -m, err := app.Migrator("migrations") -if err != nil { - log.Fatal(err) -} -if err := m.Up(); err != nil { - log.Fatal(err) -} -``` - -### 7.3 Docker - -```yaml -command: sh -c "./bin/migrate up && ./bin/server" -volumes: - - ./config.json:/app/config.json:ro - - ./migrations:/app/migrations:ro -``` - --- -## 8. 各模块按需对接 - -### 8.1 日志 +## 8. 模块速查 ```go -log, _ := app.Logger() -defer log.Close() -log.Info("服务启动") -``` +// 日志 +app.MustLogger().Info("服务启动", nil) -### 8.2 存储 - -```go +// 存储 store, _ := app.Storage() -store.Upload(ctx, "images/a.jpg", fileReader, "image/jpeg") -url, _ := store.GetURL("images/a.jpg", 3600) -``` +_ = store.Upload(ctx, "images/a.jpg", reader, "image/jpeg") -不经 Factory 时:`storage.NewStorage(storage.StorageTypeLocal, cfg)`。 +// 邮件 / 短信(HTTP 通知用 Async;验证码用同步) +app.MustEmail().SendEmailAsync(ctx, []string{"a@b.com"}, "主题", "正文") +app.MustSMS().SendSMSAsync(ctx, []string{"13800138000"}, map[string]string{"code": "123456"}) -### 8.3 邮件 / 短信 +// Excel +app.Excel().ExportToFile("users.xlsx", "用户列表", columns, users) -```go -mail, _ := app.Email() -defer mail.Close() - -// HTTP 通知类:异步,不阻塞请求 -mail.SendEmailAsync(r.Context(), []string{"a@b.com"}, "主题", "正文") - -// 验证码等需等待结果:同步 -mail.SendEmail([]string{"a@b.com"}, "主题", "正文") - -sms, _ := app.SMS() -defer sms.Close() -sms.SendSMSAsync(r.Context(), []string{"13800138000"}, map[string]string{"code": "123456"}) -sms.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"}) -``` - -### 8.4 Excel - -```go -ex := app.Excel() -ex.ExportToFile("users.xlsx", "用户列表", columns, users) -``` - -### 8.5 国际化 - -```go -i18n, _ := app.I18n() -i18n.LoadFromDir("locales") -msg := i18n.GetMessage("zh-CN", "user.not_found") -``` - -HTTP 出参的 i18n 通过 `NewHandler(..., WithI18n(i18n))` 注入,Handler 内用消息码调用 `h.Error("user.not_found")`。 - -### 8.6 无状态工具(不经 Factory) - -```go -import "git.toowon.com/jimmy/go-common/tools" +// i18n(locales 目录;出参经 NewHandler 自动用) +app.MustI18n().GetMessage("zh-CN", "user.not_found") +// tools(不经 Factory) tools.Now() tools.MD5("text") -tools.YuanToCents(100.5) ``` --- -## 9. config.json 最小示例 +## 9. 最小 config.json ```json { @@ -389,41 +215,23 @@ tools.YuanToCents(100.5) --- -## 10. 反模式(禁止) +## 10. 反模式 -- 每个 Handler 内 `factory.Init` / `NewFromFile` -- 业务 Service 内写 HTTP JSON 响应 +- Handler 内重复 `Init` +- Service 拼 HTTP JSON / 自定义 Response - 自行 `gorm.Open` / `redis.NewClient` -- 使用 Factory 透传:`LogInfo`、`RedisSet`、`Success`、`Error`、`Now`、`MD5` 等 -- 直接调用 `http.Success(w, ...)` 包级函数(应使用 `http.Handler`) -- 在业务项目复制 GoCommon 的 Response / 中间件实现 +- Factory 透传(`LogInfo`、`Success`、`Now` 等) +- 请求路径滥用 `MustXxx` --- ## 11. 故障排除 -**无法下载模块:** - -```bash -go env -w GOPRIVATE=git.toowon.com -git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/" -go get git.toowon.com/jimmy/go-common@latest -``` - -**依赖错误:** `go clean -modcache && go mod tidy` - -**getter 报 config is nil:** 补充 `config.json` 对应段,或不调用该 getter - -**迁移找不到文件:** 检查 `-dir` 与 SQL 文件命名 - ---- - -## 12. 文档与版本 - -| 文档 | 用途 | +| 问题 | 处理 | |------|------| -| 本文档 | 业务项目对接 | -| [README.md](./README.md) | 库概述 | -| [VERSION.md](./VERSION.md) | 版本发布 | +| 无法下载模块 | 检查 `GOPRIVATE` 与 SSH insteadOf | +| 依赖异常 | `go clean -modcache && go mod tidy` | +| getter 报 config is nil | 补配置段,或不调用该模块 | +| 迁移找不到文件 | 检查 `-dir` 与 SQL 命名 | -版本升级见 [VERSION.md](./VERSION.md)。 +版本发布见 [VERSION.md](./VERSION.md)。 diff --git a/README.md b/README.md index 64d4bc5..6f073d0 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,45 @@ -# GoCommon - Go 通用工具类库 +# GoCommon -供其他 Go 项目引用的通用工具集合。业务项目对接请直接阅读: - -**[业务项目对接操作手册(INTEGRATION.md)](./INTEGRATION.md)** - -## 模块路径 +供其他 Go 项目引用的通用工具库。对接请看:**[INTEGRATION.md](./INTEGRATION.md)** ``` git.toowon.com/jimmy/go-common ``` -## 安装 - ```bash go env -w GOPRIVATE=git.toowon.com -go get git.toowon.com/jimmy/go-common@v1.0.0 +go get git.toowon.com/jimmy/go-common@v1.2.0 ``` -## 设计概要 +## 设计 -- **Factory**:入口,启动时初始化一次,按需 getter 获取模块对象(DB、Redis、Logger 等) -- **各模块包**:能力在对象方法上(`log.Info()`、`store.Upload()`) -- **http 包**:统一 HTTP 出参(Response / PageData / Handler) -- **migration**:独立 CLI 或 Factory 执行 SQL 迁移 -- **tools**:无状态工具函数,直接 import +- **Factory**:启动初始化一次,getter 取模块对象 +- **模块包**:`object.Method()`(如 `log.Info`、`store.Upload`) +- **http**:统一出参(`Handler`) +- **tools / migration**:无状态或独立 CLI,不经 Factory 透传 -## 功能模块 +## 模块 -| 模块 | 包路径 | 说明 | -|------|--------|------| -| 配置 | `config` | JSON 配置加载 | -| 工厂 | `factory` | 统一入口与 lazy getter | -| HTTP | `http` | 请求解析、统一响应 | -| 中间件 | `middleware` | CORS、日志、Recovery、限流、语种、时区 | -| 工具 | `tools` | 时间、加密、金额、类型转换 | -| 日志 | `logger` | 异步日志 | -| 存储 | `storage` | Local / OSS / MinIO | -| 邮件 / 短信 | `email` / `sms` | SMTP、阿里云短信 | -| Excel | `excel` | 数据导出 | -| 国际化 | `i18n` | 多语言消息 | -| 迁移 | `migration` | SQL 版本管理 | +| 包 | 说明 | +|----|------| +| `config` | JSON 配置 | +| `factory` | 入口与 lazy getter | +| `http` | 请求解析、统一响应 | +| `middleware` | CORS、日志、Recovery、限流、语种、时区 | +| `logger` | 异步日志 | +| `storage` | Local / OSS / MinIO | +| `email` / `sms` | SMTP、阿里云短信 | +| `excel` | 导出 | +| `i18n` | 多语言消息 | +| `migration` | SQL 迁移 | +| `tools` | 时间、加密、金额等 | ## 文档 | 文档 | 说明 | |------|------| -| [INTEGRATION.md](./INTEGRATION.md) | 业务项目对接操作手册 | -| [VERSION.md](./VERSION.md) | 版本管理与发布 | -| [templates/](./templates/) | migrate 等脚手架模板 | -| [config/example.json](./config/example.json) | 配置文件示例 | -| [examples/](./examples/) | 代码示例 | - -## 许可证 - -MIT License +| [INTEGRATION.md](./INTEGRATION.md) | 对接手册 | +| [VERSION.md](./VERSION.md) | 版本与发布 | +| [templates/](./templates/) | server / migrate 等脚手架 | +| [config/example.json](./config/example.json) | 配置示例 | +| [examples/](./examples/) | 示例代码 | diff --git a/VERSION.md b/VERSION.md index f3756ee..eb04a1a 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,136 +1,33 @@ -# 版本管理说明 +# 版本管理 -## 版本号规则 +遵循 [语义化版本](https://semver.org/lang/zh-CN/):`v主.次.修订`。 -本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范: - -- **主版本号(MAJOR)**:当你做了不兼容的 API 修改 -- **次版本号(MINOR)**:当你做了向下兼容的功能性新增 -- **修订号(PATCH)**:当你做了向下兼容的问题修正 - -版本格式:`v主版本号.次版本号.修订号`,例如:`v1.0.0`、`v1.1.0`、`v2.0.0` - -## 发布新版本 - -### 方式1:使用发布脚本(推荐) +## 发布 ```bash -# 使用发布脚本(会自动验证版本格式、检查未提交更改等) -./scripts/release.sh v1.0.0 "Release version 1.0.0" +# 推荐:脚本校验并打标签 +./scripts/release.sh v1.2.0 "Release version 1.2.0" + +# 或手动 +git tag -a v1.2.0 -m "Release version 1.2.0" +git push origin v1.2.0 ``` -### 方式2:手动创建标签 +发布前:代码已提交;同步更新本文档与 `README.md` 中的版本号。 + +## 消费方引用 ```bash -# 1. 确保所有更改已提交 -git add . -git commit -m "Prepare for release v1.0.0" - -# 2. 创建版本标签 -git tag -a v1.0.0 -m "Release version 1.0.0" - -# 3. 推送标签到远程仓库 -git push origin v1.0.0 - -# 或者一次性推送所有标签 -git push origin --tags +go get git.toowon.com/jimmy/go-common@v1.2.0 # 生产固定版本 +go get git.toowon.com/jimmy/go-common@latest # 开发 ``` -### 验证标签 - -```bash -# 查看所有标签 -git tag -l - -# 查看标签详情 -git show v1.0.0 - -# 查看标签列表(带注释) -git tag -l -n -``` - -## 调用方如何使用版本 - -### 方式1:使用最新版本(推荐用于开发) - -```bash -go get git.toowon.com/jimmy/go-common@latest -``` - -### 方式2:使用特定版本(推荐用于生产) - -```bash -# 使用具体版本号 -go get git.toowon.com/jimmy/go-common@v1.0.0 - -# 使用版本范围(自动选择最新版本) -go get git.toowon.com/jimmy/go-common@v1.0 -``` - -### 方式3:在 go.mod 中指定版本 - -```go -module your-project - -require ( - git.toowon.com/jimmy/go-common v1.0.0 -) -``` - -然后运行: -```bash -go mod tidy -``` - -### 方式4:更新到最新版本 - -```bash -# 更新到最新版本 -go get -u git.toowon.com/jimmy/go-common@latest - -# 更新到最新补丁版本(如从 v1.0.0 更新到 v1.0.1) -go get -u=patch git.toowon.com/jimmy/go-common - -# 更新到最新次版本(如从 v1.0.0 更新到 v1.1.0) -go get -u=minor git.toowon.com/jimmy/go-common -``` - -## 版本发布流程 - -1. **开发完成**:确保所有功能已实现并通过测试 -2. **更新版本号**:在 `README.md` 和 `VERSION.md` 中更新版本号 -3. **提交代码**:提交所有更改到 Git - ```bash - git add . - git commit -m "Prepare for release v1.0.0" - ``` -4. **创建并推送标签**: - ```bash - # 使用脚本(推荐) - ./scripts/release.sh v1.0.0 "Release version 1.0.0" - - # 或手动创建 - git tag -a v1.0.0 -m "Release version 1.0.0" - git push origin v1.0.0 - ``` -5. **验证**:在其他项目中测试是否能正确获取该版本 - ```bash - # 在新项目中测试 - go get git.toowon.com/jimmy/go-common@v1.0.0 - ``` - ## 当前版本 -当前版本:**v1.0.0** +**v1.2.0** ## 版本历史 -- **v1.0.0** (当前版本) - - 初始发布 - - Factory:`Init` / `Default()` + lazy getter(`Logger`、`Database`、`Redis`、`Storage` 等) - - HTTP 出参统一由 `http.Handler` 负责 - - Logger:`Debug/Info/Error(msg, fields)`,Request ID + `FromContext`,异步默认开启 - - email / sms:异步队列 + `Close()` - - 中间件链:Recovery → RequestID → Logging → RateLimit → CORS → Language → Timezone - - 模块:migration、tools、middleware、config、storage(Local/MinIO/OSS)、email、sms、excel、i18n - +- **v1.2.0** — DX:`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`;Option 注入;Excel 每次新建;`templates/server`;`ErrorData`;文档精简 +- **v1.1.0** — 同上 DX 能力首次合入(见上) +- **v1.0.0** — 初始:Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel diff --git a/examples/http_handler_example.go b/examples/http_handler_example.go index 7882d87..4a1f6eb 100644 --- a/examples/http_handler_example.go +++ b/examples/http_handler_example.go @@ -30,8 +30,7 @@ func main() { func listUsers(w http.ResponseWriter, r *http.Request) { app := factory.Default() - i18n, _ := app.I18n() - h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n)) + h := app.NewHandler(w, r) var req struct { Keyword string `json:"keyword"` @@ -42,11 +41,9 @@ func listUsers(w http.ResponseWriter, r *http.Request) { return } - p := h.Pagination() users := []User{ {ID: 1, Name: "User1", Email: "user1@example.com"}, {ID: 2, Name: "User2", Email: "user2@example.com"}, } h.SuccessPage(users, 100) - _ = p } diff --git a/examples/locales/en-US.json b/examples/locales/en-US.json index 7c98dcf..e9bf0df 100644 --- a/examples/locales/en-US.json +++ b/examples/locales/en-US.json @@ -1,38 +1,18 @@ { + "common.success": { + "code": 0, + "message": "success" + }, + "common.invalid_request": { + "code": 100001, + "message": "Invalid request" + }, "user.not_found": { "code": 100002, "message": "User not found" }, - "user.login_success": { - "code": 0, - "message": "Login successful" - }, - "user.welcome": { - "code": 0, - "message": "Welcome, %s" - }, - "user.logout": { - "code": 0, - "message": "Logout" - }, - "error.invalid_params": { - "code": 100001, - "message": "Invalid parameters" - }, - "error.server_error": { + "system.internal_error": { "code": 100003, - "message": "Server error" - }, - "order.created": { - "code": 0, - "message": "Order created successfully" - }, - "order.paid": { - "code": 0, - "message": "Order paid successfully" - }, - "message.count": { - "code": 0, - "message": "You have %d new messages" + "message": "Internal server error" } } diff --git a/examples/locales/zh-CN.json b/examples/locales/zh-CN.json index 0070530..26939d1 100644 --- a/examples/locales/zh-CN.json +++ b/examples/locales/zh-CN.json @@ -1,38 +1,18 @@ { + "common.success": { + "code": 0, + "message": "成功" + }, + "common.invalid_request": { + "code": 100001, + "message": "请求无效" + }, "user.not_found": { "code": 100002, "message": "用户不存在" }, - "user.login_success": { - "code": 0, - "message": "登录成功" - }, - "user.welcome": { - "code": 0, - "message": "欢迎,%s" - }, - "user.logout": { - "code": 0, - "message": "退出登录" - }, - "error.invalid_params": { - "code": 100001, - "message": "参数无效" - }, - "error.server_error": { + "system.internal_error": { "code": 100003, "message": "服务器错误" - }, - "order.created": { - "code": 0, - "message": "订单创建成功" - }, - "order.paid": { - "code": 0, - "message": "订单支付成功" - }, - "message.count": { - "code": 0, - "message": "您有 %d 条新消息" } } diff --git a/excel/excel.go b/excel/excel.go index cce15a4..22897be 100644 --- a/excel/excel.go +++ b/excel/excel.go @@ -87,9 +87,12 @@ type ExportData interface { // // excel.ExportToWriter(w, "用户列表", columns, users) func (e *Excel) ExportToWriter(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error { - if e.file == nil { - e.file = excelize.NewFile() - } + // 每次导出使用新文件,避免同实例多次/并发导出互相污染 + e.file = excelize.NewFile() + defer func() { + _ = e.file.Close() + e.file = nil + }() // 设置工作表名称 if sheetName == "" { diff --git a/excel/excel_test.go b/excel/excel_test.go new file mode 100644 index 0000000..2f4b68c --- /dev/null +++ b/excel/excel_test.go @@ -0,0 +1,35 @@ +package excel + +import ( + "bytes" + "testing" +) + +type row struct { + ID int + Name string +} + +func TestExportToWriterResetsFile(t *testing.T) { + ex := NewExcel() + cols := []ExportColumn{ + {Header: "ID", Field: "ID"}, + {Header: "Name", Field: "Name"}, + } + data := []row{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}} + + var buf1, buf2 bytes.Buffer + if err := ex.ExportToWriter(&buf1, "S1", cols, data); err != nil { + t.Fatal(err) + } + if err := ex.ExportToWriter(&buf2, "S1", cols, data); err != nil { + t.Fatal(err) + } + if buf1.Len() == 0 || buf2.Len() == 0 { + t.Fatal("expected non-empty xlsx output") + } + // 导出后不应残留内部文件句柄 + if ex.file != nil { + t.Fatal("internal file should be cleared after ExportToWriter") + } +} diff --git a/factory/factory.go b/factory/factory.go index 8e13af5..3ef9be8 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -10,6 +10,7 @@ import ( "git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/excel" + commonhttp "git.toowon.com/jimmy/go-common/http" "git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/logger" "git.toowon.com/jimmy/go-common/middleware" @@ -38,7 +39,6 @@ type Factory struct { db *gorm.DB redis *redis.Client i18n *i18n.I18n - excel *excel.Excel chain *middleware.Chain mu sync.Mutex @@ -47,13 +47,6 @@ type Factory struct { // Option Factory 可选项(支持重载模块实现) type Option func(*Factory) -// WithStorage 注入自定义存储实现 -func WithStorage(s storage.Storage) Option { - return func(f *Factory) { - f.storage = s - } -} - // Init 从配置文件初始化全局 Factory(启动时调用一次) func Init(filePath string, opts ...Option) error { cfg, err := config.LoadFromFile(filePath) @@ -86,6 +79,15 @@ func (f *Factory) Config() *config.Config { return f.cfg } +// NewHandler 创建 HTTP 出参处理器,并自动注入已配置的 i18n(若可用) +// 能力仍在 *http.Handler 上(Success / Error),Factory 不透传出参方法。 +func (f *Factory) NewHandler(w http.ResponseWriter, r *http.Request, opts ...commonhttp.HandlerOption) *commonhttp.Handler { + if i18nInst, err := f.getI18n(); err == nil && i18nInst != nil { + opts = append([]commonhttp.HandlerOption{commonhttp.WithI18n(i18nInst)}, opts...) + } + return commonhttp.NewHandler(w, r, opts...) +} + func (f *Factory) getLogger() (*logger.Logger, error) { if f.logger != nil { return f.logger, nil @@ -107,7 +109,7 @@ func (f *Factory) getLogger() (*logger.Logger, error) { return l, nil } -// Logger 获取日志对象 +// Logger 获取日志对象(无 logger 配置时使用默认配置) func (f *Factory) Logger() (*logger.Logger, error) { return f.getLogger() } @@ -312,21 +314,9 @@ func (f *Factory) I18n() (*i18n.I18n, error) { return f.getI18n() } -func (f *Factory) getExcel() *excel.Excel { - if f.excel != nil { - return f.excel - } - f.mu.Lock() - defer f.mu.Unlock() - if f.excel == nil { - f.excel = excel.NewExcel() - } - return f.excel -} - -// Excel 获取 Excel 导出器 +// Excel 创建 Excel 导出器(每次新建,避免共享可变文件状态) func (f *Factory) Excel() *excel.Excel { - return f.getExcel() + return excel.NewExcel() } // MiddlewareChain 获取默认中间件链 @@ -341,8 +331,11 @@ func (f *Factory) MiddlewareChain() *middleware.Chain { } var mws []func(http.Handler) http.Handler - l, _ := f.getLogger() - i18nInst, _ := f.getI18n() + + // logger 无配置时有默认实现;失败则降级为 nil(Recovery/Logging 可处理) + l, _ := f.getLoggerUnlocked() + // i18n 为可选:未配置时 Recovery 使用默认文案 + i18nInst, _ := f.getI18nUnlocked() mws = append(mws, middleware.Recovery(&middleware.RecoveryConfig{ Logger: l, @@ -384,6 +377,41 @@ func (f *Factory) MiddlewareChain() *middleware.Chain { return f.chain } +// getLoggerUnlocked 在已持有 f.mu 时初始化 logger(供 MiddlewareChain 使用) +func (f *Factory) getLoggerUnlocked() (*logger.Logger, error) { + if f.logger != nil { + return f.logger, nil + } + var cfg *config.LoggerConfig + if f.cfg != nil { + cfg = f.cfg.Logger + } + l, err := logger.NewLogger(cfg) + if err != nil { + return nil, err + } + f.logger = l + return l, nil +} + +// getI18nUnlocked 在已持有 f.mu 时初始化 i18n(供 MiddlewareChain 使用) +func (f *Factory) getI18nUnlocked() (*i18n.I18n, error) { + if f.i18n != nil { + return f.i18n, nil + } + if f.cfg == nil || f.cfg.I18n == nil { + return nil, fmt.Errorf("i18n config is nil") + } + i := i18n.NewI18n(f.cfg.I18n.DefaultLang) + if f.cfg.I18n.LocalesDir != "" { + if err := i.LoadFromDir(f.cfg.I18n.LocalesDir); err != nil { + return nil, err + } + } + f.i18n = i + return i, nil +} + // Migrator 创建迁移器并加载指定目录下的 SQL 文件 func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) { db, err := f.getDatabase() diff --git a/factory/factory_test.go b/factory/factory_test.go new file mode 100644 index 0000000..bbac0a0 --- /dev/null +++ b/factory/factory_test.go @@ -0,0 +1,121 @@ +package factory + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.toowon.com/jimmy/go-common/config" + "git.toowon.com/jimmy/go-common/i18n" + "git.toowon.com/jimmy/go-common/logger" +) + +func TestExcelReturnsFreshInstance(t *testing.T) { + app := New(nil) + a := app.Excel() + b := app.Excel() + if a == nil || b == nil { + t.Fatal("Excel() returned nil") + } + if a == b { + t.Fatal("Excel() should return a new instance each call") + } +} + +func TestWithLoggerOption(t *testing.T) { + custom, err := logger.NewLogger(&config.LoggerConfig{ + Level: "error", + Output: "stdout", + Async: config.BoolPtr(false), + }) + if err != nil { + t.Fatal(err) + } + app := New(nil, WithLogger(custom)) + got, err := app.Logger() + if err != nil { + t.Fatal(err) + } + if got != custom { + t.Fatal("WithLogger should inject the provided logger") + } +} + +func TestWithI18nAndNewHandler(t *testing.T) { + i := i18n.NewI18n("zh-CN") + i.LoadFromMap("zh-CN", map[string]i18n.MessageInfo{ + "common.success": {Code: 0, Message: "成功"}, + }) + app := New(nil, WithI18n(i)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + h := app.NewHandler(rec, req) + h.Success(map[string]string{"ok": "1"}) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if body == "" || !strings.Contains(body, "成功") { + t.Fatalf("response body missing i18n message: %s", body) + } +} + +func TestNewHandlerWithoutI18n(t *testing.T) { + app := New(nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h := app.NewHandler(rec, req) + h.Success(nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestWarmupLogger(t *testing.T) { + app := New(nil) + if err := app.Warmup(ModuleLogger); err != nil { + t.Fatal(err) + } +} + +func TestWarmupMissingDatabase(t *testing.T) { + app := New(nil) + err := app.Warmup(ModuleDatabase) + if err == nil { + t.Fatal("expected database warmup to fail") + } +} + +func TestWarmupUnknownModule(t *testing.T) { + app := New(nil) + err := app.Warmup(Module("unknown")) + if err == nil { + t.Fatal("expected unknown module error") + } +} + +func TestCloseLogger(t *testing.T) { + app := New(nil) + if _, err := app.Logger(); err != nil { + t.Fatal(err) + } + if err := app.Close(); err != nil { + t.Fatal(err) + } + // Close 后再取 logger 应可重新创建 + if _, err := app.Logger(); err != nil { + t.Fatal(err) + } + _ = app.Close() +} + +func TestMustLogger(t *testing.T) { + app := New(nil) + l := app.MustLogger() + if l == nil { + t.Fatal("MustLogger returned nil") + } +} diff --git a/factory/lifecycle.go b/factory/lifecycle.go new file mode 100644 index 0000000..5a86424 --- /dev/null +++ b/factory/lifecycle.go @@ -0,0 +1,181 @@ +package factory + +import ( + "errors" + "fmt" + + "git.toowon.com/jimmy/go-common/email" + "git.toowon.com/jimmy/go-common/i18n" + "git.toowon.com/jimmy/go-common/logger" + "git.toowon.com/jimmy/go-common/sms" + "git.toowon.com/jimmy/go-common/storage" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// Module 可预热的模块标识(用于 Warmup) +type Module string + +const ( + ModuleLogger Module = "logger" + ModuleDatabase Module = "database" + ModuleRedis Module = "redis" + ModuleStorage Module = "storage" + ModuleEmail Module = "email" + ModuleSMS Module = "sms" + ModuleI18n Module = "i18n" +) + +// MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动) +func MustInit(filePath string, opts ...Option) *Factory { + if err := Init(filePath, opts...); err != nil { + panic(err) + } + return Default() +} + +// Warmup 按需预热模块:配置缺失或连接失败时立即返回错误(适合启动期 fail-fast) +func (f *Factory) Warmup(modules ...Module) error { + if f == nil { + return fmt.Errorf("factory is nil") + } + for _, m := range modules { + var err error + switch m { + case ModuleLogger: + _, err = f.Logger() + case ModuleDatabase: + _, err = f.Database() + case ModuleRedis: + _, err = f.Redis() + case ModuleStorage: + _, err = f.Storage() + case ModuleEmail: + _, err = f.Email() + case ModuleSMS: + _, err = f.SMS() + case ModuleI18n: + _, err = f.I18n() + default: + return fmt.Errorf("unknown module: %s", m) + } + if err != nil { + return fmt.Errorf("warmup %s: %w", m, err) + } + } + return nil +} + +// Close 关闭已创建的有状态模块(email / sms / redis / database / logger) +// 可重复调用;未初始化的模块会被跳过。 +func (f *Factory) Close() error { + if f == nil { + return nil + } + + f.mu.Lock() + defer f.mu.Unlock() + + var errs []error + + if f.email != nil { + if err := f.email.Close(); err != nil { + errs = append(errs, fmt.Errorf("email: %w", err)) + } + f.email = nil + } + if f.sms != nil { + if err := f.sms.Close(); err != nil { + errs = append(errs, fmt.Errorf("sms: %w", err)) + } + f.sms = nil + } + if f.redis != nil { + if err := f.redis.Close(); err != nil { + errs = append(errs, fmt.Errorf("redis: %w", err)) + } + f.redis = nil + } + if f.db != nil { + if sqlDB, err := f.db.DB(); err != nil { + errs = append(errs, fmt.Errorf("database: %w", err)) + } else if err := sqlDB.Close(); err != nil { + errs = append(errs, fmt.Errorf("database: %w", err)) + } + f.db = nil + } + if f.logger != nil { + if err := f.logger.Close(); err != nil { + errs = append(errs, fmt.Errorf("logger: %w", err)) + } + f.logger = nil + } + + // 中间件链依赖 logger/i18n,关闭后允许下次重新组装 + f.chain = nil + + return errors.Join(errs...) +} + +// MustLogger 获取日志对象;失败则 panic(适合启动期注入) +func (f *Factory) MustLogger() *logger.Logger { + l, err := f.Logger() + if err != nil { + panic(err) + } + return l +} + +// MustDatabase 获取数据库连接;失败则 panic(适合启动期注入) +func (f *Factory) MustDatabase() *gorm.DB { + db, err := f.Database() + if err != nil { + panic(err) + } + return db +} + +// MustRedis 获取 Redis 客户端;失败则 panic(适合启动期注入) +func (f *Factory) MustRedis() *redis.Client { + c, err := f.Redis() + if err != nil { + panic(err) + } + return c +} + +// MustStorage 获取存储对象;失败则 panic(适合启动期注入) +func (f *Factory) MustStorage() storage.Storage { + s, err := f.Storage() + if err != nil { + panic(err) + } + return s +} + +// MustEmail 获取邮件客户端;失败则 panic(适合启动期注入) +func (f *Factory) MustEmail() *email.Email { + e, err := f.Email() + if err != nil { + panic(err) + } + return e +} + +// MustSMS 获取短信客户端;失败则 panic(适合启动期注入) +func (f *Factory) MustSMS() *sms.SMS { + s, err := f.SMS() + if err != nil { + panic(err) + } + return s +} + +// MustI18n 获取国际化对象;失败则 panic(适合启动期注入) +func (f *Factory) MustI18n() *i18n.I18n { + i, err := f.I18n() + if err != nil { + panic(err) + } + return i +} diff --git a/factory/options.go b/factory/options.go new file mode 100644 index 0000000..15fe979 --- /dev/null +++ b/factory/options.go @@ -0,0 +1,68 @@ +package factory + +import ( + "git.toowon.com/jimmy/go-common/email" + "git.toowon.com/jimmy/go-common/i18n" + "git.toowon.com/jimmy/go-common/logger" + "git.toowon.com/jimmy/go-common/middleware" + "git.toowon.com/jimmy/go-common/sms" + "git.toowon.com/jimmy/go-common/storage" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// WithStorage 注入自定义存储实现 +func WithStorage(s storage.Storage) Option { + return func(f *Factory) { + f.storage = s + } +} + +// WithLogger 注入自定义日志对象 +func WithLogger(l *logger.Logger) Option { + return func(f *Factory) { + f.logger = l + } +} + +// WithDatabase 注入自定义数据库连接 +func WithDatabase(db *gorm.DB) Option { + return func(f *Factory) { + f.db = db + } +} + +// WithRedis 注入自定义 Redis 客户端 +func WithRedis(c *redis.Client) Option { + return func(f *Factory) { + f.redis = c + } +} + +// WithEmail 注入自定义邮件客户端 +func WithEmail(e *email.Email) Option { + return func(f *Factory) { + f.email = e + } +} + +// WithSMS 注入自定义短信客户端 +func WithSMS(s *sms.SMS) Option { + return func(f *Factory) { + f.sms = s + } +} + +// WithI18n 注入自定义国际化对象 +func WithI18n(i *i18n.I18n) Option { + return func(f *Factory) { + f.i18n = i + } +} + +// WithMiddlewareChain 注入自定义中间件链 +func WithMiddlewareChain(c *middleware.Chain) Option { + return func(f *Factory) { + f.chain = c + } +} diff --git a/http/handler.go b/http/handler.go index 69f6a18..229a338 100644 --- a/http/handler.go +++ b/http/handler.go @@ -82,8 +82,13 @@ func (h *Handler) SuccessPage(list interface{}, total int64) { h.Success(pageData) } -// Error 失败响应(messageCode 为 i18n 消息码) +// Error 失败响应(messageCode 为 i18n 消息码,data 为 null) func (h *Handler) Error(messageCode string, args ...interface{}) { + h.ErrorData(messageCode, nil, args...) +} + +// ErrorData 失败响应并附带 data +func (h *Handler) ErrorData(messageCode string, data interface{}, args ...interface{}) { code := 0 message := messageCode if h.i18n != nil { @@ -91,5 +96,5 @@ func (h *Handler) Error(messageCode string, args ...interface{}) { code = info.Code message = info.Message } - writeResponse(h.w, code, message, nil) + writeResponse(h.w, code, message, data) } diff --git a/templates/README.md b/templates/README.md index 880dc59..f87fdb4 100644 --- a/templates/README.md +++ b/templates/README.md @@ -1,40 +1,17 @@ -# 模板文件 +# 模板 -这个目录包含了可以直接复制到你项目中使用的模板文件。 +复制到业务项目后,按注释中的挂载点扩展即可。 -## 包含的模板 - -- `migrate/main.go` - 数据库迁移工具模板 ⭐ -- `Dockerfile.example` - Docker 构建示例 -- `docker-compose.example.yml` - Docker Compose 示例 -- `Makefile.example` - Makefile 常用命令示例 - -## 快速使用 - -### 迁移工具模板 +| 文件 | 默认已接好 | 主要改哪里 | +|------|------------|------------| +| `server/main.go` | MustInit、日志、Warmup、默认中间件链、NewHandler、Close;`-config`/`-addr` | `buildOptions` / `setupMiddleware` / `registerRoutes` | +| `migrate/main.go` | MustInit、日志、Database Warmup、Migrator、Close;`-config`/`-dir` | `buildOptions`;SQL 放 `migrations/` | +| `Dockerfile.example` 等 | 镜像 / Compose / Makefile 示例 | 按部署环境改 | ```bash -# 1. 复制到你的项目 -mkdir -p cmd/migrate -cp templates/migrate/main.go cmd/migrate/ - -# 2. 编译 -go build -o bin/migrate cmd/migrate/main.go - -# 3. 使用 -./bin/migrate up -./bin/migrate -help +mkdir -p cmd/server cmd/migrate +cp templates/server/main.go cmd/server/main.go +cp templates/migrate/main.go cmd/migrate/main.go ``` -### Docker 模板 - -```bash -# 复制到你的项目根目录 -cp templates/Dockerfile.example Dockerfile -cp templates/docker-compose.example.yml docker-compose.yml -cp templates/Makefile.example Makefile -``` - -## 完整文档 - -详细使用说明请查看:[INTEGRATION.md](../INTEGRATION.md) 第 7 节「数据库迁移」 +对接说明:[INTEGRATION.md](../INTEGRATION.md)。 diff --git a/templates/migrate/main.go b/templates/migrate/main.go index ad2ce80..2562a52 100644 --- a/templates/migrate/main.go +++ b/templates/migrate/main.go @@ -5,145 +5,153 @@ import ( "fmt" "os" + "git.toowon.com/jimmy/go-common/factory" "git.toowon.com/jimmy/go-common/migration" ) -// 数据库迁移工具(黑盒模式) +// 数据库迁移 CLI 脚手架。 // -// 工作原理: -// 此工具调用 migration.RunMigrationsFromConfigWithCommand() 方法, -// 内部自动处理配置加载、数据库连接、迁移执行等所有细节。 -// 你只需要提供配置文件和SQL迁移文件即可。 +// mkdir -p cmd/migrate +// cp templates/migrate/main.go cmd/migrate/main.go +// go build -o bin/migrate cmd/migrate/main.go +// ./bin/migrate up|status|down // -// 使用方式: -// 基本用法: -// ./migrate up # 使用默认配置 -// ./migrate up -config /path/to/config.json # 指定配置文件 -// ./migrate up -config config.json -dir db/migrations # 指定配置和迁移目录 -// ./migrate status # 查看迁移状态 -// ./migrate down # 回滚最后一个迁移 -// -// Docker 中使用: -// # 方式1:挂载配置文件(推荐) -// docker run -v /host/config.json:/app/config.json myapp ./migrate up -// -// # 方式2:使用环境变量指定配置文件路径 -// docker run -e CONFIG_FILE=/etc/app/config.json myapp ./migrate up -// -// # 方式3:指定容器内的配置文件路径 -// docker run myapp ./migrate up -config /etc/app/config.json -// -// 支持的命令: -// up - 执行所有待执行的迁移 -// down - 回滚最后一个迁移 -// status - 查看迁移状态 -// -// 配置优先级(从高到低): -// 1. 命令行参数 -config 和 -dir -// 2. 环境变量 CONFIG_FILE 和 MIGRATIONS_DIR -// 3. 默认值(config.json 和 migrations) - -var ( - configFile string - migrationsDir string - showHelp bool -) - -func init() { - flag.StringVar(&configFile, "config", "", "配置文件路径(默认:config.json 或环境变量 CONFIG_FILE)") - flag.StringVar(&configFile, "c", "", "配置文件路径(简写)") - flag.StringVar(&migrationsDir, "dir", "", "迁移文件目录(默认:migrations 或环境变量 MIGRATIONS_DIR)") - flag.StringVar(&migrationsDir, "d", "", "迁移文件目录(简写)") - flag.BoolVar(&showHelp, "help", false, "显示帮助信息") - flag.BoolVar(&showHelp, "h", false, "显示帮助信息(简写)") -} +// 默认已接好:Factory 初始化、日志、Database Warmup、Migrator、Close 收口。 +// 配置优先级:-config/-dir > 环境变量 CONFIG_FILE/MIGRATIONS_DIR > 默认值。 func main() { + var ( + configFile string + migrationsDir string + showHelp bool + ) + + flag.StringVar(&configFile, "config", "", "配置文件路径(默认 config.json)") + flag.StringVar(&configFile, "c", "", "配置文件路径(简写)") + flag.StringVar(&migrationsDir, "dir", "", "迁移目录(默认 migrations)") + flag.StringVar(&migrationsDir, "d", "", "迁移目录(简写)") + flag.BoolVar(&showHelp, "help", false, "显示帮助") + flag.BoolVar(&showHelp, "h", false, "显示帮助(简写)") flag.Parse() - // 显示帮助 if showHelp { printHelp() - os.Exit(0) + return } - // 获取命令(默认up) - // 支持两种方式: - // 1. 位置参数:./migrate up - // 2. 标志参数:./migrate -cmd=up(向后兼容) command := "up" - args := flag.Args() - if len(args) > 0 { + if args := flag.Args(); len(args) > 0 { command = args[0] } - - // 验证命令 if command != "up" && command != "down" && command != "status" { - fmt.Fprintf(os.Stderr, "错误:未知命令 '%s'\n\n", command) + fmt.Fprintf(os.Stderr, "未知命令: %s\n\n", command) printHelp() os.Exit(1) } - // 获取配置文件路径(优先级:命令行 > 环境变量 > 默认值) - // 如果未指定,RunMigrationsFromConfigWithCommand 会自动查找 if configFile == "" { - configFile = getEnv("CONFIG_FILE", "") + configFile = envOr("CONFIG_FILE", "config.json") } - - // 获取迁移目录(优先级:命令行 > 环境变量 > 默认值) - // 如果未指定,RunMigrationsFromConfigWithCommand 会使用默认值 "migrations" if migrationsDir == "" { - migrationsDir = getEnv("MIGRATIONS_DIR", "") + migrationsDir = envOr("MIGRATIONS_DIR", "migrations") } - // 执行迁移(黑盒模式:内部自动处理所有细节) - if err := migration.RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command); err != nil { - fmt.Fprintf(os.Stderr, "错误: %v\n", err) + app := factory.MustInit(configFile, buildOptions()...) + defer app.Close() + + log := app.MustLogger() + + if err := app.Warmup(factory.ModuleLogger, factory.ModuleDatabase); err != nil { + log.Error("warmup failed", map[string]any{"error": err.Error()}) os.Exit(1) } + + m, err := app.Migrator(migrationsDir) + if err != nil { + log.Error("create migrator failed", map[string]any{"error": err.Error()}) + os.Exit(1) + } + + switch command { + case "up": + if err := m.Up(); err != nil { + log.Error("migrate up failed", map[string]any{"error": err.Error()}) + os.Exit(1) + } + log.Info("migrate up ok", map[string]any{"dir": migrationsDir}) + fmt.Println("✓ 迁移执行成功") + + case "down": + if err := m.Down(); err != nil { + log.Error("migrate down failed", map[string]any{"error": err.Error()}) + os.Exit(1) + } + log.Info("migrate down ok", map[string]any{"dir": migrationsDir}) + fmt.Println("✓ 迁移回滚成功") + + case "status": + status, err := m.Status() + if err != nil { + log.Error("migrate status failed", map[string]any{"error": err.Error()}) + os.Exit(1) + } + printStatus(status) + } } -func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value +// buildOptions 组装 Factory Option(自定义 DB / Logger 等在此注入) +func buildOptions() []factory.Option { + // 默认走 config.json。需要替换时例如: + // + // return []factory.Option{ + // factory.WithLogger(customLogger), + // factory.WithDatabase(db), + // } + return nil +} + +func printStatus(status []migration.MigrationStatus) { + if len(status) == 0 { + fmt.Println("没有找到迁移") + return } - return defaultValue + fmt.Println("\n迁移状态:") + fmt.Printf("%-20s %-40s %-10s\n", "版本", "描述", "状态") + for _, s := range status { + st := "待执行" + if s.Applied { + st = "已应用" + } + fmt.Printf("%-20s %-40s %-10s\n", s.Version, s.Description, st) + } + fmt.Println() +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback } func printHelp() { - fmt.Println("数据库迁移工具") - fmt.Println() - fmt.Println("用法:") - fmt.Println(" migrate [命令] [选项]") - fmt.Println() - fmt.Println("命令:") - fmt.Println(" up 执行所有待执行的迁移(默认)") - fmt.Println(" down 回滚最后一个迁移") - fmt.Println(" status 查看迁移状态") - fmt.Println() - fmt.Println("选项:") - fmt.Println(" -config, -c 配置文件路径(默认: config.json)") - fmt.Println(" -dir, -d 迁移文件目录(默认: migrations)") - fmt.Println(" -help, -h 显示帮助信息") - fmt.Println() - fmt.Println("示例:") - fmt.Println(" # 使用默认配置") - fmt.Println(" migrate up") - fmt.Println() - fmt.Println(" # 指定配置文件") - fmt.Println(" migrate up -config /etc/app/config.json") - fmt.Println() - fmt.Println(" # 指定配置和迁移目录") - fmt.Println(" migrate up -c config.json -d db/migrations") - fmt.Println() - fmt.Println(" # 使用环境变量指定配置文件路径") - fmt.Println(" CONFIG_FILE=/etc/app/config.json migrate up") - fmt.Println() - fmt.Println(" # Docker 中使用(挂载配置文件)") - fmt.Println(" docker run -v /host/config.json:/app/config.json myapp migrate up") - fmt.Println() - fmt.Println("配置优先级(从高到低):") - fmt.Println(" 1. 命令行参数 -config 和 -dir") - fmt.Println(" 2. 环境变量 CONFIG_FILE 和 MIGRATIONS_DIR") - fmt.Println(" 3. 默认值(config.json 和 migrations)") + fmt.Println(`数据库迁移工具 + +用法: + migrate [命令] [选项] + +命令: + up 执行待执行迁移(默认) + down 回滚最后一个迁移 + status 查看状态 + +选项: + -config, -c 配置文件(默认: config.json,可用 CONFIG_FILE) + -dir, -d 迁移目录(默认: migrations,可用 MIGRATIONS_DIR) + -help, -h 帮助 + +示例: + migrate up + migrate up -config /etc/app/config.json -dir db/migrations + CONFIG_FILE=config.json migrate status`) } diff --git a/templates/server/main.go b/templates/server/main.go new file mode 100644 index 0000000..cb60f9a --- /dev/null +++ b/templates/server/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "flag" + "net/http" + "os" + + "git.toowon.com/jimmy/go-common/factory" + "git.toowon.com/jimmy/go-common/middleware" +) + +// 业务 HTTP 服务脚手架。 +// +// mkdir -p cmd/server +// cp templates/server/main.go cmd/server/main.go +// +// 默认已接好:Factory 初始化、日志、默认中间件链、NewHandler 出参、Close 收口。 +// 按需改:buildOptions / setupMiddleware / registerRoutes / Warmup 模块列表。 + +func main() { + configPath := flag.String("config", envOr("CONFIG_FILE", "config.json"), "配置文件路径") + addr := flag.String("addr", envOr("HTTP_ADDR", ":8080"), "监听地址") + flag.Parse() + + app := factory.MustInit(*configPath, buildOptions()...) + defer app.Close() + + log := app.MustLogger() + + // 启动期预热:按业务实际使用的模块增删(未配置的模块 Warmup 会失败) + if err := app.Warmup( + factory.ModuleLogger, + // factory.ModuleI18n, + // factory.ModuleDatabase, + // factory.ModuleRedis, + ); err != nil { + log.Error("warmup failed", map[string]any{"error": err.Error()}) + os.Exit(1) + } + + chain := setupMiddleware(app) + mux := http.NewServeMux() + registerRoutes(mux, chain, app) + + log.Info("server starting", map[string]any{"addr": *addr, "config": *configPath}) + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Error("server stopped", map[string]any{"error": err.Error()}) + os.Exit(1) + } +} + +// buildOptions 组装 Factory Option(自定义实现 / 测试替身在此注入) +func buildOptions() []factory.Option { + // 默认不注入,全部走 config.json lazy 初始化。 + // 需要替换实现时取消注释,例如: + // + // return []factory.Option{ + // factory.WithLogger(customLogger), + // factory.WithStorage(customStorage), + // factory.WithDatabase(db), + // factory.WithRedis(rds), + // factory.WithI18n(i18nInst), + // factory.WithMiddlewareChain(customChain), + // } + return nil +} + +// setupMiddleware 基于 Factory 默认链追加业务中间件 +// 默认链:Recovery → RequestID → Logging → RateLimit? → CORS? → Language → Timezone +func setupMiddleware(app *factory.Factory) *middleware.Chain { + chain := app.MiddlewareChain() + + // 业务鉴权等自行挂载(本库不提供登录体系) + // chain.Append(authMiddleware) + // chain.Append(permissionMiddleware) + + return chain +} + +// registerRoutes 注册路由;出参统一用 app.NewHandler +func registerRoutes(mux *http.ServeMux, chain *middleware.Chain, app *factory.Factory) { + mux.Handle("/api/ping", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { + h := app.NewHandler(w, r) + h.Success(map[string]string{"status": "ok"}) + })) + + // 示例:业务路由 + // mux.Handle("/api/users", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { + // h := app.NewHandler(w, r) + // // ... + // h.Success(nil) + // })) +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +}