- fun gen [ts|go]:AST 扫描 BindService* 调用,临时生成 go test 辅助采集, 经 fun v1.3.7 公开 API 生成,产物与内置 GenCode 字节级一致(回归测试锁定) - fun run:热更新开发循环,先编译成功再切换,失败保留旧进程 - fun docs:浅色标准文档站——参数表/State 添加字段/请求 JSON 可编辑/ 响应格式化/NDJSON 流式逐行展示,反向代理 /cell 同源无 CORS - 元数据:TS 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持) - 纯标准库实现,零第三方依赖
187 lines
4.9 KiB
Go
187 lines
4.9 KiB
Go
package main
|
||
|
||
// fun gen:从运行中的服务采集元数据,渲染 Go/TS 客户端代码
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// goCommand 组装 go run/build 命令:-p 为目录时切换工作目录,
|
||
// 否则视为当前模块内的包路径(如 ./cmd/server)
|
||
func goCommand(subcmd string, pkg string, extra ...string) *exec.Cmd {
|
||
args := append([]string{subcmd}, extra...)
|
||
if st, err := os.Stat(pkg); err == nil && st.IsDir() {
|
||
args = append(args, ".")
|
||
cmd := exec.Command("go", args...)
|
||
cmd.Dir = pkg
|
||
return cmd
|
||
}
|
||
args = append(args, pkg)
|
||
return exec.Command("go", args...)
|
||
}
|
||
|
||
// collectMeta 采集元数据:优先 -from 文件,否则 FUN_DUMP=<tmp> go run <pkg>
|
||
func collectMeta(pkg string, from string) (*FunMeta, error) {
|
||
if from != "" {
|
||
return loadMeta(from)
|
||
}
|
||
tmp, err := os.CreateTemp("", "fun-meta-*.json")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tmpPath := tmp.Name()
|
||
tmp.Close()
|
||
defer os.Remove(tmpPath)
|
||
|
||
cmd := goCommand("run", pkg)
|
||
cmd.Env = append(os.Environ(), "FUN_DUMP="+tmpPath)
|
||
cmd.Stdout = os.Stderr // 用户程序的输出不污染元数据文件
|
||
cmd.Stderr = os.Stderr
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, fmt.Errorf("启动 go run 失败: %w", err)
|
||
}
|
||
|
||
done := make(chan error, 1)
|
||
go func() { done <- cmd.Wait() }()
|
||
// 用户 main 需要响应 FUN_DUMP(调用 f.Start 的都会自动响应);
|
||
// 首次 go run 含编译耗时,给足余量
|
||
select {
|
||
case err := <-done:
|
||
if err != nil {
|
||
return nil, fmt.Errorf("go run 退出: %w(程序需调用 f.Start 或显式处理 fun.DumpRequested)", err)
|
||
}
|
||
case <-time.After(120 * time.Second):
|
||
cmd.Process.Kill()
|
||
return nil, fmt.Errorf("等待元数据超时:程序 120 秒内未退出。请确认 main 调用了 f.Start(会自动响应 FUN_DUMP 导出后退出),或用 fun.DumpRequested + f.DumpMetadata 显式导出")
|
||
}
|
||
|
||
return loadMeta(tmpPath)
|
||
}
|
||
|
||
func cmdGen(args []string) {
|
||
fs := flag.NewFlagSet("gen", flag.ExitOnError)
|
||
out := fs.String("o", "./gen", "输出目录(会被清空重建)")
|
||
from := fs.String("from", "", "从元数据 JSON 文件读取(跳过 go run 采集)")
|
||
pkg := fs.String("p", ".", "业务 main 包路径")
|
||
fs.Parse(reorderFlags(args))
|
||
|
||
rest := fs.Args()
|
||
wantTS, wantGo := false, false
|
||
switch {
|
||
case len(rest) == 0:
|
||
wantTS, wantGo = true, true
|
||
case len(rest) == 1 && (rest[0] == "ts" || rest[0] == "go"):
|
||
wantTS = rest[0] == "ts"
|
||
wantGo = rest[0] == "go"
|
||
default:
|
||
fmt.Fprintln(os.Stderr, "用法: fun gen [ts|go] [-o 输出目录] [-from meta.json] [-p 包路径]")
|
||
os.Exit(2)
|
||
}
|
||
|
||
lang := "both"
|
||
if wantTS && !wantGo {
|
||
lang = "ts"
|
||
} else if wantGo && !wantTS {
|
||
lang = "go"
|
||
}
|
||
|
||
if *from != "" {
|
||
meta, err := loadMeta(*from)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "fun: 读取元数据失败:", err)
|
||
os.Exit(1)
|
||
}
|
||
files := fileSet{}
|
||
if wantTS {
|
||
renderTs{}.renderAll(meta, files)
|
||
}
|
||
if wantGo {
|
||
renderGo{}.renderAll(meta, files)
|
||
}
|
||
if err := writeFiles(*out, files); err != nil {
|
||
fmt.Fprintln(os.Stderr, "fun: 写入产物失败:", err)
|
||
os.Exit(1)
|
||
}
|
||
reportGen(*out, files)
|
||
return
|
||
}
|
||
|
||
root, err := findModuleRoot(*pkg)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "fun:", err)
|
||
os.Exit(1)
|
||
}
|
||
if err := runFunGen(root, lang, *out); err != nil {
|
||
fmt.Fprintln(os.Stderr, "fun:", err)
|
||
os.Exit(1)
|
||
}
|
||
count := countGenFiles(*out)
|
||
fmt.Printf("fun: 已生成客户端(%s)→ %s\n", lang, *out)
|
||
for _, name := range count {
|
||
fmt.Println(" 生成:", name)
|
||
}
|
||
}
|
||
|
||
func reportGen(out string, files fileSet) {
|
||
names := make([]string, 0, len(files))
|
||
for name := range files {
|
||
names = append(names, filepath.ToSlash(name))
|
||
}
|
||
sortStrings(names)
|
||
fmt.Printf("fun: 已生成 %d 个文件 → %s\n", len(files), out)
|
||
for _, name := range names {
|
||
fmt.Println(" 生成:", name)
|
||
}
|
||
}
|
||
|
||
func countGenFiles(out string) []string {
|
||
var names []string
|
||
filepath.WalkDir(out, func(path string, d os.DirEntry, err error) error {
|
||
if err == nil && !d.IsDir() {
|
||
rel, _ := filepath.Rel(out, path)
|
||
names = append(names, filepath.ToSlash(rel))
|
||
}
|
||
return nil
|
||
})
|
||
sortStrings(names)
|
||
return names
|
||
}
|
||
|
||
func sortStrings(s []string) {
|
||
for i := 1; i < len(s); i++ {
|
||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||
s[j], s[j-1] = s[j-1], s[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
// reorderFlags 把 -x 值 对挪到位置参数之前,让 "fun gen ts -o dir" 与
|
||
// "fun gen -o dir ts" 等价(flag 包遇到首个位置参数就停止解析)
|
||
func reorderFlags(args []string) []string {
|
||
var flags, rest []string
|
||
i := 0
|
||
for i < len(args) {
|
||
a := args[i]
|
||
if strings.HasPrefix(a, "-") && a != "-" && a != "--" {
|
||
flags = append(flags, a)
|
||
// 有值的标志:-o dir / -odir
|
||
if !strings.Contains(a, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||
flags = append(flags, args[i+1])
|
||
i += 2
|
||
continue
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
rest = append(rest, a)
|
||
i++
|
||
}
|
||
return append(flags, rest...)
|
||
}
|