小規模案件のデスクトップアプリをMVCで設計する
開発委託でのリスク
最近、客先の受託開発の案件で、**「既存の流用ソフトウエアを開発した後に、新規機能を付けたデスクトップアプリケーションを開発したい」**と要望があった。
客先からしてみれば、開発委託案件であるから、当然ソフトウエアは他社に外注して全ての品質を担保して作ることが大前提である。
ところが、それは大体IT企業として商売をしているところなどの常識であり、委託側に設計能力がない場合、仕様すら決まらないときが結構あるのだ。
そういうチームでは、割とこういうことが起きる。
すると、そのソースコードを読んだりして必死に仕様に解読するんだけど、ドキュメントない、設計書ない、テスト仕様もない状況になり、結局そういうことが出来る専門家を探すということで外注を始める。
もちろん、そういう案件があること自体は構わない。しかし、ここでの設計を全任してしまうと・・・。
という具合に、委託側は揉めに揉めて工期が大幅に伸び、受託側も他社に乗り換えられるリスクが出てくる、誰も幸せにならん未来が訪れてしまうのである。
フロントエンドとバックエンド
当然、Web業界ではこういう状況が起きるのは非常に厳しいので、サーバ周りをバックエンド、画面やバリデーションなどをフロントエンドにして設計する。
要望があれば、委託側をフロントエンドチームに設定することでもあるだろう。
フロントエンド側の開発はHTMLになるので、作業者もある程度見繕いやすいのだ。
デスクトップアプリでの問題点
ところが、環境によっては、こういう分担なんて存在しない場合がある。
特にデスクトップアプリでよくある理由は、画面は**「デザイナで画面を簡単に書けるから」**である。
この記事を書いている人も多くのフレームワークを触ってきたとは言えないが、現代の多くのIDEではデザイナをサポートしているものが多い。
C++系の有名なものに限っても、そのチープさ、リッチさ、運用環境などには差があるものの、Qt, GTK, wxWidgets, TK, Embarcadero C++ , mfcなど様々あり、こうしたフレームワークは**「デザイナの美しさ、軽さこそ、生産性向上で重要な要素であり、正義」**みたいなところがある。
もちろん、視野を広げれば、最近ではelectron.jsなどJavaScriptやTypeScript、HTMLなどを再利用できる環境もあるにはあるのだけど、**「やっぱクロスプラットフォームなんかより、デザイナでGUIペタ貼出来る方がいいんじゃい」**という人はまだ生存している。
これは大きな問題である。
開発委託において、見た目や挙動まで全部がぜーんぶ付き合いしていたら、先ほども言うたように工数もかかり委託側はイライラするであろう。
いくらデザイナでの編集が容易だからと言って、委託先は「そもそも開発委託で全部済ませる」とすれば、**「1つの画面を新しく作って欲しい」**程度の簡単な要求すら実現できない可能性があるのだ。
WinFormsにMVCアーキテクチャもどきを導入する
今回の案件は今更ながら.NET Framework 4.8 (#)の環境なので、古いと言えば古いが、
まだまだサポート切れまでは多少の猶予があるので、これで一旦開発検討している。
(ちなみに、.NET FrameworkはEnterprise版でしか長期サポートが受けられなくなっているので、
今から採用するのはお勧めしないです。
・・・・・・って言いたいんですけど、Microsoft社のVisual Studio 2022やWindows 11の件でぎくしゃくしていて、、
最先端技術を取り入れる必要がある会社を除いて
2021年段階で「最新版のVisual Studioをそろえる必要性ありませんわ」
って状況だと思われるので、まだ暫くは移行できないですね・・・。)
こんな感じの分担を実現すると、分業がはかどる。
Viewの部分をフロントエンド、Modelの部分をバックエンドとして、分割し、委託側にViewの画面を作らせて、開発受託としてよりコアな部分を作ることで担当領域を分割する。
使うGUI APIはWinFormsだ。
こうした案件をやる場合、委託側でも開発出来る必要があるので、なるべく取り扱いやすくする必要がある。
この時点でUWPなどのメトロアプリを使った設計は候補からなくなる。WPFも、使いたい部分だけライブラリとして提供し、ElementHostなどを使ってWinFormsに貼り付ける形式が望ましいだろう。
こんな感じで委託側に環境整備させてあげたい。
今回の案件は、「既存の流用ソフトウエアの再開発」なので、一部の画面を委託側に作らせて、内部ロジックを受託案件としてやる方向性で考えた。細かい話は準備編で書いておく。
例1. 新規ウインドウの作成
namespace WinFormsMVC
{
namespace View
{
public partial class Form1 : BaseForm
{
~(省略)~
/* ボタンイベント */
private void button1_Click(object sender, EventArgs e)
{
// コントローラ取得
var controller = Facade.GetController<Form1Controller>(this);
// フォーム実行
controller.LaunchForm2(this);
}
}
}
}
using WinFormsMVC.Model.Services;
using WinFormsMVC.View;
namespace WinFormsMVC.Controller
{
class Form1Controller : Controller
{
private FormManager _manager;
/* コンストラクタでFormManager渡す。このコンストラクタはコントローラ生成時に新規で作られる */
public Form1Controller(FormManager manager)
{
_manager = manager;
}
/* フォームを開く */
public void LaunchForm2(Form1 self_form)
{
var forms = new Form2();
/* ここでフォームを開く。仕事はFormManagerに一任する */
_manager.LaunchForm(self_form, forms);
}
}
}
実行結果
例2. あるウインドウから別のウインドウ(2つ)に命令を送信する
namespace WinFormsMVC
{
namespace View
{
public partial class Form2 : BaseForm
{
~省略~
/* ここから開かれた子のウインドウForm3, Form4に、データを送信する */
private void button2_Click(object sender, EventArgs e)
{
/** 送信内容.
・InitOperationで現在のデータを確保する。
・PrevOperationで元に戻すときの動作を記載する。
・NextOperationで実行・やり直しのときの動作を記載する。
*/
var controller = Facade.GetController<Form2Controller>(this);
controller.SendMessage( new AbstractCommand[] {
new Command<Form3> {
Invoker=this,
InitOperation = (command, form3) =>
{
command.PrevTemporary = form3.Message; /* form3はForm3型 */
command.NextTemporary = textBox1.Text;
return true;
},
PrevOperation = (command, form3) =>
{
if (command.PrevTemporary != null)
{
form3.Message = command.PrevTemporary; /* form3はForm3型 */
}
},
NextOperation = (command, form3) =>
{
if (command.NextTemporary != null)
{
form3.Message = command.NextTemporary; /* form3はForm3型 */
}
}
},
new Command<Form4>() {
Invoker = this,
InitOperation = (command, form4) =>
{
command.PrevTemporary = form4.Message; /* form4はForm4型 */
command.NextTemporary = textBox1.Text;
return true;
},
PrevOperation = (command, form4) =>
{
if (command.PrevTemporary != null)
{
form4.Message = command.PrevTemporary; /* form4はForm4型 */
}
},
NextOperation = (command, form4) =>
{
if (command.NextTemporary != null)
{
form4.Message = command.NextTemporary; /* form4はForm4型 */
}
}
}
});
}
/* Form3を開く */
private void button1_Click(object sender, EventArgs e)
{
var controller = Facade.GetController<Form2Controller>(this);
controller.LaunchForm3(this);
}
/* Form4を開く */
private void button4_Click(object sender, EventArgs e)
{
var controller = Facade.GetController<Form2Controller>(this);
controller.LaunchForm4(this);
}
}
}
}
using WinFormsMVC.Model.Command;
using WinFormsMVC.Model.Services;
using WinFormsMVC.View;
namespace WinFormsMVC.Controller
{
public class Form2Controller : Controller
{
/* コンストラクタでFormManager渡す。このコンストラクタはコントローラ生成時に新規で作られる */
public Form2Controller(FormManager manager)
{
_manager = manager;
}
/* フォームを開く */
public void LaunchForm3(Form2 self_view)
{
_manager.LaunchForm(self_view, new Form3());
}
/* フォームを開く */
public void LaunchForm4(Form2 self_view)
{
_manager.LaunchForm(self_view, new Form4());
}
/* メッセージを送る */
public void SendMessage(AbstractCommand[] abstractCommand)
{
_manager.Operate(abstractCommand);
}
}
}
実行結果
Form2, Form3のみを開いている
メッセージ送信
Form4も開いてメッセージ送信
例3.「元に戻す」を実装
namespace WinFormsMVC
{
namespace View
{
public partial class Form2 : BaseForm
{
~省略~
private void button3_Click(object sender, EventArgs e)
{
var controller = Facade.GetController<Form2Controller>(this);
controller.Redo(); /* 元に戻す */
}
}
}
}
using WinFormsMVC.Model.Command;
using WinFormsMVC.Model.Services;
using WinFormsMVC.View;
namespace WinFormsMVC.Controller
{
public class Form2Controller : Controller
{
/* コンストラクタでFormManager渡す。このコンストラクタはコントローラ生成時に新規で作られる */
public Form2Controller(FormManager manager)
{
_manager = manager;
}
/* 元に戻す */
public void Redo()
{
_manager.OperatePrevious();
}
}
}
実行結果
先ほどの結果をRedoする
準備
1. Viewの代表クラスとしてBaseFormを用意する。
単純なことだ。
通常フォームを作るときは、自動的にフォーム生成が出来るのだが、
独自で専用フォームを作ってそこで継承させるようにする。
最新のVS 2019の画面では、フォーム作成をしようとすると、**「継承されたフォーム(Inherited Form)」**が欄として追加される。
Formの設定で一部独自のプロパティを足したいときにこれを使う。
そして、今回定義したBaseFormには次のように書いた。
using System.Windows.Forms;
using WinFormsMVC.Facade;
namespace WinFormsMVC.View
{
public partial class BaseForm : Form
{
/* Facade(窓口)。Controllerを直接取得したいときに呼び出す。 */
public ViewFacade Facade { get; set; }
/* Invoker(実行者)。このフォームを実行したクラスを表す。*/
public BaseForm Invoker { get; set; }
public BaseForm()
{
InitializeComponent();
}
}
}
こうすることで、Form間で「親子関係」を割り振ることが出来、またFacadeを使うことでフォーム間の連動動作を作り出すことが出来る。
2. 2つのフォームを行き来するような処理を考えるとき、コマンドクラスを定義する。
今回考えたCommandクラスはこんな感じ
using System;
using WinFormsMVC.View;
namespace WinFormsMVC.Model.Command
{
public abstract class AbstractCommand
{
/* 命令の実行者.
FormのInvokerが一致しているクラスのみに対して、処理を行なえるようにする。 */
public BaseForm Invoker { get; set; }
/* 命令を指定するフォームのタイプ
Invokeする対象のフォームはここで判定する */
public abstract Type FormType
{
get;
}
/* フォーム間が関係する処理の初期化 */
public abstract bool Initialize(BaseForm form);
/* 元に戻す(Undo) */
public abstract void Prev(BaseForm form);
/* 実行とやり直し(Redo) */
public abstract void Next(BaseForm form);
/* Redo後に実行*/
public abstract void Finalize(BaseForm form);
/* 初期化エラー時に実行*/
public abstract void HandleInitError(BaseForm form);
}
}
using System;
using WinFormsMVC.View;
namespace WinFormsMVC.Model.Command
{
/* ジェネリックを使って、TargetForm固有の処理に割り当てる。
Formで命令を書くときに、 */
public class Command<TargetForm> : AbstractCommand where TargetForm : BaseForm
{
/*実行(やり直し)時の一時テキスト */
public string NextTemporary
{
get;
set;
}
/*元に戻す時の一時テキスト */
public string PrevTemporary
{
get;
set;
}
/* Invoke対象のフォームはジェネリックで指定した型を使用する */
public override Type FormType
{
get
{
return typeof(TargetForm);
}
}
/* 初期化処理(ラムダ式) */
public Func<Command<TargetForm>, TargetForm, bool> InitOperation { get; set; }
/* 実行&やり直し(ラムダ式) */
public Action<Command<TargetForm>, TargetForm> NextOperation { get; set; }
/* 元に戻す(ラムダ式) */
public Action<Command<TargetForm>, TargetForm> PrevOperation { get; set; }
/* 元に戻す終了(ラムダ式) */
public Action<Command<TargetForm>, TargetForm> FinalOperation { get; set; }
/* 初期化エラー(ラムダ式) */
public Action<Command<TargetForm>, TargetForm> ErrorOperation { get; set; }
/** 以降抽象クラスから継承したメソッドの実装 **/
public override bool Initialize(BaseForm form)
{
if (InitOperation != null)
{
return InitOperation(this, (TargetForm)form);
}
else
{
return true;
}
}
public override void Prev(BaseForm form)
{
if (PrevOperation != null)
{
PrevOperation(this, (TargetForm)form);
}
}
public override void Next(BaseForm form)
{
if (NextOperation != null)
{
NextOperation(this, (TargetForm)form);
}
}
public override void Finalize(BaseForm form)
{
if (FinalOperation != null)
{
FinalOperation(this, (TargetForm)form);
}
}
public override void HandleInitError(BaseForm form)
{
if (ErrorOperation != null)
{
ErrorOperation(this, (TargetForm)form);
}
}
}
}
このように、フォームとフォームの間はコマンドを生成し、コントローラがそれを媒介するような仕組みにする。
このコードを、フロントエンド側の理解力に応じて色々なパラメータを振れるようにしたらよいのだ。
3. FormManagerを作成
現在表示されているフォームが何かを指定するために、FormManagerクラスを作成し管理させる。
これはModelのコアプログラムに置いておく。そして、コントローラではこのクラスを呼び出せるようにする。
using System;
using System.Collections.Generic;
using WinFormsMVC.Facade;
using WinFormsMVC.View;
namespace WinFormsMVC.Model.Services
{
public class FormManager
{
/* 現在管理しているフォーム */
private List<BaseForm> _managed_baseform;
/* 窓口役。フォームを新規に作るときこのオブジェクトを渡す */
private ViewFacade _facade;
/* GoFのMementoパターンに基づいて作成。Redo/Undoを実装する */
private MementoManager _memento_manager;
/* 窓口役は外部から呼び出せるようにする */
public ViewFacade Facade
{
get { return _facade; }
set { _facade = value; }
}
/* コンストラクタ */
public FormManager()
{
_managed_baseform = new List<BaseForm>();
_memento_manager = new MementoManager();
}
/* ① フォーム新規作成 */
public void LaunchForm<TargetForm>(BaseForm source, TargetForm target)
where TargetForm : BaseForm
{
_managed_baseform.Add(target);
target.Invoker = source;
target.Facade = _facade;
target.Closed += OnFormClosed;
OperateFromInit(target);
target.Show();
}
/* ② 処理実行 */
public void Operate(IEnumerable<Command.AbstractCommand> abstract_command)
{
foreach (var command in abstract_command)
{
var target_forms = new List<BaseForm>();
foreach (var form in _managed_baseform)
{
if (form.Invoker == command.Invoker && form.GetType() == command.FormType)
{
target_forms.Add(form);
}
}
bool was_done = true;
foreach (var target in target_forms)
{
if (command.Initialize(target))
{
command.Next(target);
}
else
{
was_done = false;
break;
}
}
if (!was_done)
{
foreach (var target in target_forms)
{
command.HandleInitError(target);
}
}
}
_memento_manager.PushCommand(abstract_command);
}
/* ③ 処理実行(フォーム新規作成時) */
public void OperateFromInit(BaseForm target)
{
foreach (var recent_commands in _memento_manager.MememtoCommand)
{
foreach (var command in recent_commands)
{
if (target.Invoker == command.Invoker && target.GetType() == command.FormType)
{
command.Next(target);
}
}
}
}
/* ④ 処理を元に戻す */
public void OperatePrevious()
{
var recent_commands = _memento_manager.PopCommand();
if (recent_commands == null)
{
return;
}
foreach (var command in recent_commands)
{
foreach (var form in _managed_baseform)
{
if (form.Invoker == command.Invoker && form.GetType() == command.FormType)
{
command.Prev(form);
command.Finalize(form);
}
}
}
}
/* ⑤ フォームを閉じるときの処理 */
protected void OnFormClosed(object sender, EventArgs e)
{
// 自分自身
BaseForm form = (BaseForm) sender;
// 子フォームを探す
var children_form = new List<BaseForm>();
foreach (var any_form in _managed_baseform)
{
if (any_form.Invoker == form)
{
children_form.Add(any_form);
}
}
// 削除
_managed_baseform.Remove(form);
foreach (var child in children_form)
{
child.Close();
_managed_baseform.Remove(child);
}
}
}
}
細かい部分は置いといても、大体①~⑤が.NET Framework(Winforms)では必要になるポイントだと思われる。
これらの処理は⑤フォームを閉じるときの処理を除いて、Controllerから実行される。ちなみに⑤フォームを閉じるときの処理はWinforms特有の部分なので恐らく必要。
githubリポジトリ
作成中です
私も色々研究中です
知っている人なら何となくピンと来る部分もあるかと思いますが、このコードは結構ASP .NET MVCのやり方を参考にしています。
非常に楽なのですが、良いライブラリがあるわけではないので(MJ.MVCとかはあるが少しややこしいのとバージョン対応が大変そうなので今は触ってない)、思考錯誤しながら、テスト機会を増やして使えるようにしていきたいです
いいやり方あったり、「こうすると使いやすそうだな」と思う点があったら、是非教えてください