5 minutes
File Watcher With Fsnotify
Sometimes we need to watch for modifications in specific locations in the filesystem, reacting to them differently.
As an example, an alert could be triggered if a specific file is modified or a backup procedure could start as new files are created into specific directories.
There are several tools and libraries to help doing that, but since I am evaluating libraries in Go, I have decided to play with fsnotify and write a bit about it.
fsnotify
It is a library written in Go that uses https://pkg.go.dev/golang.org/x/sys instead of https://pkg.go.dev/syscall (from the standard library).
Fsnotify is supported on Linux, macOS, Windows and other operating systems.
With fsnotify you can create a Watcher
instance that will emit a notification Event
for all files or directories (not recursively) added to it.
Goal
Define a simple interface that provides a common mechanism for reacting to changes notified against a specific file or directory.
Basically we will a pass specific location to be monitored by fsnotify
.
This location can be an existing file, directory or even a new location in the file system that may still not yet exist.
Quick start
First thing we need to know is how to use the Watcher provided by fsnotify.
Code must import github.com/fsnotify/fsnotify
and create a fsnotify.Watcher
instance.
watcher, err := fsnotify.NewWatcher()
After that you must add target files or directories to watch, like:
err = watcher.Add(filename)
One important thing to mention here is that you must pass an existing file or directory to be watched.
If you pass an invalid filename, fsnotify will report an error saying:
“no such file or directory
”.
To get around that, our implementation will also validate if the given file or directory exists and otherwise it will try to add the named file to the Watcher instance till it returns no error.
The notification events are sent to a channel named Events
in the Watcher
instance and it has a property named Op
that represents a file operation.
To validate the correct operation being notified you have to perform a bitwise
&
(AND) operation against fsnotify generalized operations and validate
that the result matches it. As an example, to validate if a given event notification
refers to a new file or directory being created you can do:
event.Op&fsnotify.Create == fsnotify.Create
With that we know what are the basics to write our own interface that helps reacting to file system modifications more easily.
type FSChangeHandler interface {
OnCreate(string)
OnUpdate(string)
OnRemove(string)
}
We can customize how to react to certain events more easily and leave the communication
with fsnotify
to be handled by an internal component.
In order to achieve that, the sample code will offer the following function:
func NewWatcher(name string, stopCh chan bool, handler FSChangeHandler) error
Then you can use it as you need and simply provide multiple implementations to react to specific events differently.
Solution
watcher.go
Here we have the NewWatcher function that uses watchCreated to wait for new file
or directory to be created and it will use the FSChangeHandler
interface to notify
events accordignly.
package watcher
import (
"log"
"os"
"time"
"github.com/fsnotify/fsnotify"
)
type FSChangeHandler interface {
OnCreate(string)
OnUpdate(string)
OnRemove(string)
}
func watchCreated(watcher *fsnotify.Watcher, name string) {
log.Printf("-> waiting for %s to exist", name)
go func() {
ticker := time.Tick(time.Second)
for {
select {
case <-ticker:
if err := watcher.Add(name); err == nil {
log.Printf("-> now it exists: %s", name)
return
}
}
}
}()
return
}
func NewWatcher(name string, stopCh chan bool, handler FSChangeHandler) error {
log.Printf("Creating watcher for: %s", name)
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
if _, err := os.Stat(name); err != nil && os.IsNotExist(err) {
watchCreated(watcher, name)
} else {
watcher.Add(name)
}
go func() {
for {
select {
case event := <-watcher.Events:
switch {
// useful for new files when watching directories
case event.Op&fsnotify.Create == fsnotify.Create:
handler.OnCreate(event.Name)
case event.Op&fsnotify.Write == fsnotify.Write:
handler.OnUpdate(event.Name)
case event.Op&fsnotify.Remove == fsnotify.Remove:
handler.OnRemove(event.Name)
// object being watched removed, watch for it to show up again
if event.Name == name {
watcher.Remove(name)
watchCreated(watcher, name)
}
}
case <-stopCh:
log.Printf("Done watching: %s", name)
watcher.Close()
return
}
}
}()
return nil
}
fw.go
Here is a sample usage of the watcher presented earlier.
It provides a FSChangeHandler
implementation that simply displays
the event type that has just been triggered and the filename.
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/fgiorgetti/go-playground/filewatcher/pkg/watcher"
)
type MyHandler struct {
}
func (m *MyHandler) OnCreate(name string) {
log.Printf("File has been created: %s", name)
}
func (m *MyHandler) OnUpdate(name string) {
log.Printf("File has been updated: %s", name)
}
func (m *MyHandler) OnRemove(name string) {
log.Printf("File has been removed: %s", name)
}
func main() {
stopCh := make(chan bool)
if len(os.Args) != 2 {
log.Fatalf("Use: %s file_or_directory", os.Args[0])
}
fileOrDir := os.Args[1]
err := watcher.NewWatcher(fileOrDir, stopCh, &MyHandler{})
if err != nil {
log.Fatalf("Error creating watcher: %v", err)
}
// var done string
fmt.Println("Press ENTER when done")
_, _ = fmt.Scanln()
close(stopCh)
time.Sleep(time.Second)
}
Running
To run it you must pass a single target file to be watched. Remember it may or may not exist.
In a separate terminal you can play with modifications to the respective file.
Once you’re done with it, just press ENTER in the terminal where the watcher is running.
Example:
go run fw.go /tmp/sample-location
It will show you something like:
2022/08/23 12:26:52 Creating watcher for: /tmp/sample-location
2022/08/23 12:26:52 -> waiting for /tmp/sample-location to exist
Press ENTER when done
In another terminal you can create a file and add more content to it, like:
echo "some data" >> /tmp/sample-location
echo "some more data" >> /tmp/sample-location
Then in the main terminal you will see:
2022/08/23 12:27:07 -> now it exists: /tmp/sample-location
2022/08/23 12:28:40 File has been updated: /tmp/sample-location
Remove the file now:
rm /tmp/sample-location
And in the main terminal you should see:
2022/08/23 12:29:56 File has been removed: /tmp/sample-location
2022/08/23 12:29:56 -> waiting for /tmp/sample-location to exist
Next, create the target /tmp/sample-location
as a directory and
create a file inside it with some sample content:
mkdir /tmp/sample-location
echo "some data" >> /tmp/sample-location/file1
You should see:
2022/08/23 12:31:18 -> now it exists: /tmp/sample-location
2022/08/23 12:31:40 File has been created: /tmp/sample-location/file1
2022/08/23 12:31:40 File has been updated: /tmp/sample-location/file1
I hope you find it useful.
System information
FSNotify v1.5.4 OS: Fedora 36 Go: 1.19