【Poteto】自作Webフレームワークにホットリロードを実装した話


お正月はどうお過ごしでしたでしょうか?私は自作Webフレームワークの開発をしながらGo正月を過ごしました。

https://github.com/poteto-go/poteto

これまでの開発の記録として、前回の記事を貼っておきます。ぜひ興味があればご覧ください。

https://zenn.dev/poteto0/articles/aae407f0d21f0f

ホットリロード

ホットリロードとは

ホットリロードは、ファイルシステムに変更があると、アプリケーションサーバーのプロセスをkillしてもう一度立ち上げなおす仕組みです。開発中に、「ファイル変更→プロセスkill→立ち上げ」のステップを踏むことなく、スムーズに開発できる点が魅力です。

なぜホットリロード

  • 単純に仕組みに興味があった
  • 参考にしているgolangのフレームワークであるEchoには実装されていない(はず?)で差別化できると思った

ホットリロードの仕組み

開発のプロセスとしてはまず仕組みを調べることから始めました。こちらの記事を参考にしました。

https://www.docswell.com/s/hireroo/524QPR-lightning_techtalks_02

以下は、私がつたない絵で図に表したものです。

ホットリロードの仕組み

以下の順序でホットリロードが達成されます。

  1. FileWatcherがFileSystemを監視
  2. FileWatcherがファイルの変更を検知したシグナルを送る
  3. シグナルによってBuildRunnerがAppServerのプロセスをkillして再スタート
  4. アプリサーバーからのログをLogTransporterがAgentServerに転送

ホットリロードの実装

cliツール上に実装していきました(整備があんま整ってなくて穴だらけ、、、)。

https://github.com/poteto-go/poteto-cli

こっからは結構長いです。

FileWatcher

FileSystemの監視には以下のライブラリを使っています。

https://github.com/fsnotify/fsnotify

簡単に言えば、ファイル変更を検知した際に、FileChangeStreamチャネルに入力を行います。

fileWatcher.go
func (client *runnerClient) FileWatcher(ctx stdContext.Context, fileChangeStream chan<- struct{}) func() error {
	return func() error {
		var (
			timer     *time.Timer
			lastEvent fsnotify.Event
		)
		timer = time.NewTimer(time.Millisecond)
		<-timer.C // timer should be expired at first

		for {
			select {
			...

			// ファイル変更
			case event, ok := <-client.watcher.Events:
				...
				lastEvent = event
				timer.Reset(time.Millisecond)

			// 複数回イベントが発行されるため、timerを上で作り出して、一定時間後に処理する
			case <-timer.C:
				...
				switch {
				// reload event
				// write, create, remove, rename
				case lastEvent.Has(fsnotify.Write),
					lastEvent.Has(fsnotify.Create),
					lastEvent.Has(fsnotify.Remove),
					lastEvent.Has(fsnotify.Rename):

					fileChangeStream <- struct{}{}

				...
				}

			...
			}
		}
	}
}

工夫した点でいうと、最初はファイル変更の検知を無限ループで回していましたが、1回のファイル変更を2-3回検知してしまうという問題があったので、タイマーを設けて1ms毎にイベントを検知するようにしました。

BuildRunner

BuildRunnerはまず最初に一度AppServerを起動し、FileChangeStreamの入力に対して、アプリの再ビルドを行います

buildRunner.go
func (client *runnerClient) BuildRunner(ctx stdContext.Context, fileChangeStream chan struct{}) func() error {
	return func() error {
		errChan := make(chan error, 1)
		go func() {
			client.AsyncBuild(ctx, errChan)
		}()

		for {
			select {
			...
			// rebuild
			case <-fileChangeStream:
				go func() {
					client.AsyncBuild(ctx, errChan)
				}()
			}
		}
	}
}

AppSeverプロセスのKill

AppServerのプロセスIDを保存しておいて、それ自身や、子プロセスに対してSIGTERMを送信します。

killProcess.go
func (client *runnerClient) killProcess() error {
	...
	if err := client.killByOS(); err != nil {
		return err
	}
	return nil
}

func (client *runnerClient) killByOS() error {
	switch runtime.GOOS {
	...
	case "linux", "ubuntu":
		// -pid
		// https://makiuchi-d.github.io/2020/05/10/go-kill-child-process.ja.html
		return syscall.Kill(-client.pid, syscall.SIGTERM)
  ...
	}
}

ゾンビプロセスが残ってしまったりと、意外とここがHotReloadの実装で一番苦労しました。以下を参考にしたものが、上手く動作しました。恐らく子プロセスが残ると、ゾンビ化されたプロセスが回収されきらないのだと理解しました。詳しい方捕捉ください。

https://makiuchi-d.github.io/2020/05/10/go-kill-child-process.ja.html

またWindowsではsyscall.Killが使えないので、現状これが使えるOSでないと動作しません

AppServerのBuild

以前のプロセスが残っていれば、killし、設定ファイルからbuildScript(Goのファイル)パスを取得して、go run <script.go>をします。立ち上げたプロセスのPIDは控えておきます。また、ログをAgentServerに流すために、logStreamを保存しておきます。

build.go
func (client *runnerClient) Build(ctx stdContext.Context) error {
	client.startupMutex.Lock()

	if err := client.killProcess(); err != nil {
		...
		return err
	}

	// run build script
	cmd := exec.Command("go", "run", client.option.BuildScriptPath)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Setpgid: true,
	}
	client.logStream, _ = cmd.StdoutPipe()
	client.reader = bufio.NewReader(client.logStream)

	// async start
	if err := cmd.Start(); err != nil {
		client.startupMutex.Unlock()
		return err
	}

	// save process for kill
	client.pid = cmd.Process.Pid
	client.startupMutex.Unlock()

	return nil
}

プロセスkillもするので、ReBuildとかの方が良かったかもなあ、と少し後悔しています。。。

LogTransporter

上記までで、基本的なhot-reloadの仕組みは完成しました。ここからはAppServerで流したログをAgentServer(ターミナル)で流す実装になります。実際にはこちらを最初に実装しました。上手くいっているか分からないので。。。

logTransporter.go
func (client *runnerClient) LogTransporter(ctx stdContext.Context, fileChangeStream chan struct{}) func() error {
	return func() error {
		for {
			select {
			case <-ctx.Done():
				return ctx.Err()

			// re-watch log stream watcher
			case <-fileChangeStream:
				return nil

			// log streamed
			default:
				if client.reader == nil {
					continue
				}

				line, _, err := client.reader.ReadLine()
				...

				utils.PotetoPrint(
					fmt.Sprintf("%s %s\n", color.HiGreenString("poteto |"), string(line)),
				)
			}
		}
	}
}

AppServerをBuildした際に保存したLogStreamからログを一行ずつ取得して、AgentServerに流します。

詰まったポイントは、AppServerの再ビルドの際に、LogStreamが更新されても一度立ち上げたLogTranporter内からはそれを取得できず、再ビルドしたアプリのログが流れなかった点です。

// re-watch log stream watcher
case <-fileChangeStream:
	return nil

そのため、再ビルド時に、LogTransporterを再実行するように、一旦処理を終了しています。これが何故上手くいかなかったのか、まったく分かっていないです。もし分かる方いましたら、教えていただけるとありがたいです。

AgentServer

RunRunとかいうクソダサい関数名なのは、触れないでください笑。

agentServer.go
func RunRun(option core.RunnerOption) error {
	runnerClient := core.NewRunnerClient(option)
	defer runnerClient.Close()

	// Ctrl+Cで子プロセスをkillする
	quit := make(chan os.Signal)
	signal.Notify(quit, os.Interrupt)

	clientContext := stdContext.Background()
	fileChangeStream := make(chan struct{}, 1)
	defer close(fileChangeStream)

	errWatcherChan := make(chan error, 1)
	fileWatcher := runnerClient.FileWatcher(
		clientContext,
		fileChangeStream,
	)
	go func() {
		// FileWatcher watch file system
		if err := fileWatcher(); err != nil {
			errWatcherChan <- err
		}
	}()

	errBuildChan := make(chan error, 1)
	buildRunner := runnerClient.BuildRunner(
		clientContext,
		fileChangeStream,
	)
	go func() {
		// Build Runner
		if err := buildRunner(); err != nil {
			errBuildChan <- err
		}
	}()

	logChan := make(chan struct{}, 1)
	errLogChan := make(chan error, 1)
	logTransporter := runnerClient.LogTransporter(clientContext, fileChangeStream)
	go func() {
		defer close(logChan)
		// log transport
		if err := logTransporter(); err != nil {
			errLogChan <- err
		}
	}()

	for {
		select {
		...
		case <-logChan:
			logChan = make(chan struct{}, 1)
			// re-watch log stream watcher
			go func() {
				defer close(logChan)
				// log transport
				if err := logTransporter(); err != nil {
					errLogChan <- err
				}
			}()
		case <-quit:
			return runnerClient.Close()
		}
	}
}

長いですが、処理自体は単純です。これまでに紹介したFileWatcherBuildRunnerLogTranporterを並列に立ち上げているだけです。工夫した点としては、AgentServerを停止した際にもAppServerを閉じるようにした点です。

// Ctrl+Cで子プロセスをkillする
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)

case <-quit:
  return runnerClient.Close()

作成したCLIツールの使い方

まずはインストール

go install github.com/poteto-go/poteto-cli/cmd/poteto-cli@latest

例えば以下みたいな感じでpoteto.yamlを作ります。

app
 ┣ poteto.yaml
 ┣ main.go
 ┗ ...

ちなみに一応おいてるだけでversionは意味ないです。文字列ならなんでも動く(1つしかversionないので、、、)。その辺整備不足で、poteto-cli newは存在しないversionを出力します、、、

poteto.yaml
version: "1.0"
build_script_path: "main.go"
debug_mode: true

この状態で、以下のコマンドを入力します。

poteto-cli run

これでホットリロードでアプリが立ち上がります。

終わりと今後

ひとまずWebフレームワークを作り始めたときに掲げていた目標は達成できたので、これからはしばらくドキュメント整備とバグ修正を行っていこうと思っています。また、徐々にPotetoを使ったアプリも作っているので、そっちもゆっくり進めたいですね。

記事活動においては、JSONRPCAdapterの差分についてまだ書いていないので、時間あるときにその話をまとめるかもしれません。

ここまでお付き合いありがとうございました。読んでくれた方含め自分も2025年良い年にしましょう、!!