438 lines
9.5 KiB
Go
438 lines
9.5 KiB
Go
// 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"
|
||
"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
|
||
}
|
||
|
||
type serviceRunner struct {
|
||
spec serviceSpec
|
||
|
||
mu sync.Mutex
|
||
cmd *exec.Cmd
|
||
done chan struct{}
|
||
stopping bool
|
||
}
|
||
|
||
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
|
||
if err := runner.start(root); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] start failed: %v\n", spec.Name, err)
|
||
}
|
||
watchers = append(watchers, newWatchState(root, spec))
|
||
}
|
||
|
||
fmt.Printf("dev-run watching %d service(s): %s\n", len(specs), serviceNames(specs))
|
||
fmt.Println("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 {
|
||
fmt.Fprintf(os.Stderr, "[%s] watch failed: %v\n", watcher.spec.Name, 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]
|
||
fmt.Printf("[%s] change detected, restarting\n", name)
|
||
runner.restart(root)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func allServices() []serviceSpec {
|
||
// 启动顺序按低层依赖到入口服务排列;gRPC dial 当前不阻塞,但这样能让日志更接近真实依赖链路。
|
||
return []serviceSpec{
|
||
appService("user-service"),
|
||
appService("activity-service"),
|
||
appService("wallet-service"),
|
||
appService("game-service"),
|
||
appService("room-service"),
|
||
appService("notice-service"),
|
||
appService("cron-service"),
|
||
appService("gateway-service"),
|
||
{
|
||
Name: "admin",
|
||
Dir: "server/admin",
|
||
Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"},
|
||
WatchRoot: []string{
|
||
"server/admin",
|
||
"api",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func appService(name 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",
|
||
},
|
||
}
|
||
}
|
||
|
||
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",
|
||
}
|
||
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.Name, stdout)
|
||
go prefixPipe(r.spec.Name, stderr)
|
||
go r.wait(cmd, r.done)
|
||
|
||
fmt.Printf("[%s] started: go %s\n", r.spec.Name, strings.Join(r.spec.Args, " "))
|
||
return nil
|
||
}
|
||
|
||
func (r *serviceRunner) restart(repoRoot string) {
|
||
r.stop()
|
||
if err := r.start(repoRoot); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] restart failed: %v\n", r.spec.Name, 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 {
|
||
fmt.Printf("[%s] stopped\n", r.spec.Name)
|
||
return
|
||
}
|
||
fmt.Fprintf(os.Stderr, "[%s] exited: %v\n", r.spec.Name, err)
|
||
return
|
||
}
|
||
fmt.Printf("[%s] exited\n", r.spec.Name)
|
||
}
|
||
|
||
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 prefixPipe(name string, reader io.Reader) {
|
||
scanner := bufio.NewScanner(reader)
|
||
buf := make([]byte, 0, 64*1024)
|
||
scanner.Buffer(buf, 1024*1024)
|
||
for scanner.Scan() {
|
||
fmt.Printf("[%s] %s\n", name, scanner.Text())
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[%s] log pipe failed: %v\n", name, err)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|