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つのメソッド内の制御構造(順次、分岐、反復)によってワークフローを進めます。イベントが来るまで待ち、イベントが来たら続きを再開するイメージです。Task による非同期メソッドを使ってコルーチン的な動作をさせているとも言えます。

このような制御構造によるワークフローを非同期シーケンシャルフロー(略して非同期フロー)と名付けました。この機能はユースケースの実行手順を示すものなのでアプリケーション層に配置しています。

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

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

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

参照

Excelアドイン開発にクリーンアーキテクチャーを導入してみた

クリーンアーキテクチャー採用の動機

ExcelのVBAを使ってシステム開発する場合、ExcelのVBIDEを使うことになると思います。そのエディタの使い勝手は Visual Studio Code など今日的なその他のものと比べてあまり便利とは言えません。しかし、VBA特有の書式化・コンパイルチェック・デバッグを考えるとVBIDEを使わないわけにもいきません。そのため、 Visual Studio Code 等外部エディターである程度編集しておき、VBIDEにコピー、また逆に外部エディターに戻す操作を何度も繰り返していました。

これを解決するため、外部エディターと VBIDE のソースを同期する Excel アドイン(ExcelVBACodeSyncと命名)を C#, VSTO を使って作りました。すなわち、マクロが含まれるブックが保存されたら、指定されたフォルダー配下にソースをエクスポート、フォルダーの変更を監視して外部エディターでファイルが変更されたらインポートするようにしました。

ExcelVBACodeSync はフォルダー監視、Excelの状態変化の両方がコマンドのトリガーとなります。この点をどのように実装するかが問題となります。例えばCRUDが主な動作となるアプリケーションではプレゼンテーション層をUI、データアクセス層をデータベースと位置付けるレイヤードアーキテクチャーを使っています。今回は、フォルダーおよび Excel がストレージであると同時にコマンドトリガーにもなります。

これらを無理なく実装するのにクリーンアーキテクチャーが使えそうだと思い採用してみました。

レイヤードアーキテクチャー各層との比較

レイヤードアーキテクチャー クリーンアーキテクチャー
プレゼンテーション層 アダプター層
Frameworks & Driver 層
アプリケーション層 ユースケース層
ドメイン層 エンティティ層
データアクセス層 アダプター層
Frameworks & Driver 層

普段採用しているレイヤードアーキテクチャーとクリーンアーキテクチャーの各層対応は右表の様になっています。

ユースケース層エンティティ層はそれぞれアプリケーションロジック、ビジネスロジックを記述する部分でありアプリケーションコアと位置づけられます。クリーンアーキテクチャーではプレゼンテーション層もデータアクセス層も技術の詳細に依存する部分としてアプリケーションコアの外部にあるアダプター層Frameworks & Driver 層に配置されます。

出典: The Clean Architecture - Clean Coder Blog

プレゼンテーション層とデータアクセス層がまったく同一視されるかというとそうではなく、その扱いには違いがあり、Primary AdapterSecondary Adapterに分かれます。これらはクリーンアーキテクチャーが影響を受けたヘキサゴナルアーキテクチャーで定義される語彙です。この記事では上記概念図のアダプター層・Frameworks & Driver 層を一体のものとして扱い、まとめたものを指すときはアダプター層と呼びます。

分類 Primary Adapter Secondary Adapter
動作分類 能動的動作(ユースケース層を呼び出す。) 受動的動作(ユースケース層に呼び出される。)
動作例 イベントをトラップしてコマンドを発行 コアロジックの実行結果を永続化
レイヤードアーキテクチャー対応 プレゼンテーション層 データアクセス層

依存性の逆転

クリーンアーキテクチャーでは、依存の方向として外側から内側にアダプター層ユースケース層エンティティ層の方向に依存すべきとの説明があります。問題になりがちなのがデータアクセス等行う Secondary Adapter で、アプリケーションコアからこれをそのまま呼び出したら内側から外側に依存します。これを避けるためにデータアクセスを行う機能をインターフェース化しておき、アプリケーションコアからはインターフェースに対して操作を行います。Secondary Adapter はモックに置き換え可能となります。これについてはレイヤードアーキテクチャーでも行われていますので、クリーンアーキテクチャー独特のものではありません。またこれまで私は行ってこなかったのですが、クリーンアーキテクチャーでは Primary Adapter がアプリケーションコアを呼ぶ際にも同様にユースケース層をインターフェース化しておき、ユースケース層をモックに置き換え可能にします(図右下のUMLを参照)。

Primary Adapter からアプリケーションコア呼び出し時のインターフェースを Input Port、アプリケーションコアから Secondary Adapter 呼び出し時のインターフェースを Output Port と呼びます。

各層への機能割当

ExcelVBACodeSync での機能と層の分類を抜粋したのが下表です。

名前 機能 分類
VbaModuleSync VBA モジュールと外部ファイルの同期を実行するアプリケーションサービス ユースケース
ExcelState Excelの状態を保持 ステート(後述)
MacrobookInfo マクロ付きワークブックの情報を保持 エンティティ
ExcelEventListener Excel のイベントをトラップしてコマンドを発行 Primary Adapter
ExcelOperator Excel に対する操作 Secondary Adapter
ProjectSourceWatcher FileSystemWatcher を使ってファイルの更新を検出 Primary Adapter
ProjectSources VBA ソースコードエクスポート先のディレクトリに対する操作 Secondary Adapter

循環参照

インターフェースと実装を結び付けるのにしばしば DI(Dependency Injection, 依存性の注入) が使われます。

ExcelVBACodeSync で Dependency Injection を使ってインスタンス化しようとすると循環参照を検出して失敗します。

インターフェースによって依存の方向は見かけ上1方向になっているのですが、実装しているオブジェクト同士は、呼び出しの方向がPrimary Adapter → ユースケース層 → Secondary Adapter となるのでこの通りに依存します。ExcelVBACodeSync では上表の通り、Excel側、フォルダー側ともに Primary Adapter、Secondary Adapter に分けているので循環参照にならないように見えます。しかし、完全分離は難しくExcel側、フォルダー側ともにそれぞれ違った理由で Secondary Adapter が Primary Adapter を参照しています。つまり、Primary Adapter → ユースケース層 → Secondary Adapter → Primary Adapter といった循環が生じています。

この解決方法としては、Secondary Adapter が Primary Adapter を呼び出すのではなく、Primary Adapter が Secondary Adapter が発生させるイベントをトラップすることが考えられます。つまり循環参照しないように依存の方向を変えるのです。今回は技術的な問題に引きずられて制御を変えるのを避けたかったのでこの方法を取りませんでした。

DIコンテナーの作成

この問題を解決するため循環参照があってもインスタンス化できるDIコンテナーを作成しました。

このコンテナーは依存チェーンにあるすべてのオブジェクトを生成してからその直後にメソッドインジェクションを実行します。メソッドインジェクションで再び依存チェーンのオブジェクトを生成し、この動作を再帰的に実行して、最終的にすべてのオブジェクトを生成します。使うときには循環チェーン上のいずれかの依存をコンストラクターインジェクションではなく、メソッドインジェクションにすれば問題なく生成してくれます。当初使っていた Microsoft.Extensions.DependencyInjection を含め、メジャーなコンテナーはこのような機能を持っていないようです。

ExcelVBACodeSync への適用

アダプター層とFrameworks & Driver 層を区別しないで一体のものとして実装しました。

クリーンアーキテクチャーで層をまたぐときに渡すデータは DTO を作って渡すことが推奨されているようですが、これは採用しませんでした。詰め替えることにメリットを感じませんでした。ですのでアダプター層からドメインモデルを参照しています。但し余計なプロパティを参照しないように公開メンバーを限定したインターフェースを通じて参照しています。

上記クリーンアーキテクチャー概念図ではユースケース層があります。UI とアプリケーションが継続的に情報をやり取りするシステムではユースケースを受け取るだけでは足りません。アダプター層のオブジェクトの状態の写し(ステート)を持っておくことでアダプター層がシンプルになります。ExcelVBACodeSync ではユースケースとステートの両方を包含するので該当部分をユースケース層とは呼ばず、アプリケーション層と呼んでいます。

Excel 上のリボンのボタンの有効・無効の切り替えはユースケースからリボンに切り替えを指示するのではなく、 ステートのイベントを ExcelEventListener がトラップしたタイミングで更新するようにしました。ステートは MVVM でいうところのView Modelの役割をしていると理解できます。

総括

下記の点が印象に残りました。

  • クリーンアーキテクチャーを採用したことで複数のコマンドトリガー発生源からの要求を無理なく処理する実装となった。
  • 概念図をそのままトレースするのではなくアプリケーション層にステートを導入することでアダプター層を単純化できた。
  • 作成した DIコンテナーは大変使い勝手がよく、インスタンス化のことを気にすることなくクラスを作成できるようになった。

クリーンアーキテクチャーは ExcelVBACodeSync のように、複数のコマンド発生源があり、一つの外部要素がユースケースを呼び出すと同時にユースケースに呼び出される場合に真価を発揮すると思いました。
開発にあたり Claude sonnet, Gemini, ChatGPT 等各種 AI エージェントには助けられました。これらなしにはクリーンアーキテクチャーを適切に実践することは難しかったと思います。

参照

Word Press

Word Press のプラグイン開発に取り組んでいます。

Word Press はアーキテクチャーとして MVC を採用していません。プラグイン部分がある程度のボリュームがある場合、MVC
フレームワークを使いたくなります。

そこでマイクロフレームワーク Slim と組み合わせて使うことにしました。

Word Press - Slim の組み合わでは、下記の点に考慮する必要がありました。

  • ショートコードによって、Slim に基づいたコードによって発生させたページを表示できるようにする。
  • カスタム固定ページの表示内容を Slim に基づいたコードによって発生できるようにする。
  • 上記2点によってページを表示する場合、Slim の Response を使用しないで、単に HTML テキストを出力、あるいは Word Press に返却する。
  • Ajax によるリクエストおよびダウンロードは、Slim で処理されるようにリライトおよびリダイレクトを設定し、Word Press で処理されないようにする。

このうち最後のリライトについてははまるポイントがありました。
プログラムのリライトは下記コードの 3 行目のように、index.php を経由してコントローラーとコマンドを引数として渡すことにしました。
index.php を経由する理由は Word Press で定義されている関数を使いたいからです。
一方、画像の URL もカスタマイズしたかったので 2 行目のように Word Press を経由しないで直接リライトを設定しました。

1
2
3
4
5
6
7
8
9
10
11
12
// リライトを設定します。
add_action('init', function (): void {
add_rewrite_rule('my_plugin/images/([^/]+)', 'wp-content/plugins/my_plugin/assets/images/$1', 'top'); //外部リライト
add_rewrite_rule('^my_plugin/([^/]+)/([^/]+)', 'index.php?my_plugin_controller=$matches[1]&my_plugin_command=$matches[2]', 'top'); //内部リライト
});

// Slim 処理系へのリダイレクトを設定します。
add_action('template_redirect', function (): void {
$controllerType = get_query_var('my_plugin_controller');
$commandName = get_query_var('my_plugin_command');
if ($controllerType and $commandName) { doSomething($controllerType, $commandName); } // コントローラーとコマンドの組み合わせを処理するコードをここに記入
});

Word Press では index.php を経由するリライトを内部リライト、そうでないものを外部リライト と呼んでいるようです。
そこで問題となるのが内部リライトと外部リライトで指定書式が異なることです。それぞれ正規表現でマッチした部分の参照方法は下記のようになります。

内部リライト $1, $2, ...
外部リライト $matches[1], $matches[2], ...

これについては気づきにくい部分だと思いました。

私は Word Press のソースコード(class-wp-rewrite.php)を読んで初めてこの違いを理解しました。

Oracle Apex

Oracle Apex を使ったシステム開発、保守に携わりました。

Oracle Apex は Oracle が提供する Rad ツール兼実行環境です。開発者視点で見ますと、PL/SQL を使ってロジックを記述し、画面は Oracle Apex が用意する画面デザイナーを使ってサーバー側およびクライアント側を作りこみします。ドメイン駆動開発とは全く異なる開発スタイルと考えてよいでしょう。

CRUD だけであまりドメインロジックがない画面では素早く開発できますが、ドメインロジックが増えていくと難しいと感じました。Oracle、SQL、PL/SQL、Javascript の知識に加えて Oracle Apex の知識も必要となり、急に難易度が上がるように思いました。

画面制御では一般的な Javascript の知識に加えて、Oracle Apex 用の API を把握する必要があります。

業務で使ったことを機会に研究し、下記のような工夫をしました。

Javascript

Proxy を使用して、Apex 項目に簡単にアクセスする方法を提供する。
Apex の項目にアクセスするためには複雑な記述が必要で、行が増えがちですが Proxy によって Apex の項目をプロパティに持つオブジェクトを定義しました。この機能をはじめ、どの画面でも使える共通ルーチンはShared ComponentsStatic / Application Filesにファイルとして格納しました。
ajax送信するときにのパラメーターに x01, x02 等を使わないで pageItems: ['P1_SEQ','P1_ITEM_CODE'] といった形式で指定する。
pageItems でページに使用している項目を送信できます。クライアント側、サーバー側双方でパラメーター・項目間で詰めなおす必要がなくなります。

PL/SQL

PL/SQL に深く取り組んだのは初めてでした。いろいろと勉強になりました。SQL および PL/SQL で工夫した点を挙げます。

自己結合および EXISTS による同じテーブルを取得するサブクエリーを使わないで、分析関数を使う。
パフォーマンス改善で効果があります。
FROM 句等にサブクエリーをインラインで記述するのではなく共通テーブル式を使う。
SQL 文が見やすくすっきりします。
コンテキストスイッチを減らす。
カーソルを使うと、Oracle エンジンと PL/SQL 間のコンテキストスイッチが頻繁に発生し、パフォーマンスを落とします。その対策として、可能であれば、BULK COLLECT を使ってコレクション型(ネストした表)に格納し、ネストした表を SQL で使ってロジックを実行するようにしました。

Oracle APEX で開発するなら下記サイトは必見です。

参照

静的サイトジェネレーター

当サイトはもともと HTML を手書きしていました。最近は記事が多くなってきたためトップページの目次だけでは探しにくい状況となっていました。タグなどで分類することで探しやすくしたいと思いました。また、HTML を書かなければいけないので記事の作成が少々面倒でした。

そこで、ブログを使って書き直すことにしました。

最初に思い付いたのが WordPress などの CMS でした。当サイトの規模と照らし合わせると仕組みが大げさであると考えて静的サイトジェネレーターにたどり着きました。

その中でも HugoHexo を候補としました。まず Hugo を試しました。タグに日本語を使うと Hugo は正しく URL を生成してくれませんでした。もし、タグから URL にマッピングする機能があれば適宜代替のアルファベットを使ったパスに変えることで回避できるのかもしれませんが、試していません。

次に Hexo を試しました。上記日本語タグ名の問題はありませんでした。

C#, C++ などの文字列に含まれる記号は URL に反映されないので、この 2 つは同じもの扱われます。Hexo にはタグ名から URL にマッピングする機能がありますので、C#CSharp, C++CPlusPlusとマッピングすることでこの問題を回避できました。

テーマは既定の Landscape を使いました。単純なつくりになっているのでカスタマイズしやすかったです。

テーマを直接上書きしています。git のサブモジュールとして管理しているので、もしアップデートされたテーマを適用したくなった時も何とかなるのではと考えています。

Visitor - Guide パターン

デザインパターンの中でも複雑な Visitor パターンについていくつかの改善を実践しましたので紹介します。TypeScript で実装しました。

Visitor パターンを使おうとした動機

ドメインモデルにおいて、エンティティがプロパティとして別のエンティティや別のエンティティを要素に持つリストを持つことは、よくある構造ではないかと思います。これらは再帰構造を持ちます。

再帰構造を持つエンティティを扱うユティリティとして次のような機能を作ろうとしました。

  • コピー
  • シリアライズ
  • デシリアライズ
  • 構造のパスに基づいてエラーをエンティティやプロパティに紐づけ
  • オブジェクト構造のどこかでエラーが発生していないか検査

これらの機能を作るとき、再帰構造を巡回するコードを書く必要がありますが、すべて類似の処理になりますので共通化したいと考えました。

過去に試した経緯

構造を巡回することから Visitor パターンが利用できるのではないかと考えて、過去に何度か試したことがあるのですが、今までうまくいっていませんでした。その理由を改めて整理すると下記の通りです。

オブジェクト構造のたどり方が微妙に異なる
コピー、シリアライズでは構造を末端までたどりますが、それ以外の処理では必ずしもすべてたどりません。 エラーがあるかないかだけの検査であれば、エラーが一つ見つかればその時点で残りの要素を巡回する必要がなくなりまが、Visitor パターンでは巡回を中断するのが困難です。
処理に付加的な情報が必要
例えば、コピーではコピー先のオブジェクト、親からとの関連(ルートなのか、親のプロパティなのか、リスト要素なのか)が必要になります。これらを共通化するのか難しかったです。
オブジェクト構造側に Visitor を受け入れる仕組みが必要
Visitor を受け入れるために、エンティティやリストに Accept メソッドが必要になります。エンティティのプロパティも巡回するとなると各言語で用意されている文字列、数値などのプリミティブな型が使えなくなります。

改善内容

そこで今回改めて次の条件を満たす仕組みを考えてみました。

  • 巡回方法を柔軟に選択できること
  • 途中で巡回を中断できること
  • 求める機能に応じて付加的な情報を柔軟に追加できること
  • オブジェクト構造側は修正しないで導入できること

以上を実現するため、次のように改善しました。

  • Visitor - Guide パターンを適用
  • Visitor に渡す付加的なパラメーターを追加
  • Visitメソッドの戻り値をGenerator(C#ならEnumerable)にする

Visitor - Guide パターン

VisitorパターンにはVisitor側で巡回経路を決める場合と、オブジェクト要素側で巡回経路を決める場合があるようです。

Visitor 側で巡回経路を決める場合、Visitor が実現する機能に従って巡回方法を変えられるメリットがあります。Visitor は巡回と具体的な処理の両方を受け持つことになります。
それに対して、オブジェクト要素側で巡回経路を決める場合、Visitor 側は巡回を受け持つ必要はなくなりますが、巡回経路が固定化されます。

そこで、これらを解決するため第3の要素として Guide を考えました。

Guide は巡回方法を決めて Visitor を案内します。Visitor は Guide の案内に従ってオブジェクト要素を訪問します。 VisitorとGuideは1対1で機能します。VisitorがGuideの guide メソッドを呼び、guide はオブジェクト構造の下位要素を巡回しVisitorのオブジェクト要素に対応する visit メソッドを呼びます。末端の文字列、数値などの要素に達した場合、Visitor は Guide をそれ以上呼び出しません。 つまり、VisitorがGuideを呼び出すのは再帰構造のルートまたは中間ノードを訪問するときだけです。

上記のような方法を Visitor - Guide パターンと名づけました。

このパターンによって責務が明確化しました。巡回方法は Guide が、実現する機能は Visitor が受け持ちます。異なる巡回方法が必要になった時は Guide を変えます。

但し、中間ノードで guide を呼ばない、visit が呼び出されても処理をしないと言った選択肢が Visitor 側にありますので、今のところ異なる Guide が必要になるケースは出てきていません。

中間ノードで guide を呼ぶ前に処理をすれば幅優先探索に、guide を呼んだあとに処理をすれば深さ優先探索になります。

また、オブジェクト構造側に手を入れる必要がありません。

Visitor に渡すパラメーターの追加

Visitor でもっとも単純なパターンではVisit メソッドにはオブジェクト要素のみを渡しますが、実現しようとする機能によっては、情報が不足します。そこで、下記の2つのパラメーターを設けました。

index
上位のオブジェクトからプロパティとしてアクセスされるならプロパティの情報、リストの要素であれば index
param
付加的な情報を表すパラメーター、コピーであればコピー先オブジェクト。木構造を dump 表示する Visitor であればインデントの深さなど

戻り値の Generator 化

Visitor の戻り値を Generator にした直接の理由は、巡回処理を途中で中断できるようにするためです。

コピーでは Visitor は処理した中間結果を処理が進むにしたがってコピーした要素を Generator に積み上げていきます。guide 呼び出しから戻った時点で積み上げた結果をコピー先オブジェクトに反映します。

一方、オブジェクト要素をたどってエラーがあるかどうか検査するケースでは、Visitor は訪問した要素が正常であれば Generator に要素を追加せず、エラーが見つかれば Generator に要素を追加します。呼び出しルーチンは巡回を開始して一つだけ要素を待ち、要素が返ればエラーありと判定し2個目の要素を待たないことで巡回を中断できます。すべて巡回が終わっても Generator が要素が返さなければエラーなしと判断できます。

欠点

オブジェクト構造側を修正しないで実現したため、Visitor パターンのメリットの一つであるダブルディスパッチによる型判定の除外はできません。

型判定をなくす理由は、型判定がプログラムのあちらこちらに散らばっていると、オブジェクト構造に新しい種類が加わった時、改修が困難となるためだと理解しています。

現状では Guide 内に型判定が残っています。もともとの動機は巡回を繰り返し記述しないことでした。それは実現できていますのでこの欠点はこれ以上対処しないことにします。

サンプル

下記は Visitor - Guide パターンによりオブジェクトを巡回する基本的なコードです。

IObjectNodesVisitor
Visitor が実装すべきインターフェースを表します。
IObjectNodesGuide
Guide が実装すべきインターフェースを表します。
ObjectNodesGuide
Guide の基本実装をカプセル化します。
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
export type ObjectNode = object | any[];
export type ObjectNodeIndex = string | number;

export interface IObjectNodesVisitor<TResult, TParam = undefined> {
visitObject(target: object, index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
visitArray(target: any[], index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
visitValue(target: any, index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
}

export interface IObjectNodesGuide<TResult, TParam = undefined> {
guideObject(target: object, param?: TParam): Generator<TResult>;
guideArray(target: any[], param?: TParam): Generator<TResult>;
}

export class ObjectNodesGuide<TResult, TParam = undefined> implements IObjectNodesGuide<TResult, TParam> {
private visitor: IObjectNodesVisitor<TResult, TParam>;

private* callVisit(index: string | number, value: any, param?: TParam): Generator<TResult> {
if (value instanceof Array) {
yield* this.visitor.visitArray(value, index, param);
} else if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
yield* this.visitor.visitObject(value, index, param);
} else {
yield* this.visitor.visitValue(value, index, param);
}
}

public* guideObject(target: object, param?: TParam): Generator<TResult> {
for (const [name, value] of Object.entries(target)) {
yield* this.callVisit(name, value, param);
}
}

public* guideArray(target: any[], param: TParam): Generator<TResult> {
for (const [index, item] of target.entries()) {
yield* this.callVisit(index, item, param);
}
}

constructor(visitor: IObjectNodesVisitor<TResult, TParam>) {
this.visitor = visitor;
}
}

下記は オブジェクトをダンプする機能を持つ Visitor を表します。

ObjectDumpVisitorParamはインデントの深さを格納するパラメーターです。各 Visitor はジェネレーターを返しています。これらの戻り値をつなぎ合わせるとダンプ結果を表す文字列を取得できます。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import {IObjectNodesVisitor, ObjectNodeIndex, ObjectNodesGuide} from "./ObjectNodesVisitor";

class ObjectDumpVisitorParam {
public depth: number;
public getNestedParam(): ObjectDumpVisitorParam {
return new ObjectDumpVisitorParam(this.depth + 2);
}
constructor(depth: number = 0) {
this.depth = depth;
}
}

class ObjectDumpVisitor implements IObjectNodesVisitor<string, ObjectDumpVisitorParam> {
public guide = new ObjectNodesGuide<any, ObjectDumpVisitorParam>(this);

private buildRow(param: ObjectDumpVisitorParam, index: ObjectNodeIndex | undefined, body: string): string {
const indent = ' '.repeat(param!.depth);
const path = index !== undefined ? `${index}: ` : '';
return `${indent}${path}${body}` + '\n';
}

public* visitObject(target: object, index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, '{')
yield* this.guide.guideObject(target, param.getNestedParam());
yield this.buildRow(param, undefined, '}');
}

public* visitArray(target: any[], index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, '[')
yield* this.guide.guideObject(target, param!.getNestedParam());
yield this.buildRow(param, undefined, ']');
}

public* visitValue(target: any, index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, target);
}

public static dumpObject(target: Record<string, any>): string {
return Array.from(new ObjectDumpVisitor().visitObject(target)).join('');
}

public static dumpArray(target: any[]): string {
return Array.from(new ObjectDumpVisitor().visitArray(target)).join('');
}
}

const target = {
id: 1,
name: 'test',
children: [
{
detailId: 1,
detailName: 'detail1',
},
{
detailId: 2,
detailName: 'detail2',
},
]
}

const result = ObjectDumpVisitor.dumpObject(target);
console.log(result);
参照

TypeScript でパラレル継承階層

以前に、パラレル継承階層を実装するとダウンキャストが避けられないとの記事を書きました。 またTypeScript を使うとある程度対応できる記事を書きました。

そして、ダウンキャストをなくすためには下記の2つの機能が言語に必要となりますが、Scala にはその機能があることがわかりました。

  • 継承可能な型引数をサポートすること
  • ジェネリックワイルドカードをサポートすること

Scalaを利用すれば完全に対応できることがわかりました。

中身と入れ物の関係がパラレル継承階層になりがちなので、TypeScriptと同じ例を下記に示しました。

中身は BaseContentDerivedContentDerivedContent2 と継承します。一方、入れ物はこれに対応して、BaseBoxDerivedBoxDerivedBox2 と継承します。

BaseBoxは中身を参照するcontentプロパティを持ち、これをTContent型としています。入れ物の継承に従ってこの型引数も制約の型をより継承した中身の型へと変えていきます。このようにすることで、contentへの代入は型安全が保たれます。また、サンプルプログラムでは省略していますが、各入れ物クラスの中でcontentのハンドリングの際にダウンキャストは発生しません。

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
class BaseContent {
}

class BaseBox {
type TContent <: BaseContent
var content: Option[TContent] = None
}

class DerivedContent extends BaseContent {
val prop1 = 0
}

class DerivedBox extends BaseBox {
override type TContent <: DerivedContent
}

class DerivedContent2 extends DerivedContent {
val prop2 = 0
}

class DerivedBox2 extends DerivedBox {
override type TContent = DerivedContent2
}

object ParallelInheritance
{
def main(args: Array[String]) = {
val derivedBox = new DerivedBox()
//derivedBox.content = Some(new DerivedContent())
// derivedBox.content = Some(new BaseContent()) //Found: BaseContent Required: DerivedContent 想定通りのエラー
val derivedBox2 = new DerivedBox2()
val baseBox: BaseBox = derivedBox2
//baseBox.content = Some(new BaseContent()) //error: type mismatch found: BaseContent required: baseBox.TContent 想定通りのエラー
println(derivedBox2.content.get.prop1)
derivedBox2.content = Some(new DerivedContent2())
// derivedBox2.content = Some(new DerivedContent()) //Found: DerivedContent Required: DerivedContent2 想定通りのエラー
// derivedBox2.content = Some(new BaseContent()) //Found: BaseContent Required: DerivedContent2 想定通りのエラー
}
}
参照

TypeScript でパラレル継承階層

以前に、パラレル継承階層を実装するとダウンキャストが避けられないとの記事を書きましたが、TypeScript を使うとある程度対応できることがわかりました。

ダウンキャストをなくすためには下記の2つの機能が言語に必要となります。

  • 継承可能な型引数をサポートすること
  • ジェネリックワイルドカードをサポートすること

このうち、継承可能な型引数については TypeScript で型引数の既定値を指定できるので、イメージしていたものとは異なりますが、これを利用すれば部分的に対応できることがわかりました。

中身と入れ物の関係がパラレル継承階層になりがちなので、その例を下記に示しました。

中身は BaseContentDerivedContentDerivedContent2 と継承します。一方、入れ物はこれに対応して、BaseBoxDerivedBoxDerivedBox2 と継承します。

BaseBoxは中身を参照するcontentプロパティを持ち、これをTContent型としています。入れ物の継承に従ってこの型引数も制約の型をより継承した中身の型へと変えていきます。このようにすることで、contentへの代入は型安全が保たれます。また、サンプルプログラムでは省略していますが、各入れ物クラスの中でcontentのハンドリングの際にダウンキャストは発生しません。

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
class BaseContent {
}

class BaseBox<TContent extends BaseContent = BaseContent> {
public content: TContent | undefined;
}

class DerivedContent extends BaseContent {
public prop1: number = 0;
}

class DerivedBox<TContent extends DerivedContent = DerivedContent> extends BaseBox<TContent> {
}

const derivedBox = new DerivedBox();
derivedBox.content = new DerivedContent();
derivedBox.content = new BaseContent(); //TS2741: Property prop1 is missing in type BaseContent but required in type DerivedContent

class DerivedContent2 extends DerivedContent {
public prop2: number = 0;
}

class DerivedBox2<TContent extends DerivedContent2 = DerivedContent2> extends DerivedBox<TContent> {

}

const derivedBox2 = new DerivedBox2();
derivedBox2.content = new DerivedContent2();
derivedBox2.content = new DerivedContent();//TS2741: Property prop2 is missing in type DerivedContent but required in type DerivedContent2
derivedBox2.content = new BaseContent();//TS2739: Type BaseContent is missing the following properties from type DerivedContent2: prop2, prop1</code></pre>

次に対応できない部分について説明します。

1
2
3
4
5
6
7
const baseBox: BaseBox = derivedBox2; //本来なら型引数が異なるので代入互換性はないはずだが代入できる。
baseBox.content = new BaseContent(); //アップキャストした参照に代入すると不正な代入が可能になる。

//型安全のためには、ジェネリックワイルドカードでないと代入できないようにする必要がある
//const baseBox: BaseBox<? extends BaseContent> = derivedBox2;
//ジェネリックワイルドカードを使った場合、下記の代入はエラーになる。
//baseBox.content = new BaseContent();

上記のコードで、DerivedContent2からBaseBoxへ継承関係にあるので、代入できると考えがちです。実際、TypeScript はこれを許可しています。しかし、継承に従って型引数が変わっているので、本来は代入互換性がないはずなのです。サンプルではダウンキャストを全く使っていないにもかかわらず、型安全でない状況が生まれます。

これを型安全にするためには前の記事でもふれたようにジェネリックワイルドカードの機能が必要です。コメント内にもしワイルドカードが使えたらどのようなコードになるかを書いています。

問題はもう一つ残っていて、入れ物の継承で中身の型である TContent が継承するたびに別物になっていることです。これも同じ型引数のまま継承させる必要があります。

言語を開発するほどの技術力と財力がありませんので、このような言語が開発されたらいいなと、願っているだけです。

参照

PHPトレイトでオーバーロード

PHP では、継承元クラスの関数を派生先でオーバーロードできません。

例えばの下記 1. の記述はエラーになります。

オーバーロードを実現するためにトレイトを使用する方法が考えられます。

2. ではトレイトを use するクラスにトレイトが持っている関数と同じ名前の関数を定義すると、元の関数が上書きされることを利用しています。但しこの記述では元の関数を呼び出せません。元の関数を呼び出すには、3. のように元の関数を呼び出す関数でラップして、呼び出す関数を上書きします。3. のように元の関数を呼び出す関数でラップして、呼び出す関数を上書きします。

1. オーバーロード不可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BaseAccess
{
public function save($data){
// $data の処理
}
}

class ConcreteAccess extends BaseAccess
{
//再定義できない
public function save($data, $detail) {
// $data の処理
// $detail の処理
}
}
2. トレイトで関数を上書き
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
trait BaseAccess
{
public function save($data){
// $data の処理
}
}

class ConcreteAccess
{
use BaseAccess;

//エラーにならない
public function save($data, $detail) {
// $data の処理
// $detail の処理
}
}
3. 元の関数を呼び出す
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
trait BaseAccess
{
protected function saveCore($data) {
// $data の処理
}

public function save($data){
$this->saveCore($data);
}
}

class ConcreteAccess
{
use BaseAccess;

public function save($data, $detail){
$this->saveCore($data);
// $detail の処理
}
}

Slim

Laravelを採用したシステムを開発した件は別記事で説明済みですが、今回はマイクロフレームワークSlimを使ったシステム開発について紹介します。

Laravel ではなく Slim を使用した理由は次の通りです。

  • ターゲットとなるサーバーの仕様が若干古く、Laravel の最新バージョンを使用できない
  • 他のサイトと同居させる環境で Laravel がインストールできるかどうか確信を持てない
  • 現行システムからの乗り換えとなり、データベース設計が Laravel 向きではなかった(複合主キーあり)
  • システム規模が小さく、Eloquent のような O/R マッパーがなくても、PDO で問題なく実装できる
  • 当方が必要とする機能がコンパクトにまとまっている

下記機能を使用しました。

  • ルーティング機能
  • DI 機能
  • コントローラーをサポートする機能、RequestResponse
  • 権限弁別の為 Middleware を補助的に使用

当初はフレームワークなしで開発しようと思っていたのですが、上記機能を一つ一つインストールするよりもコンパクトなフレームワークがよいと思い直し、Slim にたどり着いたのです。

使ってみて次のように思いました。

  • 機能が小さいので学習コストが小さい。
  • Eloquent では ActiveRecord によるを記述が強制されるが、Slim ではそもそも O/Rマッパーがないためそのような制約はなく、素直にドメイン層とデータアクセス層の分離して記述できた
  • 実行に必要なコードが 10MByte 程度、前回紹介した Laravel によるシステムでは 120MByte なのでサイズは 1/12、但しシステム規模が違うので単純に比較できない

Slim での開発を終えて、DI 機能に若干の不満点はありましたが、全体としては実装が面倒な部分を肩代わりしてくれて助かった印象を持ちました。

また、今年に入ってドメインロジックの多くをクライアントサイドに記述するスタイルに移行しているので、フレームワークを変更する負担はあまり大きくなかったです。

参照