Dev

Go: Отложенная обработка событий файловой системы

Допустим, вам нужно что-то сделать при возникновении какого-то события файловой системы. Например, перезапустить веб-сервер при изменении файлов. Довольно частая практика при разработке: повесить "слушателя" файловой системы, запустить приложение, сразу же после редактирования файлов "на лету" перекомпилировать проект и заново запустить его.

Этот сайт написан на Go, и недавно я решил добавить в него подобный hot-reload для markdown-файлов с постами: кладешь новый файл в папку, веб-сервер это замечает и переналивает внутренний in-memory-storage с публикациями без перезапуска самого себя. При этом мне хотелось именно "слушать" файловую систему, а не сканировать ее раз в несколько секунд.

Для этого уже написана хорошая библиотека fsnotify, которая умеет ловить события из каталогов. (К сожалению, нерекурсивно, но у меня этих каталогов не так много.)

В README приводится довольно понятный пример использования. Я обернул его в функцию Watcher() и добавил туда свой канал, в который я что-то шлю, когда что-то происходит. Тип событий меня мало интересует, поэтому просто шлю всегда 1. Функцию Watcher() я обернул в другую функцию Watch(), которая дергает переданную ей reload-функцию на каждом изменении.

Выглядело это примерно так (обработка ошибок и некоторые конструкции намеренно пропущены):

func main() {
    dirs := []string[
        "/foo",
        "/bar",
    ]
    go Watch(dirs, func() {
        reloadStuff()
    })
}

func Watch(dirs []string, reload func()) {
    ch := make(chan int)
    go watcher(dirs, ch)

    // Выполнение reload-функции на каждом событии
    // файловой системы
    for range ch {
        reload()
    }
}

func Watcher(dirs []string, ch chan int) {
    watcher, _ := fsnotify.NewWatcher()
    defer watcher.Close()

    done := make(chan bool)
    go func() {
        for {
            select {
            case event, _ := <-watcher.Events:
                // Шлем в наш канал уведомление
                // о любом событии из watcher
                ch <- 1
            case err, _ := <-watcher.Errors:
                log.Println("error:", err)
            }
        }
    }()
    for _, dir := range dirs {
        watcher.Add(dir)
    }
    <-done
}

Я попробовал это запустить, погонял тесты и обнаружил, что при изменении файла может выстреливать больше одного события (например, последовательно CHMOD и WRITE). А если изменить несколько файлов одновременно (git checkout, rsync, touch *.*), то их будет еще больше, и на каждое из них стриггерится мой hot-reload.

Мне же по сути надо триггернуть его только один раз, если за короткий промежуток времени пришло много событий. То есть накопить их, подождать полсекунды, и если больше ничего не пришло, сделать свое дело.

Признаюсь, самостоятельно я так и не смог придумать адекватное решение, но заметил, что CompileDaemon, который я как раз использую при разработке для перекомпиляции сорцов, работает ровно так, как я хочу. Решение оттуда вышло, скажем так, элегантное (насколько это может быть элегантным в Go), и заключается в использовании time.After(): она запускает таймер и по истечение указанного времени посылает текущее время в возвращаемый канал.

В итоге функция Watch() претерпела следующие изменения:

func Watch(dirs []string, reload func()) {
    ch := make(chan int)
    go watcher(dirs, ch)

    // Функция, возвращающая канал, в который приходит
    // текущее время по окончании указанного временного
    // интервала.
    createThreshold := func() <-chan time.Time {
        return time.After(time.Duration(500 * time.Millisecond))
    }

    // Можно сделать threshold := createThreshold(),
    // если нужно триггернуть reload() при первом запуске.
    // Мне такое не надо, поэтому достаточно пустого канала.
    threshold := make(<-chan time.Time)
    for {
        select {
        case <-ch:
            // На каждом событии просто пересоздаем threshold,
            // чтобы `case <-threshold` отложился еще на 500 мс.
            threshold = createThreshold()
        case <-threshold:
            // Если в течение 500 мс в канал `ch` больше ничего
            // не приходило, можно триггернуть reload и ждать
            // следующего поступления событий.
            reload()
        }
    }
}

Сработало ровно так, как мне хотелось, и теперь я могу без рестарта веб-сервера обновлять все файлы с данными и сам сайт через rsync в течение 500 мс.

Я изобрел PHP.