汎用帳票生成ライブラリの実装
帳票生成のソフトウェア アーキテクチャ
さて、具体的な実装に触れる前に帳票生成の仕組みをまず整理しましょう。
今回の帳票生成ライブラリでは、帳票のテンプレートにはExcelを利用することを大前提とします。このためライブラリの設計としては、Excelの存在を隠ぺいするような抽象化は行わないこととします。
また、帳票の出力項目が変わらない限りは自由にExcelのテンプレートを編集することを可能とし、プログラムの改修を伴わずに帳票のレイアウトを変更できるようにします。このため項目値の埋め込みに具体的なセル(A1やB2など)を指定することはできません。
帳票の形式は1つの表を含む帳票を前提とし、表以外に任意の単項目を設定できるものとします。表にはExcelのテーブルを利用します。テーブルを利用する理由については後述します。テーブルを利用するため、表でセルの結合を利用することができません。このため帳票内で複数の表を扱うことは現実的ではないでしょう。テーブルを使わなければ複数の表に埋め込むことも不可能ではないでしょうが、実装の難易度やユーザビリティが大幅に悪化することが想定できます。多くの帳票が単一の表でカバーできるであろうことから、今回は対象としません。
Excelへ値の適用ルール
下図はExcelのテンプレートの上半分を表示したものです。
大切な点がいくつかあります。今回は次のルールでテンプレートへ値の適用を行います。
- 単項目は、項目名を判別可能な形でテンプレートへ記載する(この例では「$~」のセルを動的な値で置き換える)
- 明細はExcelのテーブルを利用する
- テーブルは、1行目に項目名を定義する
ヘッダーは業務要求で変更が発生する可能性が高いので、マッピングへの利用を避けます。
クラス設計と実装
さて先にも見ていただいたように、帳票生成ライブラリを使うにあたり次のコードでライブラリを初期化していました。
var reportBuilder = new ReportBuilder<InvoiceDetail>(stream) // 単一項目のSetterを設定 .AddSetter("$SalesOrderId", cell => cell.Value = invoice.SalesOrderId) ... // テーブルのセルに対するSetterを設定 .AddTableSetter("$ProductName", (cell, detail) => cell.Value = detail.ProductName) ...
帳票生成はReportBuilderクラスを利用して行います。このクラスの設計は次のモデルのようになっています。
IReportBuilderとIRange、2つのインターフェースと、それぞれの実装クラスが存在します。IReportBuilderは帳票生成の処理を抽象化したインターフェースで、次の3つのメソッドが定義されています。
public interface IReportBuilder<TReportRow> { /// <summary> /// 単項目をアプリケーション側に設定させるため、設定対象のセル(IRange)を引数に /// コールバックさせるためのActionを登録する。 /// </summary> IReportBuilder<TReportRow> AddSetter(object key, Action<IRange> setter); /// <summary> /// 表項目をアプリケーション側に設定させるため、設定対象のセル(IRange)を引数に /// コールバックさせるためのActionを登録する。 /// </summary> IReportBuilder<TReportRow> AddTableSetter(string key, Action<IRange, TReportRow> setter); /// <summary> /// 表の行オブジェクトを引数に指定して帳票を生成する /// </summary> void Build(IList<TReportRow> rows, Stream stream, SaveFileFormat saveFileFormat); }
IReportBuilderは型パラメーターとして、表の行を表すクラスを指定します。
ポイントは次の通りです。ライブラリを生成する側は、設定する先をセルの座標で指定しません。論理的な名称(つまり文字列)を指定し、ライブラリ側がその位置を特定してセルオブジェクト(Excelのオブジェクト設計の慣例に従いIRangeとしています)を引数にアプリケーション側をコールバックし、アプリケーション側で必要な値をラムダ式内で設定しています。
こうすることでテンプレートとなるExcelを編集し、項目を表示すべきセルの座標が変わってもプログラム側を編集することなく適用可能になります。
Add~の戻り値がIReportBuilder<TReportRow>になっていますが、下のコードサンプルのように設定用コールバックをメソッドチェーンで設定できるようにするためのものです。メソッドチェーンには好みの問題がありますが、利用者が選択できるようにしています。
var reportBuilder = new ReportBuilder<InvoiceDetail>(stream) // 単一項目のSetterを設定 .AddSetter("$SalesOrderId", cell => cell.Value = invoice.SalesOrderId) .AddSetter("$OrderDate", cell => cell.Value = invoice.OrderDate) .AddSetter("$CompanyName", cell => cell.Value = invoice.CompanyName) ...
IRangeインターフェースはExcelのセルを表すオブジェクトです。実際には単独のセルだけではなく、結合されたセルを扱うこともあるため、Excelのオブジェクト設計の慣例にならってIRangeとしています。以下のようにsetだけがあるValueプロパティを持っており、コールバックしてアプリケーション側からExcelのセルに値を設定できるようにしています。
public interface IRange { object Value { set; } }
IRangeの実装クラスは次のようになっています。
public class Range : IRange { internal GrapeCity.Documents.Excel.IRange DioDocsRange { private get; set; } public object Value { set => DioDocsRange.Value = value; } }
内部的にDioDocsのIRangeオブジェクトを所持していて、そのラッパーの役割を担っています。帳票生成ライブラリはExcelベースのライブラリであることを隠ぺいしませんが、DioDocs自体は隠ぺいする設計にしています。これには主に2つの理由があります。
- 帳票生成ライブラリの使用者のテストコードを書く際、サードパーティクラスに依存しているとテストの自由度が下がるため
- DioDocs以外のライブラリに差し替えられる余地を残すため
それではいよいよIReportBuilderの実装クラスを見ていきましょう。全体をここに載せると見通しが悪くなるので、少しずつ切り取ったコードで解説していきます。コードの全体像はこちらをご覧ください。
まずフィールドを見ていきましょう。
private readonly byte[] _template; private readonly string _tableName; private readonly Dictionary<object, Action<IRange>> _setters = new Dictionary<object, Action<IRange>>(); private readonly Dictionary<object, Action<IRange, TReportRow>> _tableSetters = new Dictionary<object, Action<IRange, TReportRow>>();
テンプレートとなるExcelファイルを読み込んだバイト列と、帳票内の表として使うExcelのテーブルの名称(Excelのデザインタブでテーブルに名前を付けられます)、それから単項目と表の項目へ値を設定するためのコールバックActionを保持するDictionaryを持っています。
Excelの読込先について、バイト列ではなくIWorkbookにすることも考えられます。しかし、ReportBuilderのインスタンスを再利用するケースを考えた場合、2度目の生成時に1度目では値を設定したセルが2度目に設定しないとなると、前回の値を引き継いでしまう不具合が発生する可能性があります。こうした不具合の温床となる可能性があるため、Workbookは毎回開き直す実装にしています。
続いてコンストラクタとコールバックアクションの登録メソッドを見てみましょう。
public ReportBuilder(Stream template) { _template = new byte[template.Length]; template.Read(_template, 0, (int)template.Length); _tableName = typeof(TReportRow).Name; } public IReportBuilder<TReportRow> AddSetter(object key, Action<IRange> setter) { _setters[key] = setter; return this; } public IReportBuilder<TReportRow> AddTableSetter(string key, Action<IRange, TReportRow> setter) { _tableSetters[key] = setter; return this; }
今回、コンストラクタはStreamを受けるようにしていますが、byte[]やFileへのパスを受け取ってもいいでしょう。
また、テーブルの名称はデフォルトではテーブルの行に該当する情報を保持しているクラス(今回はInvoiceDetail)の名称を設定しています。これは任意に指定するためのコンストラクタがあってもいいと思います。
それでは、実際に帳票を生成しているBuildメソッドを見ていきます。ここには大きく分けて次の4つの処理が含まれています。
- 単項目への値の設定
- テーブルへ設定する行数がテンプレートのExcelのテーブルの行数より多かった場合の調整
- テーブルの列設定を解析し、どの項目を何列目に設定すべきか解析
- テーブルへ値を設定
順番に見ていきましょう。まずは単項目の値を設定します。
public byte[] Build(IList<TReportRow> rows) { IWorkbook workbook; using (var inputStream = new MemoryStream(_template)) { workbook = new Workbook(); workbook.Open(inputStream); } var worksheet = workbook.Worksheets[0]; // コールバックに渡すためのIRangeオブジェクト // 都度生成すると、大きな帳票ではインスタンス生成コストが無視できない // 可能性があるため、インスタンスを使いまわす var range = new Range(); // 利用している領域を走査して、単一項目を設定する var usedRange = worksheet.UsedRange; for (var i = 0; i < usedRange.Rows.Count; i++) { for (var j = 0; j < usedRange.Columns.Count; j++) { var cell = usedRange[i, j]; if (cell.Value != null && _setters.ContainsKey(cell.Value)) { range.DioDocsRange = cell; _setters[cell.Value](range); } } }
帳票生成は先頭のワークシートを利用します。
値の設定はコールバックにて行いますが、その際にDioDocsのRangeをラップして渡します。値を設定する回数が多いとラップオブジェクトの生成コストが気にかかりますので(実際は影響ないと思いますが)、Rangeクラスのインスタンスは使いまわしています。
単項目を設定する範囲の走査はWorksheetのUsedRangeプロパティによって、テンプレートで利用している領域にのみ行います。
使用領域を走査していき、セルに値設定用のコールバックを登録したときのkeyと同じ値が設定されている箇所を探します。該当するセルを見つけたら、コールバックを呼び出して値を設定させるという形で処理しています。
続いてテーブルの行数の調整です。
var templateTable = worksheet.Tables[_tableName]; // テーブルの行数を確認し、不足分を追加する if (templateTable.Rows.Count < rows.Count) { var addCount = rows.Count - templateTable.Rows.Count; for (var i = 0; i < addCount; i++) { templateTable.Rows.Add(templateTable.Rows.Count - 1); } }
ワークシートから対象のテーブルを名称を指定して取得します。取得したテーブルの行数が、Buildメソッドを呼び出されたときに渡された行オブジェクトよりも少ない場合はテーブルに行を追加します。
ここで素晴らしいのは、行を追加するだけで前後の行とおなじExcel式が新しい行にも設定される点にあります。この挙動がExcelと同じであることは、ご存知の方も多いでしょう。そのため可変の表を取り扱いやすく、ページングなども特に細かな配慮の要なく実現できます。
続いてテーブルの1行目を解析して、値を設定すべき列の番号を調べます。
// テーブルの1行目から項目の列番号を探索する var rowSetters = new List<(int index, Action<IRange, TReportRow> setter)>(); var firstRow = templateTable.Rows[0]; for (var i = 0; i < firstRow.Range.Columns.Count; i++) { var value = firstRow.Range[0, i].Value; if (value != null && _tableSetters.ContainsKey(value)) { rowSetters.Add((i, _tableSetters[value])); } }
表項目のコールバックを登録した際に指定されたkeyに該当する列を探索し、その列のインデックスとコールバックをrowSetter変数に保持します。そしてテーブルに値を設定していきます。
// テーブルに値を設定する for (var i = 0; i < rows.Count; i++) { var row = templateTable.Rows[i]; foreach (var rowSetter in rowSetters) { range.DioDocsRange = row.Range[rowSetter.index]; rowSetter.setter(range, rows[i]); } }
あとは帳票を生成するだけです。
workbook.Save(stream, (GrapeCity.Documents.Excel.SaveFileFormat)saveFileFormat); }
ちょっとしたアイディアだけで、簡単に汎用的な帳票生成ライブラリを作ることができました。
さて、ライブラリの実装は以上ですが、今回の帳票を生成するために利用している、Excelのテンプレート側の設定についても解説しておきましょう。
Excelの式の活用
.NETのプログラムから明示的に値を設定する箇所は最小限にし、Excelの式で計算できる箇所は式を利用します。今回はNo.・金額・小計・消費税・合計・お支払期限(請求日から)はすべてExcelの式で計算します。
なお基本的にDioDocsが式を評価して計算結果を出力してくれますが、一点注意が必要です。
明細を表示する「表」の部分は、明細の件数によって増加します。その際、単純に行を追加して式が失われることや、小計を計算する範囲が含まれないなんてことが起きてはいけません。今回はテーブルを利用して、行の増減の操作を楽にできるようにしています。
もう一点見ていただきたいのは、金額列が空欄だった場合です。
金額列の式でIF文を利用し、単価列が未入力であった場合に「0」と表示されないように制御しています。ここにExcelの強みが見て取れるのではないでしょうか?
Excelの書式の活用
数値のカンマフォーマット、日付の書式、そういった部分の実装は簡単ではあっても手間がかかるものですし、テストも面倒です。そこで活躍するのがExcelの書式です。恐らく和暦の新元号対応も、Excelが正しく動作すれば楽に対応できるのではないでしょうか。
改ページ時のヘッダーの表示
さて、表の行数が可変の場合、悩ましいのが改ページ時のヘッダーの取り扱いです。下図のように2ページ目にまたがった場合、表のヘッダーは毎ページ表示したいというのは一般的な要求だと思います。私も作ってもらうならそう要求するでしょうが、作る側からすると面倒なことこの上ありません。
これもExcelのページ設定機能を利用して実現します。
これでExcel&DioDocsの利便性と生産性の高さを、さらに感じていただけたのではないでしょうか? 私の場合、これまでActiveReportsを利用してきたシーンの半分以上はDioDocsを使うことになると思います。