このドキュメントの内容
いわゆる「基幹業務システム」では、様々なマスタ登録や伝票入力など多くのフォームを開発します。類似したデザインや機能を持ちますので、開発効率を高める目的で共通要素を実装したフォームを継承する設計手法が採用されることがありますが、この手法には目論んだメリットだけでなく(メリットより大きな)デメリットを伴いやすいです。デメリットを小さくするための工夫を考えてみます。
このドキュメントではこのような継承元のフォームを「ベースフォーム」と呼ぶことにします。
対象
このようなベースフォームに何かしらの釈然としない思いを感じている人。タグに VB.Net を設定しているのはそのような案件が比較的多いと感じるためで(私が SIer に所属しているからということもあります)、そのような環境にいる VB.net プログラマの目に留まればいいなという思いからです。
ベースフォームの歴史的背景
フォームの継承に関するドキュメントはたくさん見つかります。多くは2010年以前の古いドキュメントで、.NET Framework で可能になった継承を使ってみませんか?というニュアンスの内容になっています。想定している読者層は VisualBasic から VB.net に移行したプログラマであったと考えられます。
【MSDN】継承を利用して共通の画面デザインを作成する方法
[【@IT】各フォームの共通要素を基本フォームにまとめるには?] (http://www.atmarkit.co.jp/fdotnet/dotnettips/324winbaseform/winbaseform.html)
【@IT】独自Windowsフォーム・クラスの活用
これらのドキュメントはあくまでも入門編であって、実用レベルではもっと考えなくてはいけないことがたくさんあります。当時は知見が足りない状況で苦労しながらベースフォームを採用した開発が進められました。残念なのは、現在においてもほぼ同じような状況のままの開発プロダクトが少なくないことです。
メリットとデメリット
よく挙げられるメリットは、共通とする要素をベースフォームにまとめると生産性や保守性が高まるという発想からくるものです。
- ヘッダーやフッターのデザインを共通化でき、デザインを変更する場合はベースフォームだけ変更すればよい。
- よく使う機能をベースフォームに実装すれば、個々の継承フォームから簡単に呼び出すことができる。
しかし実際はそんなに簡単ではありません。何を共通要素とするかの視点を誤ると、デメリットのほうが大きくなります。これはベースフォームに限らず継承全般に言えることですが、特にベースフォームはいろいろな機能を取り込んでしまって肥大化しやすいです。
デメリット1:共通化したデザインが足枷になる
あるフォームに多くのコントロールを配置する必要があるがヘッダーやフッターが邪魔になって配置できないような場合、「仕様上の制限で別フォームに分けなくてはいけません」といった説明をすることになりますが、簡単に話はまとまりません。特に Windows フォームアプリケーションでは継承後のデザイン変更は簡単でなかったりします。「要件定義や基本設計でヒアリングした情報からデザインを共通化しようと考えたのに!」という恨み節が聞こえてきそうですが、実際はそんなものです。
デメリット2:状態制御などのための実装が複雑化する
半数のフォームに必要なコントロールや機能であっても、もう半数のフォームにとっては必要ではありません。非表示にしたり使用不可にしたりするなど、想定されるケースに合わせて適切に制御しなくてはならなくなります。状態制御は不具合が生まれやすい実装の一つのため、品質上のリスクが生じます。非同期処理が絡むと複雑さは増します。
デメリット3:何がどの機能と関連しているのかが分かりにくくなる
いろいろな機能を実装すると、それぞれの機能に必要なプロパティやメソッドの数も多くなります。フォームデザイナに大量のプロパティが羅列されることになります。何がどの機能と関連しているのかが分かりにくくなります。既存のソースコードがコピー&ペーストされるうちに、必要でないプロパティに対して値を設定するコードが残ってしまったりして可読性が落ちていきます。
デメリット4:ベースフォームの開発が進捗上のボトルネックになる
ベースフォームに実装しなくてはならない機能が多くなると、ベースフォームの開発に時間がかかります。ベースフォームの開発が終わるまで継承フォームの開発ができないか、ベースフォームの実装を後回しにして継承フォームの開発を始めざるを得なくなります。それが表示データのファイル出力のような外付け的な機能であれば影響は小さいですが、入力操作フローに関わるような機能であると影響が大きくなります。
ベースフォームの改善
私が見た事例
以前に私が見た、ある開発プロダクトで採用されていたベースフォームのクラス図の簡略版です。
基本設計者と直接話をする機会はありませんでしたが、次のような意図や目的があったものと推察します。
- フォームを機能の単位とした。
- 機能をデータ入力/帳票出力/バッチ処理の三つに分け、それぞれの共通処理を実装するベースフォームを用意した。
- それぞれの機能の開始から終了までの操作の流れを標準化しようとし(標準化できると判断し)、それぞれのベースフォームにフローを実装した。フローの中で呼び出す個々の処理をオーバーライド可能メソッドとし、各業務フォームでオーバーライドさせることにした。
改善ポイント1:機能の単位を見直す
フォームを機能の単位とするのはスコープが大きすぎます。データ入力フォームと帳票出力フォームに対象データを取得する処理がありますが、何れも指定された条件に該当するデータを取得する点では同じであり、重複しています。この「指定された条件に該当するデータを取得する」を1機能とみなせば重複はなくなります。同様に「指定された帳票を出力する」「指定されたバッチ処理を実行する」といった単位で機能を分割します。データ取得処理にも帳票出力処理にもバッチ処理にもログを出力できることが求められますが、「ログを出力する」ことも1機能と考えます。
この事例ではベースクラスにログ出力機能が実装されていますが、ログを出力したければベースフォームを継承するかログ出力処理を個別に実装する必要がありました。個別実装された例を見るとベースフォームの実装をコピペしており、クローンコード化していました。ログ出力が一つの機能として独立していればそれを利用するだけでよかったはずです。NLog
や log4net
を使ったことがある方であれば「なるほど」と思われるはずです。
機能を分割すると、その機能が必要とするプロパティと機能の対応関係が明確になります。誤用が減り、プロパティ名もシンプルにできます。仕様変更を行わなくてはならなくなった場合の影響範囲が小さくなり、影響範囲を見極めやすくなります。もちろんそれぞれの機能が独立性を保つようにする考慮は必要です。
改善ポイント2:標準化されたフローを強制しない
私が見たこの事例では、「データ検索ボタンが押されてからデータを取得して画面に表示する」といったフローがベースフォーム側に実装されていました。データ検索ボタンもベースフォームに配置されています。データ検索のためのSQL文を組み立てたり、画面に表示するといったフロー内の個々の処理の部分だけを継承フォームでオーバーライドするという設計です。
このような設計はフロー通りに処理が流れることが大前提になり、柔軟性に欠けます。処理の分岐や割込みを可能にするには、ベースフォームのフローにそのような考慮が必要になります。柔軟性を求めれば求めるほどフローは複雑になり肥大化します。継承フォーム側での誤用も増えるでしょう。
フローを標準化する場合、フローの単位を小さくすることと、標準化されたフローを使わなくても実装できる手段も提供することが重要だと思います。
改善ポイント3:ユーザーインターフェースとビジネスロジックの分離
ユーザーインターフェースは陳腐化が速いです。標準コントロールはまだしも、サードパーティコントロールを利用している場合はそのバージョンにロックインします。コントロールのサポート切れやマルチプラットフォーム対応などのためにユーザーインターフェースを変更せざるを得なくなった場合、ユーザーインターフェースとビジネスロジックが一体になっていると容易に変更できません。例えばこの事例の WEB 化の企画があがっても既存資産はほとんど利用できず、スクラッチ開発するしかないと思います。
そこで委譲です
複数の機能を組み合わせる場合、継承より委譲のほうが向いています。
機能のピックアップ
まず機能をピックアップします。どのように機能を呼び出すかをイメージして引数と戻り値を考えます。
機能 | 引数 | 戻り値 |
---|---|---|
指定された条件に該当するデータを取得する(※1) | データ抽出コマンド | データテーブル |
指定されたデータを保存する(※1) | データ更新コマンド | 更新レコード数 |
帳票レイアウトと出力データを受け取って帳票ドキュメントを生成する | 帳票レイアウト, データテーブル | 帳票ドキュメント |
帳票ドキュメントを受け取ってプレビューする | 帳票ドキュメント | なし |
帳票ドキュメントを受け取って印刷する | 帳票ドキュメント | なし |
帳票ドキュメントを受け取ってPDFファイルを生成する | 帳票ドキュメント, 出力ファイル名 | なし |
時間のかかるバッチ処理を実行する(※2) | 実行する処理 | 実行結果 |
ログを出力する | ログメッセージ | なし |
(※1)データ取得とデータ保存については、データベース相手であればデータベースプロバイダのメソッド(例えば ADO.NET の IDbCommand.ExecuteReader メソッドや IDbCommand.ExecuteNonQuery メソッド)を呼び出すだけで処理できます。実行するだけであればわざわざ機能として切り出す必要はありませんが、機能として切り出しておくとログ出力や例外処理など前後の処理を共通化しやすくなります。なお、ADO.NET のログ出力には AdoNetProfiler のようなライブラリを利用する方法もあります。
(※2)非同期処理は Task を使う前提でよいと思います。とすると※1と同じくわざわざ機能として切り出す必要がない場合もあります。
【Qiita】Taskを極めろ!async/await完全攻略
【Qiita】ThreadじゃなくTaskを使おうか?
機能の実装
ピックアップした機能をインターフェースやクラスとして定義します。インターフェースは使い慣れていない方にとってはとっつきにくい印象があると思いますが、拡張性や汎用性を求めるのであればインターフェースのほうが向いています。
帳票機能に関するインターフェースを考えてみました。帳票によって出力データの種類や生成処理は異なりますが、プレビュー・印刷・ファイル出力は同じであることが多いですので、帳票の生成と帳票の利用の観点で二つのインターフェースに分けました。
''' <summary>
''' 帳票に必要な機能を提供します。
''' </summary>
''' <typeparam name="TDocument">帳票ドキュメントの型</typeparam>
''' <typeparam name="TData">出力データの型</typeparam>
Public Interface IReport(Of TDocument, TData)
Inherits IDisposable
''' <summary>
''' 帳票ドキュメントを生成します。
''' </summary>
''' <param name="data">出力データ</param>
''' <returns>帳票ドキュメント</returns>
Function CreateDocument(data As TData) As TDocument
End Interface
''' <summary>
''' 帳票出力に必要な機能を提供します。
''' </summary>
''' <typeparam name="TDocument">帳票ドキュメントの型</typeparam>
Public Interface IReportRunner(Of TDocument)
''' <summary>
''' 帳票ドキュメントをプレビューします。
''' </summary>
''' <param name="document">ドキュメント</param>
Sub Preview(document As TDocument)
''' <summary>
''' 帳票ドキュメントを印刷します。
''' </summary>
''' <param name="document">ドキュメント</param>
Sub Print(document As TDocument)
''' <summary>
''' 帳票ドキュメントをPDFに出力します。
''' </summary>
''' <param name="document">ドキュメント</param>
Sub OutputToPdf(document As TDocument)
End Interface
前述の例に挙げたプロジェクトでは ActiveReports が使用されていましたので、ActiveReports を対象として上記のインターフェースを実装するクラスを考えてみました。非同期での利用は考慮していません。今手元に実行できる環境がないため、記憶を辿っています。ご了承ください。
''' <summary>
''' ActiveReport を使用した帳票生成の基本実装。
''' </summary>
Public Class ActiveReportCreator(Of TData)
Implements IReport(Of Document, TData)
''' <summary>
''' 帳票生成・実行メソッドを指定してインスタンスを生成します。
''' </summary>
''' <param name="activator">帳票を生成するメソッド</param>
''' <param name="runner">帳票を実行するメソッド</param>
Public Sub New(activator As Func(Of ActiveReport3), runner As Action(Of ActiveReport3, TData))
m_Activator = activator
m_Runner = runner
End Sub
''' <summary>
''' 帳票ドキュメントを生成します。
''' </summary>
''' <param name="data">出力データ</param>
''' <returns>帳票ドキュメント</returns>
Public Function CreateDocument(data As TData) As Document Implements IReport(Of Document, TData).CreateDocument
If (m_Report Is Nothing) Then m_Report = m_Activator()
m_Runner(m_Report, data)
Return m_Report.Document
End Function
''' <summary>
''' 帳票オブジェクト
''' </summary>
Private m_Report As ActiveReport3
''' <summary>
''' 帳票を生成するメソッド
''' </summary>
Private m_Activator As Func(Of ActiveReport3)
''' <summary>
''' 帳票を実行するメソッド
''' </summary>
Private m_Runner As Action(Of ActiveReport3, TData)
# Region "IDisposable Support"
'割愛します。m_Report を解放します。
# End Region
End Class
この ActiveReportCreator(Of TData) インスタンスを生成する処理を帳票ごとに実装します。ActiveReports では DataSet などのデータソースを設定してレポートの中でデータ項目の値を参照する方法があります。公式サンプルにもそのような実装が多いですが、どのようなデータ項目が必要かを呼び出し元が知っている前提になり、密結合が発生します。もちろんそのような方法でもよいですが、ここでは出力データではなく出力条件を受け取る実装としてみました。
''' <summary>
''' 売上帳票の出力条件
''' </summary>
Friend Class SalesCondition
'割愛します。出力対象期間や店舗などの条件値がプロパティとして実装される想定です。
End Class
''' <summary>
''' 売上月報
''' </summary>
Friend Class MonthlySalesReport
Inherits ActiveReport3
# Region "帳票生成"
''' <summary>
''' 帳票生成オブジェクトを生成します。
''' </summary>
''' <returns></returns>
Friend Shared Function GetCreator() As ActiveReportCreator(Of SalesCondition)
Return New ActiveReportCreator(Of SalesCondition)(AddressOf Activate, AddressOf Run)
End Function
''' <summary>
''' レポートインスタンスを生成します。
''' </summary>
''' <returns>レポート</returns>
Private Shared Function Activate() As ActiveReport3
Return New MonthlySalesReport()
End Function
''' <summary>
''' レポートを実行します。
''' </summary>
''' <param name="report">レポート</param>
''' <param name="condition">出力条件</param>
Private Shared Sub Run(report As ActiveReport3, condition As SalesCondition)
'condition に該当するデータを取得して report のデータソースに設定し、ActiveReport3.Run を実行します。
End Sub
# End Region
End Class
最後に IReportRunner(Of Document) インターフェースの実装です。これはプロジェクト共通部品の位置づけのクラスとして実装するイメージです。
''' <summary>
''' ActiveReport を使用した帳票を実行します。
''' </summary>
Public Class ReportRunner
Implements IReportRunner(Of Document)
''' <summary>
''' 帳票ドキュメントをプレビューします。
''' </summary>
''' <param name="document">ドキュメント</param>
Public Sub Preview(document As Document) Implements IReportRunner(Of Document).Preview
'プレビューフォームを表示する
End Sub
''' <summary>
''' 帳票ドキュメントを印刷します。
''' </summary>
''' <param name="document">ドキュメント</param>
Public Sub Print(document As Document) Implements IReportRunner(Of Document).Print
'ドキュメントを印刷する
End Sub
''' <summary>
''' 帳票ドキュメントをPDFに出力します。
''' </summary>
''' <param name="document">ドキュメント</param>
Public Sub OutputToPdf(document As Document) Implements IReportRunner(Of Document).OutputToPdf
'ドキュメントをPDFファイルに出力する。
End Sub
End Class
利用イメージ
これらのインターフェースとクラスを用いた帳票印刷処理の実装イメージです。帳票出力に関する実装をフォームから分離できました。次のような場合にも対応しやすくなったと思いませんか?
- このフォームから売上日報と売上月報を選択して出力できるようにしなくてはならなくなった
- 別のフォームからも売上月報を出力できるようにしなくてはならなくなった
Friend Class TestForm
''' <summary>
''' 売上月報を印刷します。
''' </summary>
Private Sub PrintMonthlySalesReport()
Dim runner As IReportRunner(Of Document) = New ReportRunner()
'出力条件を取得
Dim condition As SalesCondition = GetCondition()
'帳票を生成して印刷
Using report As IReport(Of Document, SalesCondition) = MonthlySalesReport.GetCreator()
Using doc As Document = report.CreateDocument(condition)
runner.Print(doc)
End Using
End Using
End Sub
End Class
まとめ
オブジェクト指向の解説では、is-a
(〇〇は◇◇である)と has-a
(〇〇は◇◇を持っている)の関係についての説明がよく出てきます。委譲は has-a
の関係を表すのに適しています。フォームの場合、ボタンなどから実行する機能はまさしく has-a
です。「フォームは帳票印刷機能である」は明らかに不自然です。「フォームは帳票印刷機能を持っている」が自然です。このように考えれば、自然と「ここは委譲で」と考えられるようになると思います。
参考リンク
継承と委譲については多くのドキュメントがあります。
【ikenox.info】継承と委譲の使い分けと、インターフェースのメリット
【Where is the mate to the Sock ?】委譲か継承か、それが問題だ
「デザインパターン」で委譲を用いたいろいろな設計パターンを学ぶことができます。