変換ルールのリファクタリング
はじめに
「Art of Conceptual Modeling」のサンプルを兼ねたチュートリアルとして、
を、そして二番目を例に、「Essense of Software Engineering」で解説している基本を踏まえて、概念モデルからコンピュータリソース上で実際に実行可能なソフトウェア成果物群を、変換によって自動生成する方法を、
BridgePoint で作成した概念モデルを In Memory で動作する C# アプリケーションライブラリに変換する
ビジネスシナリオを元に作成した概念モデルを Azure Digital Twins、Azure Functions を使って実装する
の順番で解説しています。これらのドキュメントは、この順番で作成しています。加えて、これらのドキュメントを作成する前に、概念モデルに変換による自動生成を適用するための基本テクニックを解説する、「Art of Auto Software Development Deliverables Generation」を投稿しています。それぞれのドキュメントで解説している自動生成の仕組みは、https://github.com/kae-made から全て公開しています。
残りの二つは、
で、In Memory で動くためのコードを生成する変換ルール群をまず作って、そのあとに、Azure Digital Twins を永続ストレージに、Azure Functions を実行基盤として使えるように、今後の拡張も考慮しながら変更を加えました。この過程は、全て github の履歴機能で参照可能なようにしています。以下の Tag でそれぞれ参照できるようになっているのでどこが変更されたか調べながらドキュメントを解読していくのも良いでしょう。
※ github、なんて便利なんだ(笑)
「ビジネスシナリオを元に作成した概念モデルを Azure Digital Twins、Azure Functions を使って実装する」では、最後で、自動生成の都度、生成していた固定コードを分離するというリファクタリングについて言及しています。ちなみに、分離したコードは、以下の様に github のリポジトリを開発サイトとし、NuGet パッケージで使えるように変更されています。
https://github.com/kae-made/csharp-code-generation-framework
NuGet Package - Kae.DomainModel.Csharp.Framework
2022/7/20 現在の今後の予定として、BridgePoint で OAL(Object Action Language) で記述した、ドメイン、及び、クラスのオペレーションと、状態モデルのエントリアクションのアクションセマンティクスを元に C# のプログラムロジックコードを生成する変換ルールの追加作業が控えています。
その作業を開始する前に、あらためて、https://github.com/kae-made/domainmodel-code-generator-csharp の、CodeGenerator/Kae.XTUML.Tools.Generator.CodeOfDomainModel.Csharp/template/ の T4 テンプレート群を眺めると、管理不能に近づいている長大な変換ルールや、重複したロジックが散見される状態(ある意味カオスの一歩手前)になってしまっているのが判ります。T4 テンプレートも普通のプログラムコードと基本は一緒なので、「リファクタリング」で解説しているように、リファクタリングを行って整理整頓してから新しい作業を始めた方が、開発にかかるトータルコストが低減できます。
現状分析
先ずは、現在の状況を確認します。T4 テンプレートと生成された C#コード(「概念モデリングチュートリアル ~ ホテルのコインランドリー」から生成)の対応は以下の様になっています。
「BridgePoint で作成した概念モデルを In Memory で動作する C# アプリケーションライブラリに変換する」で解説しているように、T4 テンプレートのモジュール分割は、基本的に生成するファイルの種類を単位として定義しています。一方で、”Guest”という概念クラスを例にとると、それを元に生成される DomainClassClassName interface と DomainClassClassNameBase class(Guestの場合は、DomainClassGuest interface と DomainClassGuestBase class)は、
public interface DomainClassGuest : DomainClassDef
{
string Attr_Name { get; set; }
string Attr_GuestID { get; }
string Attr_GuestStayId { get; }
string Attr_MailAddress { get; set; }
public DomainClassGuestStay LinkedR5HaveTheRightToUse();
public bool LinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null);
public bool UnlinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null);
}
public partial class DomainClassGuestBase : DomainClassGuest
{
protected static readonly string className = "Guest";
public string ClassName { get { return className; } }
InstanceRepository instanceRepository;
protected Logger logger;
public static DomainClassGuestBase CreateInstance(InstanceRepository instanceRepository, Logger logger=null, IList<ChangedState> changedStates=null)
{
var newInstance = new DomainClassGuestBase(instanceRepository, logger);
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={newInstance.Attr_GuestID}):create");
instanceRepository.Add(newInstance);
if (changedStates !=null) changedStates.Add(new CInstanceChagedState() { OP = ChangedState.Operation.Create, Target = newInstance, ChangedProperties = null });
return newInstance;
}
public DomainClassGuestBase(InstanceRepository instanceRepository, Logger logger)
{
this.instanceRepository = instanceRepository;
this.logger = logger;
attr_GuestID = Guid.NewGuid().ToString();
}
protected string attr_Name;
protected bool stateof_Name = false;
protected string attr_GuestID;
protected bool stateof_GuestID = false;
protected string attr_GuestStayId;
protected bool stateof_GuestStayId = false;
protected string attr_MailAddress;
protected bool stateof_MailAddress = false;
public string Attr_Name { get { return attr_Name; } set { attr_Name = value; stateof_Name = true; } }
public string Attr_GuestID { get { return attr_GuestID; } set { attr_GuestID = value; stateof_GuestID = true; } }
public string Attr_GuestStayId { get { return attr_GuestStayId; } }
public string Attr_MailAddress { get { return attr_MailAddress; } set { attr_MailAddress = value; stateof_MailAddress = true; } }
// This method can be used as compare predicattion when calling InstanceRepository's SelectInstances method.
public static bool Compare(DomainClassGuest instance, IDictionary<string, object> conditionPropertyValues)
{
bool result = true;
foreach (var propertyName in conditionPropertyValues.Keys)
{
switch (propertyName)
{
case "Name":
if ((string)conditionPropertyValues[propertyName] != instance.Attr_Name)
{
result = false;
}
break;
case "GuestID":
if ((string)conditionPropertyValues[propertyName] != instance.Attr_GuestID)
{
result = false;
}
break;
case "GuestStayId":
if ((string)conditionPropertyValues[propertyName] != instance.Attr_GuestStayId)
{
result = false;
}
break;
case "MailAddress":
if ((string)conditionPropertyValues[propertyName] != instance.Attr_MailAddress)
{
result = false;
}
break;
}
if (result== false)
{
break;
}
}
return result;
}
protected LinkedInstance relR5GuestStayHaveTheRightToUse;
public DomainClassGuestStay LinkedR5HaveTheRightToUse() {
if (relR5GuestStayHaveTheRightToUse == null)
{
var candidates = instanceRepository.GetDomainInstances("GuestStay").Where(inst=>(this.Attr_GuestStayId==((DomainClassGuestStay)inst).Attr_GuestStayID));
relR5GuestStayHaveTheRightToUse = new LinkedInstance() { Source = this, Destination = candidates.First(), RelationshipID = "R5", Phrase = "HaveTheRightToUse" };
}
return relR5GuestStayHaveTheRightToUse.GetDestination<DomainClassGuestStay>();
}
public bool LinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null) {
bool result = false;
if (relR5GuestStayHaveTheRightToUse == null)
{
this.attr_GuestStayId = instance.Attr_GuestStayID;
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={this.Attr_GuestID}):link[GuestStay(GuestStayID={instance.Attr_GuestStayID})]");
result = (LinkedR5HaveTheRightToUse()!=null);
if (result)
{
if(changedStates != null) changedStates.Add(new CLinkChangedState() { OP = ChangedState.Operation.Create, Target = relR5GuestStayHaveTheRightToUse });
}
}
return result;
}
public bool UnlinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null) {
bool result = false;
if (relR5GuestStayHaveTheRightToUse != null && ( this.Attr_GuestStayId==instance.Attr_GuestStayID ))
{
if (changedStates != null) changedStates.Add(new CLinkChangedState() { OP = ChangedState.Operation.Delete, Target = relR5GuestStayHaveTheRightToUse });
this.attr_GuestStayId = null;
relR5GuestStayHaveTheRightToUse = null;
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={this.Attr_GuestID}):unlink[GuestStay(GuestStayID={instance.Attr_GuestStayID})]");
result = true;
}
return result;
}
public bool Validate()
{
bool isValid = true;
if (relR5GuestStayHaveTheRightToUse == null)
{
isValid = false;
}
return isValid;
}
public void DeleteInstance(IList<ChangedState> changedStates=null)
{
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={this.Attr_GuestID}):delete");
changedStates.Add(new CInstanceChagedState() { OP = ChangedState.Operation.Delete, Target = this, ChangedProperties = null });
instanceRepository.Delete(this);
}
// methods for storage
public void Restore(IDictionary<string, object> propertyValues)
{
attr_Name = (string)propertyValues["Name"];
stateof_Name = false;
attr_GuestID = (string)propertyValues["GuestID"];
stateof_GuestID = false;
attr_GuestStayId = (string)propertyValues["GuestStayId"];
stateof_GuestStayId = false;
attr_MailAddress = (string)propertyValues["MailAddress"];
stateof_MailAddress = false;
}
public IDictionary<string, object> ChangedProperties()
{
var results = new Dictionary<string, object>();
if (stateof_Name)
{
results.Add("Name", attr_Name);
stateof_Name = false;
}
if (stateof_GuestID)
{
results.Add("GuestID", attr_GuestID);
stateof_GuestID = false;
}
if (stateof_GuestStayId)
{
results.Add("GuestStayId", attr_GuestStayId);
stateof_GuestStayId = false;
}
if (stateof_MailAddress)
{
results.Add("MailAddress", attr_MailAddress);
stateof_MailAddress = false;
}
return results;
}
public IDictionary<string, object> GetProperties(bool onlyIdentity)
{
var results = new Dictionary<string, object>();
if (!onlyIdentity) results.Add("Name", attr_Name);
results.Add("GuestID", attr_GuestID);
if (!onlyIdentity) results.Add("GuestStayId", attr_GuestStayId);
if (!onlyIdentity) results.Add("MailAddress", attr_MailAddress);
return results;
}
}
と、特徴値や Relationship から生成されるコードの基本構造は、同一になっています。Relationship から生成される、LinkedXXX、LinkXXX、UnlinkXXX といったメソッドは、宣言のみ(interface)、実装付き(class)の違いだけです。これの意味するところは、DomainClassDefs.tt と DomainClassBase.tt には相当量の同じロジックが存在するということです。プログラムコードでは、同じロジックは可能な限り一つの箇所に書くことに越したことはないので、あまり良い状況とは言えません。この様な重複は、DomainClassOperation.tt と DomainClassDefs.tt の間でも生じています。
リファクタリング
重複の解消は、生成するファイルの種類の観点に加えて、生成する部品の種類という観点から変換ルールを分割することによって達成できます。具体的には、DomainClassBase.tt の中から、
概念クラスの特徴値
概念クラスが関与する Relationship
概念クラスのオペレーション
に関する変換ルールを抽出してそれぞれの T4 テンプレートを独立して作成し、元々の場所では、それぞれの変換ルールを呼び出して変換して、生成コードに埋め込むように変更します。「部品を生成するルールを切り出してまとめる」テクニックは、リファクタリング前にも、logging/Logging.tt の抽出でも使っていて、そのテクニックをより広範囲に適用しようということです。
変換ルールに限らず、ソフトウェア開発においては、「書きたい実装を、どんな観点で、どのファイルに何を書くか」を、常に明確に意識してコーディングを行うことは非常に重要です。
結果として、ciclass というサブフォルダーを用意して、以下の3種類の T4 テンプレートを追加しました。
例えば、PropertyDef.tt では、DomainClassClassName interface 用に生成する場合は、
string Attr_Name { get; set; }
string Attr_GuestID { get; }
string Attr_GuestStayId { get; }
string Attr_MailAddress { get; set; }
DomainClassClassNameBase 用に生成する場合は、
protected string attr_Name;
protected bool stateof_Name = false;
protected string attr_GuestID;
protected bool stateof_GuestID = false;
protected string attr_GuestStayId;
protected bool stateof_GuestStayId = false;
protected string attr_MailAddress;
protected bool stateof_MailAddress = false;
public string Attr_Name { get { return attr_Name; } set { attr_Name = value; stateof_Name = true; } }
public string Attr_GuestID { get { return attr_GuestID; } set { attr_GuestID = value; stateof_GuestID = true; } }
public string Attr_GuestStayId { get { return attr_GuestStayId; } }
public string Attr_MailAddress { get { return attr_MailAddress; } set { attr_MailAddress = value; stateof_MailAddress = true; } }
と、生成する内容は変わりますが、生成するための判断や生成に必要な情報を概念モデルから取り出す論理は同じルールが使われ重複が解消されています。
RelationshipDef.tt は、DomainClassClassName interface 用には、
public DomainClassGuestStay LinkedR5HaveTheRightToUse();
public bool LinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null);
public bool UnlinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null);
DomainClassClassNameBase class 用には、
protected LinkedInstance relR5GuestStayHaveTheRightToUse;
public DomainClassGuestStay LinkedR5HaveTheRightToUse()
{
if (relR5GuestStayHaveTheRightToUse == null)
{
var candidates = instanceRepository.GetDomainInstances("GuestStay").Where(inst=>(this.Attr_GuestStayId==((DomainClassGuestStay)inst).Attr_GuestStayID));
relR5GuestStayHaveTheRightToUse = new LinkedInstance() { Source = this, Destination = candidates.First(), RelationshipID = "R5", Phrase = "HaveTheRightToUse" };
}
return relR5GuestStayHaveTheRightToUse.GetDestination<DomainClassGuestStay>();
}
public bool LinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null)
{
bool result = false;
if (relR5GuestStayHaveTheRightToUse == null)
{
this.attr_GuestStayId = instance.Attr_GuestStayID;
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={this.Attr_GuestID}):link[GuestStay(GuestStayID={instance.Attr_GuestStayID})]");
result = (LinkedR5HaveTheRightToUse()!=null);
if (result)
{
if(changedStates != null) changedStates.Add(new CLinkChangedState() { OP = ChangedState.Operation.Create, Target = relR5GuestStayHaveTheRightToUse });
}
}
return result;
}
public bool UnlinkR5HaveTheRightToUse(DomainClassGuestStay instance, IList<ChangedState> changedStates=null)
{
bool result = false;
if (relR5GuestStayHaveTheRightToUse != null && ( this.Attr_GuestStayId==instance.Attr_GuestStayID ))
{
if (changedStates != null) changedStates.Add(new CLinkChangedState() { OP = ChangedState.Operation.Delete, Target = relR5GuestStayHaveTheRightToUse });
this.attr_GuestStayId = null;
relR5GuestStayHaveTheRightToUse = null;
if (logger != null) logger.LogInfo($"@{DateTime.Now.ToString("yyyyMMddHHmmss.fff")}:Guest(GuestID={this.Attr_GuestID}):unlink[GuestStay(GuestStayID={instance.Attr_GuestStayID})]");
result = true;
}
return result;
}
となっています。
結果として、DomainClassDefs.tt と DomainClassBase.tt 、DomainClassOperation.tt にあった変換ルールのロジック重複は解消され、すっきりとした変換ルールに修正されました。
リグレッションテスト
通常のソフトウェア更新と同様、変換ルールを修正する場合には、その習性が正しいことを確認するためのテストを行います。
生成されるコード自体の変更のない、T4 テンプレートで記述された変換ルール群のリファクタリングでのテスト確認ポイントは、
空白やタブ、改行以外の変更がないこと
です。変換ルールを不用意にちょっと変えただけで、コンパイルエラーが数百万発生させることも可能なので、注意深く修正作業を進めていきます。
例えば、修正前の変換ルールで生成していた部分は、行の先頭に、'//'をつけてコメント化したり、#if false ~ #endif 等で囲って残しておいて、新たに追加した変換ルールで生成したコードと比較し、同一であることの目視確認を行ってから、修正前の変換ルールを削除するなどします。
生成されたコード一式を Visual Studio で開いておいて、
修正 → 生成 → コンパイルテスト →
を繰り返すと、正しい修正が行えます。
以上、解説してきた修正を行い、更に幾つか細かいところを修正した変換ルールセットを Tag 20220720 で公開しているので、読み込んでみてください。
最後に
概念モデリング関連のチュートリアルでは、作業の過程のリアル感が、可能な限り読者に感じてもらいたいと思って作成しています。いきなり完璧に近いものを見せられるよりも、ポカも含め作業の過程が見れた方が、魔法感が減るのではないかと思います。
本稿が、「変換による実装って、いいかも」とそろそろ感じ出した読者の糧になれば幸いです。