大抵のサーバープログラムはなんらんかの設定ファイルをもっている。 PostgreSQLでいうとpostgresql.confやpg_hba.conf、nginxでいうとnginx.confがある。
これらにはシグナルをつかって設定ファイルをサーバープロセスの再起動なしで反映するための仕組みがある。
例えば、postgresqlならpg_ctl reloadで、nginxならnginx -s reloadコマンドを実行すると SIGHUPがメインのプロセスに送られ、サーバーの設定を再度読み込んでくれる。 これによってサーバーのダウンタイムなしで work_mem(SQLのソート等に利用可能なメモリの量)などの値を 変更することができる。

一方でRails(developmentモード)やwebpack-dev-serverの場合、ファイルを変更するとそれだけで 設定を反映してくれる。(これは設定ファイルというよりもソースコードそのものの変更)
Railsやwebpack-dev-serverの場合、開発中の確認作業を便利にするために、シグナルを明に受けなくても 設定が反映されるようになっているのだろう。

このようにファイルが変わっただけで設定を勝手に反映してくれるのは、意図せぬ変更が発生する可能性があるので、プロダクション環境のサーバーには不向きだが、 個人的に使うような場合にはとても便利だ。
Goで個人的に使うサーバープロセスを立てたかったので、これを試してみた。

inotifyとfsnotify

ファイルの中身を定期的ポーリングし 変更を検知することもできるが、このような方法は あまり効率的ではない。 できれば、ファイルへの変更があった場合にのみ設定ファイルを再度読み込む ようなサーバープロセスにしたい。
Linuxの場合inotify で取得したファイルディスクリプタを epoll などで監視することによって、 OS側からファイルへの変更があった場合に通知してもらうことができる。

これらのシステムコールを直接使って監視を行うこともできるが、 Goではfsnotify というライブラリがあり、うまくラップしてくれている。 しかもLinux以外のOSも別のシステムコールを使うことでサポートしてくれている。

fsnotifyの使い方

基本的な使い方としては以下のような形になる

package main

import (
	"github.com/fsnotify/fsnotify"
	"log"
)

func main() {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		panic(err)
	}
	// sample_fileを監視
	if err := watcher.Add("sample_file"); err != nil {
		panic(err)
	}

	for {
		select {
		case event := <-watcher.Events:
			log.Printf("EVENT: %+v\n", event.Op)
		}
	}
}

上を動作させた状態でsample_fileに対して以下の操作を行ってみよう。

$ echo "test" >> sample_file
$ touch sample_file
$ rm sample_file

そうすると各操作が行われるごとに、以下のようにイベントの種類を出力してくれる。

2021/10/24 15:49:33 EVENT: WRITE
2021/10/24 15:49:39 EVENT: CHMOD
2021/10/24 15:49:56 EVENT: REMOVE

Goにおける設定ファイル

サーバープロセスの設定ファイルとしては以下のようなYamlに値を書いていくことにしたい。

a: 1
b: "test"

このようなYamlをGoの内部で使用しようとする場合、以下のような感じでYamlをstructにマッピング してから使用するケースがほとんどだと思う。

import (
	"fmt"
	"gopkg.in/yaml.v2"
	"io/ioutil"
)

type Config struct {
	A int
	B string
}

// Yamlから設定内容を読み込みConfigを返却する
func NewConfig(filename string) (*Config, error) {
	c := &Config{}
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	if err := yaml.Unmarshal([]byte(data), &c); err != nil {
		return nil, err
	}

	return c, nil
}

上のようなConfig型は以下のように使用することでYamlないの設定内容を読みだすことができる。

func main() {
	config, err := NewConfig("config.yaml")
	if err != nil {
		panic(err)
	}
	fmt.Printf("%d", config.A) // => 1
	fmt.Printf("%s", config.B) // => "test"
}

上のようなConfig型が最初に与えられたファイル(上の場合はconfig.yaml)への変更を 自動で検知し変更内容が反映されるようにしてみる。

変更の自動検知と反映

Config型の自動更新を行う場合には、fsnotifyによってファイルの変更を検知した際に ファイルを再度読み込んで、それをConfig型の属性に再度反映させてやるという操作が必要になる。 再度のyamlの読み込みに備えてファイル名を記憶しておく必要があるのでstructのprivateな属性としてfilename を用意しておく

type Config struct {
	A        int
	B        string
	filename string
}

また、元のコードからYamlの読み出しと構造体へのマッピングを行っている箇所をloadFile として切り出しておく。 こうすることでConfig型を初期化するときだけでなく後にfsnofityで変更を検知した際にも 同じloadFileを呼び出すことで変更を検知することができる。

func (c *Config) loadFile() error {
	data, err := ioutil.ReadFile(c.filename)
	if err != nil {
		return err
	}
	err = yaml.Unmarshal([]byte(data), &c)
	return err
}

fsnotifyによる変更の検知はchannelを使って行うことができる。 この変更の検知を行うメソッドrunを以下のように定義する。

func (c *Config) run() error {
	watcher, err := fsnotify.NewWatcher()
	defer watcher.Close()
	if err != nil {
		return err
	}
	if err := watcher.Add(c.filename); err != nil {
		return err
	}

	for {
		select {
		case event := <-watcher.Events:
			if event.Op&fsnotify.Write == fsnotify.Write {
				// 書き込み(fsnotify.Write)があるとここを通る
				err := c.loadFile() // 設定の読み出し
				if err != nil {
					log.Printf("Error: %s", err)
				} else {
					log.Printf("Refreshed setting from %s", c.filename)
				}
			}
		case err := <-watcher.Errors:
			log.Printf("Error: %s", err)
		}
	}
	return nil
}

あとはNewConfig内でrunをgoroutineで呼び出すようにしておけば、 run内のforループが走り続けるので、自動更新が行われる。

func NewConfig(filename string) (*Config, error) {
	config := &Config{filename: filename}

	if err := config.loadFile(); err != nil {
		return nil, err
	}
	// runをgoroutineで呼び出し
	go config.run()
	return config, nil
}

最終的なソースはこれ

使い方

変更の自動検知と反映を行う前と同様に config, err := NewConfig("config.yaml") のように変数に代入して使用する。 config.Aconfig.Bのような形式で属性にアクセスすると その時にYamlに記載されている最新の値を読みだすことができる。
ただし、このままだと設定の読み書きと、自動検知時の属性の更新の間で競合が発生する可能性 があるので、きちんとしたものを作る際にはmutexなどでアクセスのされ方を制御す必要がある。