// Package main provides the repository-local hot reload runner behind `make run`. package main import ( "bufio" "context" "errors" "flag" "fmt" "io" "os" "os/exec" "os/signal" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "time" ) const ( scanInterval = 800 * time.Millisecond restartDebounce = 350 * time.Millisecond stopTimeout = 8 * time.Second ) type serviceSpec struct { Name string Dir string Args []string WatchRoot []string Ports []int Color string } type serviceRunner struct { spec serviceSpec mu sync.Mutex cmd *exec.Cmd done chan struct{} stopping bool } var logMu sync.Mutex func main() { serviceList := flag.String("services", "all", "comma-separated services to run, or all") flag.Parse() root, err := os.Getwd() if err != nil { fatal(err) } specs, err := selectServices(*serviceList) if err != nil { fatal(err) } if len(specs) == 0 { fatal(errors.New("no services selected")) } ctx, stop := signalContext() defer stop() runners := make(map[string]*serviceRunner, len(specs)) watchers := make([]*watchState, 0, len(specs)) for _, spec := range specs { runner := &serviceRunner{spec: spec} runners[spec.Name] = runner killPortOwners(spec) if err := runner.start(root); err != nil { logf(os.Stderr, spec, "start failed: %v", err) } watchers = append(watchers, newWatchState(root, spec)) } printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs)) printPlain(os.Stdout, "press Ctrl-C to stop all services") ticker := time.NewTicker(scanInterval) defer ticker.Stop() pending := map[string]time.Time{} for { select { case <-ctx.Done(): stopAll(runners) return case now := <-ticker.C: for _, watcher := range watchers { changed, err := watcher.changed() if err != nil { logf(os.Stderr, watcher.spec, "watch failed: %v", err) continue } if changed { pending[watcher.spec.Name] = now } } for name, changedAt := range pending { if now.Sub(changedAt) < restartDebounce { continue } delete(pending, name) runner := runners[name] logf(os.Stdout, runner.spec, "change detected, restarting") runner.restart(root) } } } } func allServices() []serviceSpec { // 启动顺序按低层依赖到入口服务排列;gRPC dial 当前不阻塞,但这样能让日志更接近真实依赖链路。 return []serviceSpec{ appService("user-service", []int{13005, 13105}, "36"), appService("activity-service", []int{13006, 13106}, "35"), appService("wallet-service", []int{13004, 13104}, "33"), appService("game-service", []int{13008, 13108}, "32"), appService("room-service", []int{13001, 13101}, "34"), appService("notice-service", []int{13009, 13109}, "96"), appService("cron-service", []int{13007, 13107}, "95"), appService("gateway-service", []int{13000}, "92"), appService("statistics-service", []int{13010, 13110}, "94"), { Name: "admin", Dir: "server/admin", Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"}, WatchRoot: []string{ "server/admin", "api", }, Ports: []int{13100}, Color: "91", }, } } func appService(name string, ports []int, color string) serviceSpec { return serviceSpec{ Name: name, Dir: ".", Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"}, WatchRoot: []string{ "services/" + name, "pkg", "api", }, Ports: ports, Color: color, } } func selectServices(raw string) ([]serviceSpec, error) { all := allServices() if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "all" { return all, nil } byName := make(map[string]serviceSpec, len(all)) for _, spec := range all { byName[spec.Name] = spec } aliases := map[string]string{ "gateway": "gateway-service", "gs": "gateway-service", "room": "room-service", "rs": "room-service", "wallet": "wallet-service", "ws": "wallet-service", "user": "user-service", "us": "user-service", "activity": "activity-service", "as": "activity-service", "cron": "cron-service", "cs": "cron-service", "game": "game-service", "games": "game-service", "notice": "notice-service", "ns": "notice-service", "stats": "statistics-service", "statistics": "statistics-service", } var selected []serviceSpec seen := map[string]bool{} for _, token := range strings.Split(raw, ",") { token = strings.TrimSpace(token) if token == "" { continue } if canonical, ok := aliases[token]; ok { token = canonical } spec, ok := byName[token] if !ok { return nil, fmt.Errorf("unknown service %q", token) } if !seen[spec.Name] { selected = append(selected, spec) seen[spec.Name] = true } } return selected, nil } func (r *serviceRunner) start(repoRoot string) error { r.mu.Lock() defer r.mu.Unlock() cmd := exec.Command("go", r.spec.Args...) cmd.Dir = filepath.Join(repoRoot, r.spec.Dir) cmd.Env = append(os.Environ(), "TZ=UTC") // go run 会再托管一个编译后的子进程;独立进程组保证重启时能一起收到 SIGINT。 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stdout, err := cmd.StdoutPipe() if err != nil { return err } stderr, err := cmd.StderrPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } r.cmd = cmd r.done = make(chan struct{}) r.stopping = false go prefixPipe(r.spec, stdout) go prefixPipe(r.spec, stderr) go r.wait(cmd, r.done) logf(os.Stdout, r.spec, "started: go %s", strings.Join(r.spec.Args, " ")) return nil } func (r *serviceRunner) restart(repoRoot string) { r.stop() killPortOwners(r.spec) if err := r.start(repoRoot); err != nil { logf(os.Stderr, r.spec, "restart failed: %v", err) } } func (r *serviceRunner) stop() { r.mu.Lock() cmd := r.cmd done := r.done r.stopping = true r.mu.Unlock() if cmd == nil || cmd.Process == nil || done == nil { return } _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) select { case <-done: case <-time.After(stopTimeout): _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) <-done } } func (r *serviceRunner) wait(cmd *exec.Cmd, done chan struct{}) { err := cmd.Wait() close(done) r.mu.Lock() stopping := r.stopping if r.cmd == cmd { r.cmd = nil r.done = nil r.stopping = false } r.mu.Unlock() if err != nil { if stopping { logf(os.Stdout, r.spec, "stopped") return } logf(os.Stderr, r.spec, "exited: %v", err) return } logf(os.Stdout, r.spec, "exited") } func stopAll(runners map[string]*serviceRunner) { names := make([]string, 0, len(runners)) for name := range runners { names = append(names, name) } sort.Strings(names) var wg sync.WaitGroup for _, name := range names { wg.Add(1) go func(runner *serviceRunner) { defer wg.Done() runner.stop() }(runners[name]) } wg.Wait() } type watchState struct { root string spec serviceSpec snapshot map[string]fileStamp } type fileStamp struct { modTime time.Time size int64 } func newWatchState(root string, spec serviceSpec) *watchState { w := &watchState{root: root, spec: spec} w.snapshot = w.scan() return w } func (w *watchState) changed() (bool, error) { next := w.scan() changed := !sameSnapshot(w.snapshot, next) w.snapshot = next return changed, nil } func (w *watchState) scan() map[string]fileStamp { out := make(map[string]fileStamp) for _, rel := range w.spec.WatchRoot { w.scanPath(out, rel) } // 根 module 变更会影响所有 app 服务;admin 还有自己的独立 module 文件。 w.scanPath(out, "go.mod") w.scanPath(out, "go.sum") if w.spec.Name == "admin" { w.scanPath(out, filepath.Join("server", "admin", "go.mod")) w.scanPath(out, filepath.Join("server", "admin", "go.sum")) } return out } func (w *watchState) scanPath(out map[string]fileStamp, rel string) { path := filepath.Join(w.root, rel) info, err := os.Stat(path) if err != nil { return } if !info.IsDir() { if watchableFile(path) { out[rel] = fileStamp{modTime: info.ModTime(), size: info.Size()} } return } _ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { if ignoredDir(d.Name()) { return filepath.SkipDir } return nil } if !watchableFile(path) { return nil } info, err := d.Info() if err != nil { return nil } relPath, err := filepath.Rel(w.root, path) if err != nil { return nil } out[relPath] = fileStamp{modTime: info.ModTime(), size: info.Size()} return nil }) } func ignoredDir(name string) bool { switch name { case ".git", ".idea", ".vscode", "bin", "dist", "node_modules", "storage", "tmp", "vendor": return true default: return false } } func watchableFile(path string) bool { switch filepath.Ext(path) { case ".go", ".yaml", ".yml", ".proto", ".mod", ".sum": return true default: return false } } func sameSnapshot(a, b map[string]fileStamp) bool { if len(a) != len(b) { return false } for path, stampA := range a { stampB, ok := b[path] if !ok || !stampA.modTime.Equal(stampB.modTime) || stampA.size != stampB.size { return false } } return true } func killPortOwners(spec serviceSpec) { seen := map[int]bool{} for _, port := range spec.Ports { if port <= 0 || seen[port] { continue } seen[port] = true pids, err := listeningPIDs(port) if err != nil { logf(os.Stderr, spec, "port %d lookup failed: %v", port, err) continue } if len(pids) == 0 { continue } for _, pid := range pids { // 本地开发启动以抢占端口为准:旧进程可能是 go run 托管的子进程,直接杀监听 PID 最可靠。 if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { logf(os.Stderr, spec, "kill pid %d on port %d failed: %v", pid, port, err) continue } logf(os.Stdout, spec, "killed pid %d occupying port %d", pid, port) } } } func listeningPIDs(port int) ([]int, error) { output, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && len(output) == 0 { return nil, nil } return nil, err } lines := strings.Fields(string(output)) pids := make([]int, 0, len(lines)) for _, line := range lines { pid, err := strconv.Atoi(strings.TrimSpace(line)) if err != nil { continue } pids = append(pids, pid) } return pids, nil } func prefixPipe(spec serviceSpec, reader io.Reader) { scanner := bufio.NewScanner(reader) buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 1024*1024) for scanner.Scan() { logf(os.Stdout, spec, "%s", scanner.Text()) } if err := scanner.Err(); err != nil { logf(os.Stderr, spec, "log pipe failed: %v", err) } } func logf(w io.Writer, spec serviceSpec, format string, args ...any) { line := fmt.Sprintf(format, args...) prefix := "[" + spec.Name + "]" if colorEnabled() && spec.Color != "" { prefix = "\x1b[" + spec.Color + "m" + prefix + "\x1b[0m" } printPlain(w, "%s %s", prefix, line) } func printPlain(w io.Writer, format string, args ...any) { logMu.Lock() defer logMu.Unlock() fmt.Fprintf(w, format+"\n", args...) } func colorEnabled() bool { if _, ok := os.LookupEnv("NO_COLOR"); ok { return false } return os.Getenv("TERM") != "dumb" } func serviceNames(specs []serviceSpec) string { names := make([]string, 0, len(specs)) for _, spec := range specs { names = append(names, spec.Name) } return strings.Join(names, ", ") } func signalContext() (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(context.Background()) signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) go func() { <-signals cancel() }() return ctx, cancel } func fatal(err error) { fmt.Fprintln(os.Stderr, err) os.Exit(1) }