RV: align infrastructure, optimize matching performance, and update documentation (by AI)

This commit is contained in:
AI Engineer 2026-05-07 20:44:03 +08:00
parent 2ec560179b
commit cbc0597074
5 changed files with 176 additions and 78 deletions

View File

@ -1,5 +1,11 @@
# CHANGELOG # 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 ## v1.1.0
- **核心升级**: 引入 `github.com/gobwas/glob` 引擎。 - **核心升级**: 引入 `github.com/gobwas/glob` 引擎。
- **Gitignore 语义**: `Excludes` 现在支持 `**/node_modules/**` 等复杂路径模式,并能正确处理目录边界。 - **Gitignore 语义**: `Excludes` 现在支持 `**/node_modules/**` 等复杂路径模式,并能正确处理目录边界。

14
TEST.md
View File

@ -5,13 +5,20 @@
``` ```
=== RUN TestWatch === RUN TestWatch
--- PASS: TestWatch (0.20s) --- PASS: TestWatch (0.21s)
=== RUN TestDebounce === RUN TestDebounce
--- PASS: TestDebounce (0.25s) --- PASS: TestDebounce (0.26s)
=== RUN TestEasyStart === RUN TestEasyStart
--- PASS: TestEasyStart (0.00s) --- PASS: TestEasyStart (0.00s)
PASS 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 次回调。 - 验证 100ms 内对同一文件的 5 次连续写入被合并为 1 次回调。
4. **路径归一化**: 验证绝对路径下的准确匹配。 4. **路径归一化**: 验证绝对路径下的准确匹配。
5. **极简接口**: 验证 `EasyStart` 的可用性。 5. **极简接口**: 验证 `EasyStart` 的可用性。
6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。

79
bench_test.go Normal file
View File

@ -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)
}
}

4
go.mod
View File

@ -3,7 +3,7 @@ module apigo.cc/go/watch
go 1.25.0 go 1.25.0
require ( require (
apigo.cc/go/file v1.0.6 apigo.cc/go/file v1.0.7
apigo.cc/go/timer v1.0.6 apigo.cc/go/timer v1.0.6
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
@ -11,7 +11,7 @@ require (
require ( require (
apigo.cc/go/cast v1.2.8 // indirect 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/rand v1.0.5 // indirect
apigo.cc/go/safe v1.0.5 // indirect apigo.cc/go/safe v1.0.5 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect

151
watch.go
View File

@ -49,7 +49,11 @@ type Watcher struct {
mu sync.RWMutex mu sync.RWMutex
stopChan chan struct{} 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 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 { if len(config.Events) == 0 {
config.Events = []EventType{Create, Change, Remove, Rename} 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, config: &config,
callback: callback, callback: callback,
fsWatcher: fsWatcher, fsWatcher: fsWatcher,
isRunning: true, isRunning: true,
stopChan: make(chan struct{}), 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 模式 // 编译 Glob 模式
for _, p := range config.Excludes { for _, p := range config.Excludes {
if g, err := glob.Compile(p, '/'); err == nil { 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 { for _, p := range config.Paths {
if err := w.Add(p); err != nil { if err := watcher.Add(p); err != nil {
_ = fsWatcher.Close() _ = fsWatcher.Close()
return nil, err return nil, err
} }
} }
go w.run() go watcher.run()
return w, nil return watcher, nil
} }
// Stop 停止监听 // Stop 停止监听
func (w *Watcher) Stop() { func (watcher *Watcher) Stop() {
w.mu.Lock() watcher.mu.Lock()
if !w.isRunning { if !watcher.isRunning {
w.mu.Unlock() watcher.mu.Unlock()
return return
} }
w.isRunning = false watcher.isRunning = false
w.mu.Unlock() watcher.mu.Unlock()
_ = w.fsWatcher.Close() _ = watcher.fsWatcher.Close()
<-w.stopChan <-watcher.stopChan
} }
// Add 动态添加监听路径(递归) // Add 动态添加监听路径(递归)
func (w *Watcher) Add(path string) error { func (watcher *Watcher) Add(path string) error {
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return err return err
@ -134,35 +149,35 @@ func (w *Watcher) Add(path string) error {
} }
if info.IsDir() { if info.IsDir() {
if w.isMatchExclude(p, true) { if watcher.isExcluded(p, true) {
return filepath.SkipDir return filepath.SkipDir
} }
return w.fsWatcher.Add(p) return watcher.fsWatcher.Add(p)
} }
return nil return nil
}) })
} }
func (w *Watcher) Remove(path string) error { func (watcher *Watcher) Remove(path string) error {
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return err return err
} }
return w.fsWatcher.Remove(absPath) return watcher.fsWatcher.Remove(absPath)
} }
func (w *Watcher) run() { func (watcher *Watcher) run() {
defer close(w.stopChan) defer close(watcher.stopChan)
for { for {
select { select {
case fsEvent, ok := <-w.fsWatcher.Events: case fsEvent, ok := <-watcher.fsWatcher.Events:
if !ok { if !ok {
return return
} }
w.handleFsEvent(fsEvent) watcher.handleFsEvent(fsEvent)
case _, ok := <-w.fsWatcher.Errors: case _, ok := <-watcher.fsWatcher.Errors:
if !ok { if !ok {
return 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) name, err := filepath.Abs(fsEvent.Name)
if err != nil { if err != nil {
name = fsEvent.Name name = fsEvent.Name
@ -186,7 +201,7 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
case fsEvent.Has(fsnotify.Create): case fsEvent.Has(fsnotify.Create):
eventType = Create eventType = Create
if isDir { if isDir {
_ = w.Add(name) _ = watcher.Add(name)
} }
case fsEvent.Has(fsnotify.Write): case fsEvent.Has(fsnotify.Write):
eventType = Change eventType = Change
@ -198,7 +213,7 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
return return
} }
if !w.isMatch(name, eventType, isDir) { if !watcher.isMatch(name, eventType, isDir) {
return return
} }
@ -208,60 +223,45 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
IsDir: isDir, IsDir: isDir,
} }
if w.config.Debounce > 0 { if watcher.config.Debounce > 0 {
w.debounce(event) watcher.debounce(event)
} else { } else {
w.callback(event) watcher.callback(event)
} }
} }
func (w *Watcher) debounce(e *Event) { func (watcher *Watcher) debounce(event *Event) {
key := e.Path + string(e.Type) // 优化 key 生成,避免过多的字符串拼接,但在此时直接拼接是比较简单且易读的
d, _ := w.debounceGroups.LoadOrStore(key, timer.NewDebouncer(w.config.Debounce, func() { key := event.Path + ":" + string(event.Type)
w.callback(e) d, _ := watcher.debounceGroups.LoadOrStore(key, timer.NewDebouncer(watcher.config.Debounce, func() {
w.debounceGroups.Delete(key) watcher.callback(event)
watcher.debounceGroups.Delete(key)
})) }))
d.(*timer.Debouncer).Trigger() 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. 事件类型过滤 // 1. 事件类型过滤
eventMatch := false if _, ok := watcher.eventMap[et]; !ok {
for _, configEt := range w.config.Events {
if et == configEt {
eventMatch = true
break
}
}
if !eventMatch {
return false return false
} }
// 2. 排除过滤 (通用) // 2. 排除过滤 (通用)
if w.isMatchExclude(path, isDir) { if watcher.isExcluded(path, isDir) {
return false return false
} }
// 3. 类型过滤 (仅针对文件) // 3. 类型过滤 (仅针对文件)
if !isDir { if !isDir {
ext := filepath.Ext(path) ext := filepath.Ext(path)
if len(w.config.ExcludeTypes) > 0 { if len(watcher.exTypeMap) > 0 {
for _, t := range w.config.ExcludeTypes { if _, ok := watcher.exTypeMap[ext]; ok {
if ext == t { return false
return false
}
} }
} }
if len(w.config.Types) > 0 { if len(watcher.typeMap) > 0 {
typeMatch := false if _, ok := watcher.typeMap[ext]; !ok {
for _, t := range w.config.Types {
if ext == t {
typeMatch = true
break
}
}
if !typeMatch {
return false return false
} }
} }
@ -270,11 +270,16 @@ func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
return true 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 习惯 // 统一使用 / 作为路径分隔符进行匹配,对齐 gitignore 习惯
p := filepath.ToSlash(path) p := filepath.ToSlash(path)
base := filepath.Base(path) base := filepath.Base(path)
for _, g := range w.excludeGlobs {
for _, g := range watcher.excludeGlobs {
if g.Match(p) || g.Match(base) { if g.Match(p) || g.Match(base) {
return true return true
} }
@ -286,8 +291,8 @@ func (w *Watcher) isMatchExclude(path string, isDir bool) bool {
return false return false
} }
func (w *Watcher) WatchList() []string { func (watcher *Watcher) WatchList() []string {
return w.fsWatcher.WatchList() return watcher.fsWatcher.WatchList()
} }
func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) { func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) {