帳票生成ライブラリの実現検証
それではDioDocsを使用し、帳票生成処理の実装を検討していきましょう。
DioDocsの特長を最大限生かすためには、やはりソースコードを変更せずにレイアウト変更が実現できるようにしたいところです。そのための実装例については以前の記事で紹介しました。
こちらで紹介した帳票生成ライブラリはGitHubとNuGetにも公開しています。
具体的には次のコードで簡単に帳票を生成することが可能です。
var reportBuilder = new ReportBuilder<InvoiceDetail>(template) // 単一項目のSetterを設定 .AddSetter("$SalesOrderId", cell => cell.Value = invoice.SalesOrderId) .AddSetter("$OrderDate", cell => cell.Value = invoice.OrderDate) .AddSetter("$CompanyName", cell => cell.Value = invoice.CompanyName) .AddSetter("$Name", cell => cell.Value = invoice.Name) .AddSetter("$Address", cell => cell.Value = invoice.Address) .AddSetter("$PostalCode", cell => cell.Value = invoice.PostalCode) // テーブルのセルに対するSetterを設定 .AddTableSetter("$ProductName", (cell, detail) => cell.Value = detail.ProductName) .AddTableSetter("$UnitPrice", (cell, detail) => cell.Value = detail.UnitPrice) .AddTableSetter("$OrderQuantity", (cell, detail) => cell.Value = detail.OrderQuantity); reportBuilder.Build(invoice.InvoiceDetails, outputStream, SaveFileFormat.Pdf);
この方法自体は我ながら良くできていると思うのですが、これは帳票生成するデータが.NETのクラスとして実装されていて、すでにオブジェクトが存在する前提となっています。ビジネスロジックと帳票生成が同じ場所で実行されるオンプレミスのサーバー上やクライアントサーバー型のWindowsアプリケーションなどでは最適な方法です。
しかしAzure Functions上では必ずしも最適とは言えません。主な理由として次の2つが挙げられます。
- 帳票生成データはJSONで送信されてくるが、それをいったん.NETのオブジェクトへデシリアライズしなくてはならない。
- 帳票生成データの構造が変更されたとき、Azure Functions上で実行されているコードを修正する必要がある。
性能的にはそれほど大きな差がある訳ではありませんが、特に後者は運用を想定すると大きな違いです。これらの問題はビジネスロジックと帳票生成が分離されており、間をJSONで受け渡していることに起因します。
ではどうするべきでしょうか?「JSONのままデータを処理すればよい」となるでしょう。
その際に課題になるのは以下2点です。
- JSONの値をExcelのどのセルに設定するか
- 値を設定するときに型はどうやって決定するか
Excelの書式情報を生かすには日付はDateTime、数字はdoubleで(Excelの数字は内部的にすべて浮動小数点で扱われています)といったように、適切に扱うべきでしょう。そのためにマッピングと型をどこかに定義する必要があります。
最適な場所がありますよね?
そうです。テンプレートとなるExcelの別シートに記述すればいいでしょう。下図をご覧ください。
Excelには2つのシートを定義します。
1枚目のInvoiceシートは実際の帳票のレイアウトを定義したテンプレートです。今回は1枚の帳票に1つずつ存在する単独項目が任意の数存在し、それとは別に1つの表が存在するという一般的な構造の帳票を想定します。$PostalCode
といった、いかにもプレースホルダのような記述がありますが、今回はプレースホルダは使用せず、動的に変更する場所がどこにあるのか見やすくするために利用しています。
2枚目のDioDocs.FastReportBuilderシートがJSONとExcelの節をマッピングするための設定ファイルです。
JSONは次のような構造を想定しています。
{ "SalesOrderId": "71936", "OrderDate": "2008-06-01T00:00:00", "CompanyName": "Metropolitan Bicycle Supply", "Name": "Krishna Sunkammurali", "PostalCode": "W1N 9FA", "InvoiceDetails": [ { "OrderQuantity": "3", "UnitPrice": "12", "ProductName": "Chain" }, { "OrderQuantity": "4", "UnitPrice": "63", "ProductName": "Front Brakes" }, ...
Excelのシートには以下の列を定義します。
Name
JSONの項目名です。
Type
項目の型。stringやDateTime、doubleといった帳票的にプリミティブな型と、表を表すtable型が存在します。table型以降の行(8、9、10、11行目)が表の列を表すものとして扱います。
Cell
値を設定するセルです。固定位置の単一項目で利用します。
ColumnIndex
値を設定する列インデックス。表に対して複数行設定するテーブル項目の列を指定するために利用します。
実行効率を最大化するには、これらのメタデータもJSONなどで記述してBLOBに入れておくのがいいのでしょうが、テンプレートとメタデータが同じ場所で定義できることは運用上非常に大きなメリットです。実際の実行速度はそう変わりませんし、今回はこの方式で進めます。
さてこれらを利用して、JSONとExcelテンプレートからPDF帳票を生成することを考えましょう。
帳票中の決まったセルに設定する単一項目が複数と、表形式でExcel上でテーブルとして扱うテーブル項目という形式であれば、これらを利用することでマッピングとPDF生成のロジックは個別に記載する必要はなくなるはずです。
実際に次のクラス図のように設計実装しました。JSONの利用にはJson.NETを利用する前提です。
それぞれ次の役割を持ちます。
IJsonReportBuilder
JsonReportBuilderのインターフェース。帳票生成はテストの難しい部分です。このため帳票生成している箇所と利用している箇所は、分離してテストしたいケースが多いため、インターフェースと実装を分離します。
JsonReportBuilder
帳票生成の全体を制御するクラス。設定の読み込み、帳票データを設定値に基づきテンプレートに適用する処理、値を適用したPDFを生成する処理が含まれます。個別のセルにJSON文字列を型変換して設定する処理はRangeAccesorとTableRangeAccesorに移譲します。
RangeAccessor
単一項目へのアクセスを実装します。利用するJSONの属性名、値を設定するセルの名称を持ち、JTokenAccessorで変換された値をWorksheetへ設定します。
TableRangeAccessor
RangeAccesorのテーブル項目版です。
JTokenAccessor
JSONへアクセスし、文字列から値への変換を請け負います。
帳票生成の全体の制御構造はJsonReportBuilderに持ち、各セルへの値の設定はそれぞれRangeAccesorとTableRangeAccesorが請け負います。その際、JSON文字列をそれぞれのセルの型に変換するのはJTokenAccessorが請け負います。
具体的な実装を見ていきましょう。なお、コードの全貌は以下のリポジトリで公開しています。ぜひご覧ください。
まずはJsonReportBuilderから見ていきましょう。
public class JsonReportBuilder : IJsonReportBuilder { private readonly byte[] _template; private bool _isInitialized; private readonly List<RangeAccessor> _accessors = new List<RangeAccessor>(); private string _tableName; private readonly List<TableRangeAccessor> _tableAccessors = new List<TableRangeAccessor>(); public JsonReportBuilder(Stream template) { _template = new byte[template.Length]; template.Read(_template, 0, _template.Length); }
コンストラクタでExcelファイルを読み込んで_template
にバイト列で保持しています。実際に処理する際にはWorkbookに変換して利用しますが、Workbookオブジェクトを再利用した場合、帳票生成を複数回実行したときに前の帳票のデータが残ってしまうリスクがあります。そのため、コンストラクタでWorkbookオブジェクトを生成してキャッシュすることはせず、ベースとなるバイト列を毎回開く実装にします。
JsonReportBuilderにはJSON値を設定するRangeAccessor
とTableRangeAccessor
、表データを設定するExcelのテーブル名を所持しますが、コンストラクタでは設定せず、初回の帳票生成時に設定値を取得します。これはコンストラクタでWorkbookを開いて設定値を取得した場合、帳票生成時に再びWorkbookを開く必要ができてしまうためです。
実際に帳票生成するメソッドを見てみましょう。
public void Build(TextReader input, Stream output, SaveFileFormat saveFileFormat) { using (var memoryStream = new MemoryStream(_template)) using (var reader = new JsonTextReader(input)) { IWorkbook workbook = new Workbook(); workbook.Open(memoryStream); var settingWorksheet = workbook.Worksheets["DioDocs.FastReportBuilder"]; if (settingWorksheet == null) throw new InvalidOperationException("Setting Worksheet(DioDocs.FastReportBuilder) is not exist."); if (!_isInitialized) ParseSettings(settingWorksheet); settingWorksheet.Delete(); var worksheet = workbook.Worksheets[0]; var json = JToken.ReadFrom(reader); foreach (var rangeAccessor in _accessors) { rangeAccessor.Set(worksheet, json); } if (_tableName != null) SetTable(worksheet, json); workbook.Save(output, (GrapeCity.Documents.Excel.SaveFileFormat)saveFileFormat); } }
大きく次のような流れになっています。
- ワークブックを開く。
- 初期化されていない状態であれば、設定値を読み込む。
- 設定ワークシートを削除する。
- 単項目にすべて値を設定する。
- 設定すべきテーブルがあるのであればテーブルに値を設定する。
- ワークブックをPDFに保存する。
設定値を保管するシート名はDioDocs.FastReportBuilderで固定としていますが、こちらは実行時に指定できるようにしてもいいでしょう。設定値を保管するシートを削除していますが、これはPDF化した際にそのページがPDFに含まれないようにするための対応です。それ以外は特に説明は不要でしょう。
続いてParseSettingsメソッドの中を見てみましょう。
private void ParseSettings(IWorksheet settingWorksheet) { var usedRange = settingWorksheet.UsedRange; for (var i = 1; i < usedRange.Rows.Count; i++) { var name = usedRange[i, 0].Value.ToString(); var type = usedRange[i, 1].Value.ToString(); if (_tableName is null) { if (type == "table") { _tableName = name; } else { _accessors.Add(new RangeAccessor(name, type, usedRange[i, 2].Value.ToString())); } } else { _tableAccessors.Add(new TableRangeAccessor(name, type, int.Parse(usedRange[i, 3].Value.ToString()))); } } _isInitialized = true; }
最初にWorksheetのUseRangeを利用して値が入っている範囲のセルを取得しています。利用されている範囲だけをパースします。対象範囲から列ヘッダーが入ってる行を飛ばして順次処理していきます。
設定シートには先頭から単項目が設定されていて、途中でtype
にtable
が出現したらそれ以降はテーブルへの設定とみなして処理しています。単項目はRangeAccessor
、テーブル項目はTableRangeAccesor
を設定します。
ではRangeAccessorの実装を見てみましょう。
internal class RangeAccessor { private readonly string _name; private readonly string _cell; private readonly JTokenAccessor _accessor; internal RangeAccessor(string name, string type, string cell) { _name = name; _accessor = JTokenAccessor.GetConverter(type); _cell = cell; } ...
RangeAccessorは単項目の値の設定に利用します。そのため以下のフィールドを保持しています。
_name
JSON上の該当項目の属性名です。
_cell
値を設定するセルの名称(A1やB2など)です。
_accessor
JSON(実際にはJson.NETのJTokenオブジェクト)から値を取得し変換するアクセサーオブジェクトです。
これらの属性を利用して、JSONの値をワークシートに設定しているのが以下のコードです。
internal void Set(IWorksheet worksheet, JToken jObject) { worksheet.Range[_cell].Value = _accessor.Get(jObject[_name]); }
名称_name
の属性を取得して_accessor
のGetメソッドに渡し、値を取得しつつ文字列から該当セルの型(stringやDateTime、doubleなど)に変更したものを、_cell
のセルに値を設定しています。
以下のコードはRangeAccesorを利用しているJsonReportBuilderのBuildメソッドのコードです。
foreach (var rangeAccessor in _accessors) { rangeAccessor.Set(worksheet, json); }
設定シートに記載されていた単項目すべてをループ処理しながら、JSONから値を取得して設定しています。表に対する処理もセル名から列番号に変化していますがおおむね同じ処理です。
ただ一点注意が必要です。Excel上の表は自動的に拡張してくれないので、値を設定する前に行数が多い場合は行を追加する必要があります。
var table = worksheet.Tables[_tableName]; var rows = json[_tableName]; if (table.Rows.Count < rows.Count()) { var addCount = rows.Count() - table.Rows.Count; for (var i = 0; i < addCount; i++) { table.Rows.Add(table.Rows.Count - 1); } }
これでJSONとExcelからPDF帳票を生成するための実装が完了しました。