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
|
||||
|
||||
## 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/**` 等复杂路径模式,并能正确处理目录边界。
|
||||
|
||||
14
TEST.md
14
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)$ 查找能力。
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
147
watch.go
147
watch.go
@ -50,6 +50,10 @@ type Watcher struct {
|
||||
stopChan chan struct{}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user