From cbc0597074fcac02a2b59de262cf6ad7e05103b8 Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Thu, 7 May 2026 20:44:03 +0800 Subject: [PATCH] RV: align infrastructure, optimize matching performance, and update documentation (by AI) --- CHANGELOG.md | 6 ++ TEST.md | 14 ++++- bench_test.go | 79 ++++++++++++++++++++++++++ go.mod | 4 +- watch.go | 151 ++++++++++++++++++++++++++------------------------ 5 files changed, 176 insertions(+), 78 deletions(-) create mode 100644 bench_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 803616d..cb34db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## v1.1.1 +- **性能优化**: 将 `Types`、`ExcludeTypes` 和 `Events` 的过滤逻辑从 $O(n)$ 优化为 $O(1)$ 的 Map 查找。 +- **基础设施对齐**: 更新依赖 `go/file` 至 v1.0.7, `go/encoding` 至 v1.0.6。 +- **代码质量**: 统一变量命名规范(`watcher`, `event`),重命名内部方法 `isMatchExclude` 为 `isExcluded` 提升可读性。 +- **测试增强**: 引入专门的 `bench_test.go`,补全匹配逻辑与防抖逻辑的性能基准测试。 + ## v1.1.0 - **核心升级**: 引入 `github.com/gobwas/glob` 引擎。 - **Gitignore 语义**: `Excludes` 现在支持 `**/node_modules/**` 等复杂路径模式,并能正确处理目录边界。 diff --git a/TEST.md b/TEST.md index 0b4b154..8adbce3 100644 --- a/TEST.md +++ b/TEST.md @@ -5,13 +5,20 @@ ``` === RUN TestWatch ---- PASS: TestWatch (0.20s) +--- PASS: TestWatch (0.21s) === RUN TestDebounce ---- PASS: TestDebounce (0.25s) +--- PASS: TestDebounce (0.26s) === RUN TestEasyStart --- PASS: TestEasyStart (0.00s) PASS -ok apigo.cc/go/watch 0.748s +ok apigo.cc/go/watch 0.849s +``` + +## 性能测试结果 (Benchmark) +``` +BenchmarkIsMatch-16 15307893 74.12 ns/op 0 B/op 0 allocs/op +BenchmarkIsExcluded-16 45664744 26.09 ns/op 0 B/op 0 allocs/op +BenchmarkDebounce-16 3345813 369.3 ns/op 224 B/op 5 allocs/op ``` ## 测试覆盖场景 @@ -23,3 +30,4 @@ ok apigo.cc/go/watch 0.748s - 验证 100ms 内对同一文件的 5 次连续写入被合并为 1 次回调。 4. **路径归一化**: 验证绝对路径下的准确匹配。 5. **极简接口**: 验证 `EasyStart` 的可用性。 +6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。 diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..888e7c6 --- /dev/null +++ b/bench_test.go @@ -0,0 +1,79 @@ +package watch + +import ( + "fmt" + "testing" + "time" +) + +func BenchmarkIsMatch(b *testing.B) { + config := Config{ + Paths: []string{"."}, + Types: []string{".go", ".js", ".ts", ".md"}, + ExcludeTypes: []string{".log", ".tmp", ".exe"}, + Excludes: []string{"**/node_modules/**", "vendor/**", "dist/**"}, + Events: []EventType{Create, Change}, + } + watcher, _ := Start(config, func(e *Event) {}) + defer watcher.Stop() + + testPath := "src/main.go" + b.ResetTimer() + for i := 0; i < b.N; i++ { + watcher.isMatch(testPath, Change, false) + } +} + +func BenchmarkIsExcluded(b *testing.B) { + config := Config{ + Excludes: []string{"**/node_modules/**", "vendor/**", "dist/**", "*.log"}, + } + watcher, _ := Start(config, func(e *Event) {}) + defer watcher.Stop() + + testPath := "/abs/path/to/project/node_modules/pkg/index.js" + b.ResetTimer() + for i := 0; i < b.N; i++ { + watcher.isExcluded(testPath, false) + } +} + +func BenchmarkDebounce(b *testing.B) { + config := Config{ + Paths: []string{"."}, + Debounce: 10 * time.Millisecond, + } + watcher, _ := Start(config, func(e *Event) {}) + defer watcher.Stop() + + event := &Event{ + Path: "test.txt", + Type: Change, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + watcher.debounce(event) + } +} + +func BenchmarkKeyGeneration(b *testing.B) { + event := &Event{ + Path: "/very/long/path/to/some/file/in/the/deep/directory/structure/test.txt", + Type: Change, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = event.Path + ":" + string(event.Type) + } +} + +func BenchmarkKeyGenerationWithFmt(b *testing.B) { + event := &Event{ + Path: "/very/long/path/to/some/file/in/the/deep/directory/structure/test.txt", + Type: Change, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = fmt.Sprintf("%s:%s", event.Path, event.Type) + } +} diff --git a/go.mod b/go.mod index dee3a6d..421d806 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module apigo.cc/go/watch go 1.25.0 require ( - apigo.cc/go/file v1.0.6 + apigo.cc/go/file v1.0.7 apigo.cc/go/timer v1.0.6 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 @@ -11,7 +11,7 @@ require ( require ( apigo.cc/go/cast v1.2.8 // indirect - apigo.cc/go/encoding v1.0.5 // indirect + apigo.cc/go/encoding v1.0.6 // indirect apigo.cc/go/rand v1.0.5 // indirect apigo.cc/go/safe v1.0.5 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/watch.go b/watch.go index 5bb89ff..0cf9374 100644 --- a/watch.go +++ b/watch.go @@ -49,7 +49,11 @@ type Watcher struct { mu sync.RWMutex stopChan chan struct{} - excludeGlobs []glob.Glob + excludeGlobs []glob.Glob + eventMap map[EventType]struct{} + typeMap map[string]struct{} + exTypeMap map[string]struct{} + debounceGroups sync.Map // map[string]*timer.Debouncer } @@ -64,61 +68,72 @@ func Start(config Config, callback func(*Event)) (*Watcher, error) { if len(config.Events) == 0 { config.Events = []EventType{Create, Change, Remove, Rename} } - for i, t := range config.Types { - if !strings.HasPrefix(t, ".") { - config.Types[i] = "." + t - } - } - for i, t := range config.ExcludeTypes { - if !strings.HasPrefix(t, ".") { - config.ExcludeTypes[i] = "." + t - } - } - w := &Watcher{ + watcher := &Watcher{ config: &config, callback: callback, fsWatcher: fsWatcher, isRunning: true, stopChan: make(chan struct{}), + eventMap: make(map[EventType]struct{}), + typeMap: make(map[string]struct{}), + exTypeMap: make(map[string]struct{}), + } + + for _, et := range config.Events { + watcher.eventMap[et] = struct{}{} + } + + for _, t := range config.Types { + if !strings.HasPrefix(t, ".") { + t = "." + t + } + watcher.typeMap[t] = struct{}{} + } + + for _, t := range config.ExcludeTypes { + if !strings.HasPrefix(t, ".") { + t = "." + t + } + watcher.exTypeMap[t] = struct{}{} } // 编译 Glob 模式 for _, p := range config.Excludes { if g, err := glob.Compile(p, '/'); err == nil { - w.excludeGlobs = append(w.excludeGlobs, g) + watcher.excludeGlobs = append(watcher.excludeGlobs, g) } } // 初始添加路径 for _, p := range config.Paths { - if err := w.Add(p); err != nil { + if err := watcher.Add(p); err != nil { _ = fsWatcher.Close() return nil, err } } - go w.run() + go watcher.run() - return w, nil + return watcher, nil } // Stop 停止监听 -func (w *Watcher) Stop() { - w.mu.Lock() - if !w.isRunning { - w.mu.Unlock() +func (watcher *Watcher) Stop() { + watcher.mu.Lock() + if !watcher.isRunning { + watcher.mu.Unlock() return } - w.isRunning = false - w.mu.Unlock() + watcher.isRunning = false + watcher.mu.Unlock() - _ = w.fsWatcher.Close() - <-w.stopChan + _ = watcher.fsWatcher.Close() + <-watcher.stopChan } // Add 动态添加监听路径(递归) -func (w *Watcher) Add(path string) error { +func (watcher *Watcher) Add(path string) error { absPath, err := filepath.Abs(path) if err != nil { return err @@ -134,35 +149,35 @@ func (w *Watcher) Add(path string) error { } if info.IsDir() { - if w.isMatchExclude(p, true) { + if watcher.isExcluded(p, true) { return filepath.SkipDir } - return w.fsWatcher.Add(p) + return watcher.fsWatcher.Add(p) } return nil }) } -func (w *Watcher) Remove(path string) error { +func (watcher *Watcher) Remove(path string) error { absPath, err := filepath.Abs(path) if err != nil { return err } - return w.fsWatcher.Remove(absPath) + return watcher.fsWatcher.Remove(absPath) } -func (w *Watcher) run() { - defer close(w.stopChan) +func (watcher *Watcher) run() { + defer close(watcher.stopChan) for { select { - case fsEvent, ok := <-w.fsWatcher.Events: + case fsEvent, ok := <-watcher.fsWatcher.Events: if !ok { return } - w.handleFsEvent(fsEvent) + watcher.handleFsEvent(fsEvent) - case _, ok := <-w.fsWatcher.Errors: + case _, ok := <-watcher.fsWatcher.Errors: if !ok { return } @@ -170,7 +185,7 @@ func (w *Watcher) run() { } } -func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) { +func (watcher *Watcher) handleFsEvent(fsEvent fsnotify.Event) { name, err := filepath.Abs(fsEvent.Name) if err != nil { name = fsEvent.Name @@ -186,7 +201,7 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) { case fsEvent.Has(fsnotify.Create): eventType = Create if isDir { - _ = w.Add(name) + _ = watcher.Add(name) } case fsEvent.Has(fsnotify.Write): eventType = Change @@ -198,7 +213,7 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) { return } - if !w.isMatch(name, eventType, isDir) { + if !watcher.isMatch(name, eventType, isDir) { return } @@ -208,60 +223,45 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) { IsDir: isDir, } - if w.config.Debounce > 0 { - w.debounce(event) + if watcher.config.Debounce > 0 { + watcher.debounce(event) } else { - w.callback(event) + watcher.callback(event) } } -func (w *Watcher) debounce(e *Event) { - key := e.Path + string(e.Type) - d, _ := w.debounceGroups.LoadOrStore(key, timer.NewDebouncer(w.config.Debounce, func() { - w.callback(e) - w.debounceGroups.Delete(key) +func (watcher *Watcher) debounce(event *Event) { + // 优化 key 生成,避免过多的字符串拼接,但在此时直接拼接是比较简单且易读的 + key := event.Path + ":" + string(event.Type) + d, _ := watcher.debounceGroups.LoadOrStore(key, timer.NewDebouncer(watcher.config.Debounce, func() { + watcher.callback(event) + watcher.debounceGroups.Delete(key) })) d.(*timer.Debouncer).Trigger() } -func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool { +func (watcher *Watcher) isMatch(path string, et EventType, isDir bool) bool { // 1. 事件类型过滤 - eventMatch := false - for _, configEt := range w.config.Events { - if et == configEt { - eventMatch = true - break - } - } - if !eventMatch { + if _, ok := watcher.eventMap[et]; !ok { return false } // 2. 排除过滤 (通用) - if w.isMatchExclude(path, isDir) { + if watcher.isExcluded(path, isDir) { return false } // 3. 类型过滤 (仅针对文件) if !isDir { ext := filepath.Ext(path) - if len(w.config.ExcludeTypes) > 0 { - for _, t := range w.config.ExcludeTypes { - if ext == t { - return false - } + if len(watcher.exTypeMap) > 0 { + if _, ok := watcher.exTypeMap[ext]; ok { + return false } } - if len(w.config.Types) > 0 { - typeMatch := false - for _, t := range w.config.Types { - if ext == t { - typeMatch = true - break - } - } - if !typeMatch { + if len(watcher.typeMap) > 0 { + if _, ok := watcher.typeMap[ext]; !ok { return false } } @@ -270,11 +270,16 @@ func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool { return true } -func (w *Watcher) isMatchExclude(path string, isDir bool) bool { +func (watcher *Watcher) isExcluded(path string, isDir bool) bool { + if len(watcher.excludeGlobs) == 0 { + return false + } + // 统一使用 / 作为路径分隔符进行匹配,对齐 gitignore 习惯 p := filepath.ToSlash(path) base := filepath.Base(path) - for _, g := range w.excludeGlobs { + + for _, g := range watcher.excludeGlobs { if g.Match(p) || g.Match(base) { return true } @@ -286,8 +291,8 @@ func (w *Watcher) isMatchExclude(path string, isDir bool) bool { return false } -func (w *Watcher) WatchList() []string { - return w.fsWatcher.WatchList() +func (watcher *Watcher) WatchList() []string { + return watcher.fsWatcher.WatchList() } func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) {