第 15 章 · 毕业项目 toolkit-go 完整 Walkthrough
本章目标:综合运用 ch01~ch14,完成 toolkit-go 命令行工具:读取 JSON Lines access 日志 → 按 path 聚合慢请求 → Top N 排序 → 输出 CSV 运营日报;编写 ≥3 单元测试;go build 产出单二进制;按 100 分验收表自评;逐文件说明工程结构,衔接 gin-web ch01。
学时建议:6~8 小时(含算法复习 1 小时 + 项目实现 5 小时)
前置:完成 ch01~ch14;目录 ~/learn-go/toolkit-go 已 go mod init example.com/toolkit-go。
15.1 项目背景与交付物
toolkit-go 是虚构运维团队 toolkit-team 的 CLI,服务 贤紫优选 商城网关日志分析。运营同学每日需要:
- 读取 access.log.jsonl(JSON Lines,与 gin-web 访问日志字段兼容);
- 找出 慢于阈值 的请求,按 path 聚合 count/avg/max;
- 输出 Top N 路径的 CSV 日报,导入 Excel;
- 可选启动 HTTP /health 探针(ch12 选修)。
不连接真实生产;数据均为本地样本;用户标识仅用 user-demo 等虚构 slug。
| 序号 | 交付物 | 说明 |
|---|---|---|
| 1 | cmd/toolkit/main.go | 主入口,flags |
| 2 | data/access.log.jsonl | 样本日志 ≥20 行 |
| 3 | report-YYYYMMDD.csv | 运行后生成的日报 |
| 4 | go.mod / go.sum | 模块与依赖校验 |
| 5 | README.md | 用法、flags、示例 |
| 6 | internal/**/_test.go | ≥3 单元测试 |
| 7 | toolkit 或 toolkit.exe | go build 单二进制 |
对标 java-dev ch17 toolkit-demo-graduation 与 python-dev ch16~ch17 日报项目;Go 版强调 单二进制部署 与 并发解析(ch09/10)。
15.2 推荐目录结构
~/learn-go/toolkit-go/
├── cmd/
│ ├── toolkit/
│ │ └── main.go # CLI 入口
│ └── probe/ # 选修 HTTP 探针 ch12
│ └── main.go
├── internal/
│ ├── model/
│ │ └── entry.go # LogEntry、Product
│ ├── logparser/
│ │ ├── reader.go # ReadLines、ScanFile
│ │ ├── parser.go # ParseLine、ParseAll
│ │ ├── concurrent.go # ch09 并发解析
│ │ └── pool.go # ch10 Worker Pool
│ ├── report/
│ │ ├── aggregate.go # AggregateSlow
│ │ ├── csv.go # CSVReporter
│ │ └── slice.go # MapToSlice
│ ├── sortalgo/
│ │ ├── topn.go # TopNSlowRows ch14
│ │ └── bubble.go # 教学选修
│ └── app/
│ └── runner.go # RunReport 总控
├── pkg/
│ └── id/
│ └── id.go # request_id ch08
├── data/
│ └── access.log.jsonl
├── go.mod
├── go.sum
└── README.md
模块路径:example.com/toolkit-go(教学用,勿与真实域名混淆)。
15.3 日志格式与 CSV 输出
输入 — 每行一个 JSON(字段虚构):
{"time":"2026-08-19T10:00:01Z","method":"GET","path":"/api/v1/products/go-handbook","status":200,"duration_ms":45,"request_id":"req-demo-001","user_slug":"user-demo"}
{"time":"2026-08-19T10:00:02Z","method":"POST","path":"/api/v1/orders","status":500,"duration_ms":3200,"request_id":"req-demo-002","user_slug":"user-demo"}
{"time":"2026-08-19T10:00:03Z","method":"GET","path":"/api/v1/cart","status":200,"duration_ms":890,"request_id":"req-demo-003","user_slug":"user-demo"}
输出 CSV — report-20260819.csv:
path,count,avg_ms,max_ms,last_status
/api/v1/orders,1,3200,3200,500
/api/v1/cart,1,890,890,200
| 列 | 含义 |
|---|---|
| path | 请求路径 |
| count | 慢请求次数(≥ min-ms) |
| avg_ms | 平均耗时 |
| max_ms | 最大耗时 |
| last_status | 最后一条的状态码 |
15.4 逐文件说明
internal/model/entry.go
package model
import "time"
type LogEntry struct {
Time time.Time `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
DurationMs int `json:"duration_ms"`
RequestID string `json:"request_id"`
UserSlug string `json:"user_slug"`
}
type Product struct {
Slug string `json:"slug"`
Name string `json:"name"`
Price int64 `json:"price"` // 分
IsPublished bool `json:"is_published"`
}
其他文件(复用前几章)
| 文件 | 来源 | 职责 |
|---|---|---|
logparser/reader.go | ch11 | ReadLines + context |
logparser/parser.go | ch11 | ParseLine、ParseAll |
report/aggregate.go | ch11 | AggregateSlow |
report/csv.go | ch11 | CSVReporter.Write |
sortalgo/topn.go | ch14 | TopNSlowRows |
internal/app/runner.go
package app
import (
"context"
"fmt"
"log"
"time"
"example.com/toolkit-go/internal/logparser"
"example.com/toolkit-go/internal/report"
"example.com/toolkit-go/internal/sortalgo"
)
type Config struct {
Input string
Output string
MinMs int
TopN int
Workers int
Timeout time.Duration
Verbose bool
}
type Result struct {
Parsed int
Skipped int
Rows int
OutPath string
}
func RunReport(ctx context.Context, cfg Config) (Result, error) {
lines, err := logparser.ReadLines(ctx, cfg.Input)
if err != nil {
return Result{}, fmt.Errorf("read: %w", err)
}
entries, stats := logparser.ParseAll(lines)
if cfg.Verbose {
log.Printf("parsed=%d skipped=%d", stats.Parsed, stats.Skipped)
}
agg := report.AggregateSlow(entries, cfg.MinMs)
rows := report.MapToSlice(agg)
top := sortalgo.TopNSlowRows(rows, cfg.TopN)
out := cfg.Output
if out == "" {
out = fmt.Sprintf("report-%s.csv", time.Now().Format("20060102"))
}
if err := report.CSVReporter{OutPath: out}.Write(top); err != nil {
return Result{}, fmt.Errorf("write csv: %w", err)
}
return Result{
Parsed: stats.Parsed,
Skipped: stats.Skipped,
Rows: len(top),
OutPath: out,
}, nil
}
15.5 cmd/toolkit/main.go 完整入口
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"example.com/toolkit-go/internal/app"
)
func main() {
input := flag.String("input", "data/access.log.jsonl", "JSON Lines log file")
output := flag.String("output", "", "csv output (default report-YYYYMMDD.csv)")
minMs := flag.Int("min-ms", 500, "slow request threshold in ms")
top := flag.Int("top", 10, "top N slow paths")
workers := flag.Int("workers", 4, "concurrent workers (future use)")
timeout := flag.Duration("timeout", 60*time.Second, "max run duration")
verbose := flag.Bool("v", false, "verbose logging")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
_ = workers // ch10 并发版可在此启用 RunParsePool
res, err := app.RunReport(ctx, app.Config{
Input: *input,
Output: *output,
MinMs: *minMs,
TopN: *top,