RV: align infrastructure, optimize matching performance, and update documentation (by AI)
This commit is contained in:
parent
2ec560179b
commit
cbc0597074
@ -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
14
TEST.md
@ -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
79
bench_test.go
Normal 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
4
go.mod
@ -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
151
watch.go
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user