From dabf8c2953eb369dd02338c476b85e702523155c Mon Sep 17 00:00:00 2001 From: chiyi Date: Sat, 19 Sep 2026 15:46:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(docs):=20=E6=96=87=E6=A1=A3=E7=AB=99?= =?UTF-8?q?=E8=87=AA=E5=B8=A6=E7=83=AD=E6=9B=B4=E6=96=B0=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E5=8F=98=E6=9B=B4=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E5=90=8E=E7=AB=AF+=E5=88=B7=E6=96=B0=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs 后端从 go run 改为 build+spawn(编译成功才切换,与 fun run 同语义) - watchLoop 复用:.go/go.mod/go.sum 变更→重采元数据+重启后端 - 新增 /fun-version 端点;文档页轮询版本变化自动刷新(编辑中推迟) - snapshot 跳过 fun_gen_helper_test.go,避免采集辅助文件自触发 - -from 静态模式不启用;-no-run 仍刷新元数据(后端由外部管理) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 4 ++- cmd/fun/docs.go | 75 +++++++++++++++++++++++++++++++++------ cmd/fun/docsui/index.html | 15 ++++++++ cmd/fun/run.go | 4 +++ 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 545d07f..6d3123a 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,13 @@ fun run -- -config dev.yaml # 透传程序参数 ### fun docs —— API 文档站 ```bash -fun docs # 自动 go run . 拉起后端 +fun docs # 自动编译拉起后端,源码变更热更新 fun docs -upstream http://127.0.0.1:9000 # 指定后端地址 fun docs -no-run # 后端已在别处运行 ``` +- **热更新**:监听 `.go`/`go.mod`/`go.sum` 变更,自动重采元数据、重编译重启后端, + 文档页检测版本变化自动刷新(编辑中推迟);`-from` 静态模式不启用 - 浅色文档风格:侧栏服务/方法树(stream 徽标)+ 搜索(`/` 快速聚焦) - **分享**:标题旁一键复制深链(`#服务.方法?req=...`),自带请求体,打开即还原 - 参数表:字段/类型/示例值/说明;枚举下拉、嵌套 DTO 分层、可空标注 diff --git a/cmd/fun/docs.go b/cmd/fun/docs.go index 8da747d..b429d49 100644 --- a/cmd/fun/docs.go +++ b/cmd/fun/docs.go @@ -34,10 +34,10 @@ func cmdDocs(args []string) { var meta *FunMeta var err error + var root string if *from != "" { meta, err = loadMeta(*from) } else { - var root string root, err = findModuleRoot(*pkg) if err == nil { genDir, _ := os.MkdirTemp("", "fun-docs-gen-*") @@ -57,17 +57,16 @@ func cmdDocs(args []string) { os.Exit(1) } - // 自动起后端:FUN_DUMP 采集进程已退出,这里再起一个常驻的 + // 自动起后端:先编译成功再启动(与 fun run 同套语义,热更新复用) + binPath := filepath.Join(os.TempDir(), fmt.Sprintf("fun-docs-run-%d", os.Getpid())) var backend *exec.Cmd + var backendDone chan struct{} if !*noStart { - backend = goCommand("run", *pkg) - backend.Stdout, backend.Stderr = os.Stdout, os.Stderr - // 独立进程组:go run 的孙进程(真正的服务)随 CLI 一起被杀干净 - backend.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := backend.Start(); err != nil { - fmt.Fprintln(os.Stderr, "fun: 启动后端失败:", err) + if !build(*pkg, binPath) { + fmt.Fprintln(os.Stderr, "fun: 后端编译失败") os.Exit(1) } + backend, backendDone = spawn(binPath, nil) } target, err := url.Parse(*upstream) @@ -80,7 +79,47 @@ func cmdDocs(args []string) { loadOrCreateEdits(*editsPath) + // 热更新:元数据与版本号会被 watcher 刷新,HTTP 读侧与写侧用同一把锁 + var metaMu sync.RWMutex metaJSON := renderMetaJSON(meta) + var metaVersion int64 + + // 监听源码变更:重采元数据 + 重建重启后端(-from 静态模式不启用) + if root != "" { + restart := make(chan struct{}, 1) + go watchLoop(root, restart) + go func() { + for range restart { + fmt.Println("fun: 检测到源码变更,重新采集元数据...") + genDir, _ := os.MkdirTemp("", "fun-docs-gen-*") + genErr := runFunGen(root, "ts", genDir) + var m *FunMeta + if genErr == nil { + m, genErr = parseTsDir(filepath.Join(genDir, "ts")) + } + os.RemoveAll(genDir) + if genErr != nil { + fmt.Fprintln(os.Stderr, "fun: 元数据刷新失败(保留旧文档):", genErr) + continue + } + metaMu.Lock() + metaJSON = renderMetaJSON(m) + metaVersion++ + metaMu.Unlock() + if *noStart { + fmt.Println("fun: 元数据已刷新(后端由外部管理)") + continue + } + if !build(*pkg, binPath) { + fmt.Fprintln(os.Stderr, "fun: 编译失败,旧后端继续运行") + continue + } + killChild(backend, backendDone) + backend, backendDone = spawn(binPath, nil) + fmt.Println("fun: 已重启后端并刷新文档元数据") + } + }() + } mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -90,8 +129,17 @@ func cmdDocs(args []string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) case "/fun-meta.json": + metaMu.RLock() + data := metaJSON + metaMu.RUnlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Write(metaJSON) + w.Write(data) + case "/fun-version": + metaMu.RLock() + v := metaVersion + metaMu.RUnlock() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprintf(w, `{"v":%d}`, v) case "/fun-docs-edits.json": editsMu.RLock() data, _ := json.Marshal(edits) @@ -134,7 +182,7 @@ func cmdDocs(args []string) { signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs - killChild(backend, nil) + killChild(backend, backendDone) os.Exit(0) }() @@ -142,15 +190,20 @@ func cmdDocs(args []string) { for _, svc := range meta.Services { nMethods += len(svc.Methods) } + hot := "关(-from 静态模式)" + if root != "" { + hot = "开(源码变更自动重建后端 + 刷新元数据)" + } fmt.Printf(` fun docs 已启动 ───────────────────────────────────────── 文档站: http://%s 后端代理: %s → %s 说明存档: %s + 热更新: %s ───────────────────────────────────────── %d 个服务 / %d 个方法。Ctrl+C 退出(连同自动拉起的后端) -`, *addr, *upstream, *upstream, *editsPath, len(meta.Services), nMethods) +`, *addr, *upstream, *upstream, *editsPath, hot, len(meta.Services), nMethods) if err := http.ListenAndServe(*addr, mux); err != nil { fmt.Fprintln(os.Stderr, "fun: 文档站启动失败:", err) diff --git a/cmd/fun/docsui/index.html b/cmd/fun/docsui/index.html index 88f1d7a..a618253 100644 --- a/cmd/fun/docsui/index.html +++ b/cmd/fun/docsui/index.html @@ -243,6 +243,21 @@ Promise.all([ selectFromHash(); }).catch(e=>{const h=$('#hmeta');h.textContent='元数据加载失败: '+e.message;h.style.color='var(--err)';$('#live').style.background='var(--err)'}); +/* 热更新:源码变更触发后端重建+元数据刷新后,文档页自动重载; + 正在输入时推迟一轮,避免打断编辑(防抖保存会先把备注落盘) */ +let DOC_V=null; +setInterval(async()=>{ + try{ + const r=await fetch('/fun-version').then(r=>r.json()); + if(DOC_V===null){DOC_V=r.v;return} + if(r.v!==DOC_V){ + const ae=document.activeElement; + if(ae&&/INPUT|TEXTAREA/.test(ae.tagName))return; + location.reload(); + } + }catch{} +},1500); + window.addEventListener('hashchange',selectFromHash); /* 深链:#Svc.Method 或 #Svc.Method?req= */ function selectFromHash(){ diff --git a/cmd/fun/run.go b/cmd/fun/run.go index 0a703dd..793077e 100644 --- a/cmd/fun/run.go +++ b/cmd/fun/run.go @@ -135,6 +135,10 @@ func snapshot(root string) map[string]int64 { } ext := filepath.Ext(name) if ext == ".go" || name == "go.mod" || name == "go.sum" { + // 元数据采集的临时辅助文件在模块内一闪而过,忽略避免自触发 + if name == helperTestFile { + return nil + } if info, err := d.Info(); err == nil { out[path] = info.ModTime().UnixNano() }