From 94a251ec44c56848f287d6a62ae61ebcd778cd6b Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Mon, 18 May 2026 13:12:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=E5=A2=9E=E5=8A=A0=20Seq=20?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E9=9B=86=E6=88=90=E4=B8=8E=E6=A0=87=E5=87=86?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SeqWriter 支持,将日志异步推送到 Seq 平台,支持多进程实例区分 - 实现 redirectStdout 函数,重定向 os.Stdout 和标准 log 包输出,确保 fmt.Println 等输出被捕获并记录 - 更新 README 文档,增加 Seq 日志集成的说明与配置链接 - 扩展 Logger 结构,支持动态添加输出目标,提升日志记录灵活性 --- README.md | 2 + application.go | 49 ++++++++++ docs/Seq日志集成.md | 121 ++++++++++++++++++++++++ log/logger.go | 219 +++++++++++++++++++++++++++++++++++++++++++- var.go | 2 + 5 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 docs/Seq日志集成.md diff --git a/README.md b/README.md index 7b21cf5..1d10ba3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ D:\app\go1.23.1\bin\go.exe test ./dd/... -v - **代码生成** - 根据数据库表自动生成 CRUD 接口 - **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台 - **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启 +- **结构化日志 / Seq 集成** - zerolog 驱动,异步 channel 队列推送,零阻塞接入 Seq 集中搜索,自动捕获 fmt.Println 等全量输出,支持单机多进程实例区分 - **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置 ## 文档 @@ -33,6 +34,7 @@ D:\app\go1.23.1\bin\go.exe test ./dd/... -v | [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 | | [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 | | [优雅停机](docs/graceful-shutdown.md) | 跨平台优雅停机、nginx 配置、滚动重启操作指南 | +| [Seq 日志集成](docs/Seq日志集成.md) | Seq 接入配置、异步队列原理、单机多进程实例区分、全量日志捕获、搜索语法速查 | | [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 | ## 安装 diff --git a/application.go b/application.go index a51854c..dcb9055 100644 --- a/application.go +++ b/application.go @@ -1,9 +1,12 @@ package hotime import ( + "bufio" "context" "database/sql" "io/ioutil" + stdlog "log" + "net" "net/http" "net/url" "os" @@ -340,6 +343,16 @@ func (that *Application) SetConfig(configPath ...string) { that.WebConnectLog = log.NewLoggerNoCaller(1, that.Config.GetString("webConnectLogFile"), 0) } + // 重定向 os.Stdout 和标准 log 包,捕获 fmt.Println 等绕过 HoTime Logger 的输出 + redirectStdout(that.Log) + + // 接入 Seq:instance = ip:port,同机多进程靠端口区分,跨机器靠 IP 区分 + if seqUrl := that.Config.GetString("seqUrl"); seqUrl != "" { + instance := getLocalIP() + ":" + that.Config.GetString("port") + that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance) + that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance) + } + } // SetConnectListener 连接判断,返回false继续传输至控制层,true则停止传输 @@ -623,6 +636,42 @@ func (that *Application) crossDomain(context *Context, sessionId string) { } +// getLocalIP 通过 UDP dial 探测方式获取本机出口 IP(不发送任何数据,无副作用)。 +// 用于构建 Seq instance 字段,格式为 ip:port,支持跨机器集群识别。 +// 失败时返回 "unknown"。 +func getLocalIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "unknown" + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).IP.String() +} + +// redirectStdout 重定向 os.Stdout 和标准 log 包到 HoTime Logger。 +// 捕获 fmt.Println/fmt.Printf 等绕过 HoTime 日志系统的输出, +// 以 INFO 级别 + source="stdout" 字段写入,后续经 SeqWriter 推送到 Seq。 +func redirectStdout(l *log.Logger) { + r, w, err := os.Pipe() + if err != nil { + return + } + os.Stdout = w + stdlog.SetOutput(w) + go func() { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + l.Info().Str("source", "stdout").Msg(line) + } + } + if err := scanner.Err(); err != nil { + l.Errorf("[stdout redirect] scanner error: %v", err) + } + }() +} + // Init 初始化application func Init(config string) *Application { appIns := Application{} diff --git a/docs/Seq日志集成.md b/docs/Seq日志集成.md new file mode 100644 index 0000000..4e7ba2d --- /dev/null +++ b/docs/Seq日志集成.md @@ -0,0 +1,121 @@ +# Seq 日志集成 + +HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活, +所有框架日志(含 `fmt.Println` 等绕过 Logger 的输出)实时推送到 Seq, +通过 Seq Web UI 实现关键词、日期范围、日志级别、结构化字段等多维度搜索。 + +--- + +## 配置项 + +在 `config.json` 中添加: + +```json +"seqUrl": "http://127.0.0.1:5341", +"seqApiKey": "" +``` + +| 字段 | 默认值 | 说明 | +|---|---|---| +| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 | +| `seqApiKey` | 空 | API Key,免费单用户版留空 | + +`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。 + +--- + +## 架构原理 + +``` +业务代码 + │ l.Info().Msg("...") ← HoTime Logger 正常调用路径 + │ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径 + ↓ +multiWriter(hotimev1.5/log/logger.go) + ├─ ConsoleWriter → 彩色终端输出(不变) + ├─ FileWriter → 本地日志文件(按需,logFile 配置) + └─ SeqWriter + │ Write() 只做 channel <- bytes,O(1) 非阻塞 + ↓ + channel(容量 10000) + ↓ 后台 goroutine + 批量打包(100 条 或 500ms) + ↓ HTTP POST + Seq 服务(CLEF 格式) +``` + +**关键特性:** +- `SeqWriter.Write()` 仅向 channel 投递字节即返回,**绝不阻塞** web 请求处理 goroutine +- channel 满时(Seq 宕机/网络故障)新日志被丢弃并计数,主服务完全不受影响 +- HTTP POST 设 5s 超时,失败仅打印到 stderr + +--- + +## 字段映射(zerolog → CLEF) + +| zerolog 字段 | Seq CLEF 字段 | 说明 | +|---|---|---| +| `time` | `@t` | 时间,自动转 ISO 8601 格式 | +| `level` | `@l` | 级别,映射为 Debug/Information/Warning/Error/Fatal | +| `message` / `msg` | `@mt` | 消息正文 | +| `caller` | `caller` | 调用位置,原样保留 | +| 其余自定义字段 | 原字段名 | 原样保留,可在 Seq 中直接查询 | +| — | `instance` | 框架自动注入,值为 `port` 配置(如 `"8085"`) | +| — | `source` | fmt.Println 等捕获的输出标记为 `stdout` | + +--- + +## 单机多进程实例区分 + +框架启动时自动获取本机出口 IP,拼接为 `ip:port` 格式作为 `instance`: + +``` +单机多进程: + 192.168.1.10:8085 ─┐ + 192.168.1.10:8086 ─┼─ HTTP CLEF ──→ Seq + 192.168.1.10:8087 ─┘ + +多服务器集群: + 192.168.1.10:8085 ─┐ + 192.168.1.11:8085 ─┼─ HTTP CLEF ──→ Seq(中央日志服务器) + 192.168.1.12:8085 ─┘ +``` + +Seq 中按实例筛选: +- 单台机器所有进程:`instance like '192.168.1.10%'` +- 精确到某个进程:`instance = '192.168.1.10:8085'` + +--- + +## stdout 全量捕获 + +框架启动时(`SetConfig()` 中)自动调用 `redirectStdout()`, +将 `os.Stdout` 和标准 `log` 包重定向到 HoTime Logger: + +- `fmt.Println("xxx")` → 以 `INFO` 级别、`source=stdout` 字段推送到 Seq +- `log.Printf("xxx")` → 同上 +- 控制台仍然能看到这些输出(输出管道由 goroutine 实时转发) + +--- + +## Seq 安装 + +Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖: + +- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务 +- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq` +- 访问 `http://localhost:5341` 使用 Web UI + +--- + +## 搜索语法速查 + +| 目标 | 查询语句 | +|---|---| +| 关键词搜索 | 直接输入,如 `支付失败` | +| 日志级别 | `@l = 'Error'` | +| 特定实例 | `instance = '8085'` | +| stdout 来源 | `source = 'stdout'` | +| 调用位置 | `caller like '%order.go%'` | +| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` | +| 日期范围 | 右上角时间选择器,支持精确到秒 | diff --git a/log/logger.go b/log/logger.go index db36354..2d5c17d 100644 --- a/log/logger.go +++ b/log/logger.go @@ -2,13 +2,17 @@ package log import ( "bufio" + "bytes" + "encoding/json" "fmt" "io" + "net/http" "os" "path/filepath" "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/rs/zerolog" @@ -25,6 +29,7 @@ type ErrorRecord struct { // Logger 日志核心结构体,封装 zerolog type Logger struct { zl zerolog.Logger + mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等 logLevel int errors []ErrorRecord errIdx int // 环形缓冲写入位置 @@ -69,8 +74,8 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger { writers = append(writers, fw) } - multi := zerolog.MultiLevelWriter(writers...) - zl := zerolog.New(multi). + mw := &multiWriter{writers: writers} + zl := zerolog.New(mw). Level(level). With(). Timestamp(). @@ -79,6 +84,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger { l := &Logger{ zl: zl, + mw: mw, logLevel: logLevel, maxErrors: maxErrors, } @@ -116,8 +122,8 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger { writers = append(writers, fw) } - multi := zerolog.MultiLevelWriter(writers...) - zl := zerolog.New(multi). + mw := &multiWriter{writers: writers} + zl := zerolog.New(mw). Level(level). With(). Timestamp(). @@ -125,6 +131,7 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger { l := &Logger{ zl: zl, + mw: mw, logLevel: logLevel, maxErrors: maxErrors, } @@ -145,6 +152,7 @@ func NewTestLogger() *Logger { func (l *Logger) SetOutput(w io.Writer) { if w == io.Discard { l.zl = zerolog.New(w).Level(zerolog.Disabled) + l.mw = nil return } consoleWriter := zerolog.ConsoleWriter{ @@ -159,7 +167,9 @@ func (l *Logger) SetOutput(w io.Writer) { return fmt.Sprintf("[%s]", i) }, } - l.zl = zerolog.New(consoleWriter). + mw := &multiWriter{writers: []io.Writer{consoleWriter}} + l.mw = mw + l.zl = zerolog.New(mw). With(). Timestamp(). Caller(). @@ -422,6 +432,205 @@ func shortenPath(file string) string { return file } +// --- multiWriter: 支持运行时追加输出目标 --- + +// multiWriter 实现 zerolog.LevelWriter,允许在 Logger 创建后动态追加新的输出(如 SeqWriter) +type multiWriter struct { + mu sync.RWMutex + writers []io.Writer +} + +func (mw *multiWriter) Write(p []byte) (int, error) { + mw.mu.RLock() + defer mw.mu.RUnlock() + for _, w := range mw.writers { + _, _ = w.Write(p) + } + return len(p), nil +} + +// WriteLevel 实现 zerolog.LevelWriter,使 zerolog 的级别过滤正确传递给各子 Writer +func (mw *multiWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { + mw.mu.RLock() + defer mw.mu.RUnlock() + for _, w := range mw.writers { + if lw, ok := w.(zerolog.LevelWriter); ok { + _, _ = lw.WriteLevel(l, p) + } else { + _, _ = w.Write(p) + } + } + return len(p), nil +} + +func (mw *multiWriter) add(w io.Writer) { + mw.mu.Lock() + mw.writers = append(mw.writers, w) + mw.mu.Unlock() +} + +// --- SeqWriter: 异步推送到 Seq(CLEF 格式)--- + +// SeqWriter 将 zerolog JSON 日志通过 channel 队列异步发送到 Seq。 +// Write() 仅做 channel <- bytes,O(1) 非阻塞,绝不阻塞 web 请求处理 goroutine。 +// channel 容量 10000,满时丢弃新条目(dropped 计数),不会影响主服务。 +type SeqWriter struct { + seqUrl string + apiKey string + instance string + queue chan []byte + dropped int64 + client *http.Client +} + +func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter { + w := &SeqWriter{ + seqUrl: strings.TrimRight(seqUrl, "/"), + apiKey: apiKey, + instance: instance, + queue: make(chan []byte, 10000), + client: &http.Client{Timeout: 5 * time.Second}, + } + go w.run() + return w +} + +// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送 +func (w *SeqWriter) Write(p []byte) (int, error) { + clef := w.toClef(p) + select { + case w.queue <- clef: + default: + atomic.AddInt64(&w.dropped, 1) + } + return len(p), nil +} + +// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式 +// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt +func (w *SeqWriter) toClef(p []byte) []byte { + trimmed := bytes.TrimSpace(p) + var m map[string]interface{} + if err := json.Unmarshal(trimmed, &m); err != nil { + // 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志 + return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@mt":%q,"source":"stdout","instance":%q}`, + time.Now().Format(time.RFC3339), strings.TrimSpace(string(p)), w.instance)) + } + + clef := make(map[string]interface{}, len(m)+3) + for k, v := range m { + clef[k] = v + } + + // time → @t(zerolog 格式 "2006-01-02 15:04:05" → ISO 8601) + if t, ok := m["time"].(string); ok { + delete(clef, "time") + if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil { + clef["@t"] = pt.Format(time.RFC3339) + } else { + clef["@t"] = t + } + } else { + clef["@t"] = time.Now().Format(time.RFC3339) + } + + // level → @l + if lv, ok := m["level"].(string); ok { + delete(clef, "level") + switch lv { + case "debug": + clef["@l"] = "Debug" + case "info": + clef["@l"] = "Information" + case "warn": + clef["@l"] = "Warning" + case "error": + clef["@l"] = "Error" + case "fatal": + clef["@l"] = "Fatal" + default: + clef["@l"] = lv + } + } + + // message/msg → @mt + if msg, ok := m["message"].(string); ok { + delete(clef, "message") + clef["@mt"] = msg + } else if msg, ok := m["msg"].(string); ok { + delete(clef, "msg") + clef["@mt"] = msg + } + + if w.instance != "" { + clef["instance"] = w.instance + } + + b, _ := json.Marshal(clef) + return b +} + +// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq +func (w *SeqWriter) run() { + batch := make([][]byte, 0, 100) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case entry := <-w.queue: + batch = append(batch, entry) + if len(batch) >= 100 { + w.flush(batch) + batch = batch[:0] + } + case <-ticker.C: + if len(batch) > 0 { + w.flush(batch) + batch = batch[:0] + } + } + } +} + +func (w *SeqWriter) flush(batch [][]byte) { + var buf bytes.Buffer + for _, entry := range batch { + buf.Write(entry) + buf.WriteByte('\n') + } + req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf) + if err != nil { + fmt.Fprintf(os.Stderr, "[seq] build request error: %v\n", err) + return + } + req.Header.Set("Content-Type", "application/vnd.serilog.clef") + if w.apiKey != "" { + req.Header.Set("X-Seq-ApiKey", w.apiKey) + } + resp, err := w.client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped)) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "[seq] server returned %d: %s\n", resp.StatusCode, string(body)) + } +} + +// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出) +// seqUrl: Seq 服务地址,如 "http://127.0.0.1:5341" +// apiKey: Seq API Key,免费单用户版留空 +// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085" +func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) { + if seqUrl == "" || l.mw == nil { + return + } + sw := newSeqWriter(seqUrl, apiKey, instance) + l.mw.add(sw) +} + // isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线, // 这些文件在调用栈中会被跳过,以显示真正的业务调用者。 // context.go 是 Display/View 的纯中间层,也作为基础设施跳过。 diff --git a/var.go b/var.go index 123e32b..bb362ec 100644 --- a/var.go +++ b/var.go @@ -61,6 +61,8 @@ var ConfigNote = Map{ "logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取", "webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能", "webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置", + "seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port(如 192.168.1.10:8085),支持单机多进程和多服务器集群", + "seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写", //"codeConfig": Map{ // "注释": "配置即启用,非必须,默认无", // //"package":"默认admin,必须,mode模式为2时会自动生成包文件夹和代码文件",