Excelアドインで非同期フロー

解決したい問題

Excelアドイン開発で、複数のイベントをまたぐ複雑なユースケースの実装に悩んでいませんか? グローバル変数での状態保持や、あちこちのイベントハンドラーにちりばめられたアプリケーションロジックを整理したい方に、非同期フロー導入のメリットを紹介します。

非同期フロー導入の動機

以前の記事(Excelアドイン開発にクリーンアーキテクチャーを導入してみた)で作成した Excelアドインは既に実用的なものになっていましたが、気になる点が2つありました。

  • ユースケースを実行するうえで複数のイベントを必要とする場合に、ユースケースの一時的な処理のために状態をグローバルに記録しなければいけない。またこのイベント毎に処理が書かれているのでコードを見ても関連がわかりにくい。
  • アダプター層にタイマーを 2 箇所記述しているが統一的な処理ではなくつぎはぎになっている

今回は、上記問題を解決するためにクリーンアーキテクチャーに非同期フローを導入しました。

問題だったこと

最初の問題はワークブックを保存したときに変更があったモジュールをエクスポートするユースケースについてです。

WorkbookBeforeSaveイベントとWorkbookAfterSaveイベントの2つが必要になります。WorkbookBeforeSaveイベントで変更があったモジュールを記録し、WorkbookAfterSaveでそれらをエクスポートしています。

これらの処理はそれぞれのイベントハンドラーに書いてあります。変更があったモジュールはExcelStateクラスのワークブックを表すオブジェクトに変更があったモジュールの配列プロパティとして持たせました。1つのユースケースのためにプロパティを持っていることが気になります。また処理が2箇所に分かれていることも気になります。

次の問題はタイマーです。

Excel では WorkbookClose というイベントは発生しません。一方でワークブックを閉じたときに ExcelState からも該当ワークブックを除外する必要がありますので擬似的に WorkbookClose イベントを作り出すことにしました。実装方法としては、タイマーを使って閉じられているワークブックがないか定期的にチェックしています。

もう1か所、エクスポートしたファイルに変更があった時に Excel にインポートする機能があります。外部エディターで複数のファイルに対して一括置換を掛けると短い期間に断続的にインポートされます。このとき Excel 側の VBIDE で該当モジュールを表示するようにしていますので、負荷が大きい処理になっています。そこでタイマーを使ってデバウンズによって一度にインポートするようにしました。VBIDE には最後にインポートしたモジュールを表示するようにしています。機能としては問題ありませんが Timer 周りの記述が場当たり的であまりきれいとは言えません。

async/await を活用した直感的なワークフロー実装

Excel や FileSystemWatcher 等の Primary Adapter はイベントを受け取り上記の処理をしていました。これを下記の2つに分離しました。

  1. Primary Adapter はイベントが発生したらメッセージに変換してメッセージディスパッチャーに送信する
  2. メッセージディスパッチャーは Primary Adapter から送信されるメッセージを一手に引き受け、ワークフローを実行しながらユースケースを呼び出す

メッセージディスパッチャーはイベントハンドラーを使った実装とは異なり、複数のイベントにわたって実行されるユースケースも await によって非同期にメッセージ(イベント)を待ちながら1つのメソッド内の制御構造によってワークフローを進めます。このような制御構造によるワークフローを非同期シーケンシャルフロー(略して非同期フロー)と名付けました。この機能はユースケースの実行手順を示すものなのでアプリケーション層に配置しています。

ワークブック毎のワークフローを実行するメソッドは下記の様になっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// <summary>
/// ワークブック毎のワークフローを実行します。
/// </summary>
/// <param name="handle">ワークブックを表すハンドル</param>
/// <param name="context">メッセージを待つコンテキスト</param>
/// <returns>非同期操作を表すオブジェクト</returns>
private async Task WorkbookFlow(IWorkbookHandle handle, WaitingFlowContext<WorkbookMessage> context)
{
var info = (WorkbookInfo)handle;
using var fileChangedContext = new WaitingFlowContext<FileChangedMessage>(ctx => FileChangedFlow(info, ctx));

var message = await context.GetMessage().ConfigureAwait(false);
ExcelState.AddWorkbook(info);
while (true) {
try {
if (message.Type == WorkbookMessageType.Close) break;
if (info.HasMacroEnabledFormat) {
if (message.Type == WorkbookMessageType.ChangeSyncState) {
VbaModuleSync.ChangeSyncState(info);
} else if (message.Type == WorkbookMessageType.ClearSourceCodeDirectoryPath) {
VbaModuleSync.ClearSourceCodeDirectoryPath(info);
} else if (message.Type == WorkbookMessageType.ExportAllModules) {
VbaModuleSync.ExportAllModules(info);
} else if (message.Type == WorkbookMessageType.ImportAllModules) {
VbaModuleSync.ImportAllModules(info);
} else if (message.Type == WorkbookMessageType.SetSourceCodeDirectoryPath) {
VbaModuleSync.SetSourceCodeDirectoryPath(info);
} else if (message.Type == WorkbookMessageType.BeforeSave && info.AutoSyncEnabled) {
var unsavedNames = ExcelOperator.GetUnsavedModuleNames(info);
if (unsavedNames.Length > 0) {
message = await context.GetMessage().ConfigureAwait(false);
if (message is not AfterSaveMessage asm) continue;
info.HasMacroEnabledFormat = asm.HasMacroEnabledFormat;
if (info.HasMacroEnabledFormat) VbaModuleSync.ExportModules(info, unsavedNames);
}
} else if (message is FileChangedMessage applyToWorkbookMessage) {
fileChangedContext.SendMessage(applyToWorkbookMessage);
}
}
if (message is AfterSaveMessage asm2) info.HasMacroEnabledFormat = asm2.HasMacroEnabledFormat;
} catch (Exception ex) {
message.Exception = ex;
}
message = await context.GetMessage().ConfigureAwait(false);
}
VbaModuleSync.FinalizeSyncState(info);
ExcelState.RemoveWorkbook(info);
}

BeforeSave から AfterSave にローカル変数 unsavedNames で保存されていないモジュールの配列を渡しています。またユーザが保存ダイアログをキャンセルしたら AfterSave メッセージが来ないで別のメッセージがきます。このときに continue が実行され、ユースケースが中断されることもこのメソッドからよくわかります。

もし非同期フローを使わなかった場合には、状態管理をデザインパターンでいうStateパターンによって実装できます。しかし、Stateパターンは状態毎にクラスが必要となる上、ワークフローの記述が複数のクラスに分散されます。それに対して非同期フローでは記述が1か所に集まっていて単純で理解しやすいと言えます。

UIスレッドの制約がある環境での非同期プログラミング

Excel では、各操作を UI スレッドで実行しなければなりません。何も対策しないと異なるスレッドで呼び出して問題を起こします。

タイマーはTask.Delayを使って実現しましたので終了後は UI スレッドではないスレッドで継続されます。そこで Delay 終了後に UI スレッドに切り替える処理をいれました。これによって安定して動作しました。

下記は定期的にメッセージを送信するフローです。ここに至るまでにはいろいろと試行錯誤しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 定期的に Heartbeat メッセージを送信するフロー
/// </summary>
private async Task HeartbeatFlow(WaitingFlowRegistry<IWorkbookHandle, WorkbookMessage> children, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested) {
try {
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
UIThreadAdapter.Send(() => DetectClosedWorkbooks(children));
} catch (OperationCanceledException) {
break;
} catch (Exception ex) {
Debug.WriteLine("Heartbeat Error: " + ex);
}
}
}

UIThreadAdapter.Sendは UI スレッドに切り替えるためのメソッドで下記のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// <summary>
/// UI スレッドでアクションを実行する機能をカプセル化します。
/// </summary>
internal class UIThreadAdapter : IUIThreadAdapter
{
private Control UiInvoker = null!;

public void AddInStartup()
{
UiInvoker = new Control();
UiInvoker.CreateControl();
}

public void Send(Action action)
{
if (!UiInvoker.InvokeRequired) action(); else UiInvoker.Invoke(action);
}
}

クリーンアーキテクチャーの深化

前回紹介したクリーンアーキテクチャーで実装されていたものから下記のように変わりました。

  • アプリケーション層にワークフローを処理するメッセージディスパッチャーを設けた。
  • Primary Adapter はイベントを解釈してアプリケーション層に対してメッセージを送信するだけになった。

クリーンアーキテクチャーでは、外から内へ依存するという原則を守りながら適宜構造を変更できます。今回のリファクタリングによってさらに洗練された構造になりました。

導入の結果

下記の点が良かったと思います。

  • Primary Adapter からアプリケーションロジックがなくなり単純になった。
  • ユースケースを呼び出すまでのアプリケーションロジックを非同期フローで直感的に記述できるようになった。
  • ワークフローのための状態をプロパティで持つ必要がなくなった。

一方で UI スレッドで実行させるために非同期処理を理解するまでにいろいろな試行錯誤をしました。この点は今回のブレークスルーで解消できたと思います。

また、装置制御では装置からの入力を非同期で受け取ることが多いと思うので、このような分野にも今回導入した非同期フローによって明快な記述ができるものと想像しています。

参照