0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Boot × Jxls で拡張性の高い帳票出力基盤を構築する(Strategyパターンの活用)

0
Posted at

はじめに

Spring Bootを使ったWeb型業務システムにおいて、必要となってくる機能に帳票出力機能があります。
帳票出力と一口にいっても、PDFを出力する場合もあれば、エクセルファイルをダウンロードの形で出力する場合もあります。
今回は、エクセルファイルによる帳票出力において、開発効率、拡張性、メンテナンス性を考慮した帳票出力基盤の実装例を示します。

帳票出力によくある問題(Before)

業務システムでは、業務内容に合わせて色々な種類の帳票が必要とされます。同時に、運用後も新しい帳票の追加が必要になることも、ごく普通にあるケースです。

業務システムでは、多数の帳票があるという問題

業務システムでは、複数の種類の帳票を必要とします。また、運用後に新しい帳票の追加が必要になることは一般的です。
このため、複数の帳票出力を実装するためには、以下に示すような実装方法をとっている場合が多いのではないでしょうか?

  • 帳票ごとに、コントローラの入り口から、実際に帳票ファイルを生成するロジックまで個別に実装しているケース
  • 帳票の種類単位で、コントローラーの入り口をまとめて、途中で if/elseswitch を使ってディスパッチしているケース

これらの方法を使った場合には、どうしてもソースコードの肥大化や、条件分岐の増加といったメンテナンス性の低下を避けることができません。

よくある帳票(エクセル形式)の実装

エクセル形式の帳票を作成するためには、大きく二段階の処理が必要になります。

  1. 帳票に表示するためのデータを準備する
    • データベースから帳票に表示するためのデータを抽出/集計する
  2. 準備したデータを使ってエクセルファイルを生成する

ステップ1に関しては、データベースから必要なデータを取得するという処理ですので、特に問題はないかと思います。
問題は、ステップ2の実装方法にあります。

とにかく頑張った系

エクセルのファイルを作成して編集するために、Apache POIを使って必死に頑張っている場合があるかと思います。
しかしながら、この方法は以下の理由で推奨できる方法ではありません。

  • Apache POIは低レベルのAPIのため、大量のコーディングが必要
    • 一つ一つのセルに値を直接書き込む必要がある
  • ソースコードの可読性が悪くなる
  • 帳票のレイアウト変更が行われた場合の修正が困難

テンプレートを使った系

Jxlsライブラリを使って、エクセルで作ったテンプレートファイルにデータを流し込むことで、エクセルの帳票を簡単に作成する場合もあるかと思います。
この方法は、Apache POIを使って頑張るよりも効率的であり、かつ、テンプレートを使うことで帳票のレイアウトと流し込むデータが分離しているため、非常に有効なエクセル形式の帳票出力の実装方法です。つまり、コード内でセル一つ一つに値をセットする苦行から解放され、エクセルテンプレート側でレイアウトを完結することができます。
この方法は、テンプレートの定義方法に関する学習コストが若干かかりますが、慣れると簡単にテンプレートファイルを作成することができるようになります。

問題を解決する設計方針(After)

前述のような帳票出力方法による問題を解決するために、以下のような設計方針を取ります。

  • Strategyパターンの導入: 各帳票のデータ抽出ロジックを Bean として独立させる
  • メタデータのDB管理: 「帳票名」「テンプレートパス」「実行する Bean 名」をDBで紐づける
  • 動的ディスパッチ: ApplicationContext を使って、DBの値に基づいて実行時に Bean を決定する仕組みを構築

この設計方針をとることで、帳票名を指定するだけで、自動的に最適な帳票が出力されるという仕組みが出来上がります。
この場合の処理フローは以下のようになります。

では、具体的な実装例を見ていきましょう。

具体的実装例

基本的な設計方針は前述のとおりですが、実際に実装するにあたっては、いくつかの改善を行いました。

  • 基底クラスの導入: Bean の定義に当たって、単に共通のインターフェースを作るのではなく、基底クラスを導入して、Jxlsを使った処理を実装
  • パラメータ(JSON文字列)の導入: 実際に帳票を出力する場合には、データの抽出条件等を設定するケースが多く、かつ、条件項目は帳票ごとに異なるため、JSON文字列 とすることで汎用性を持たせる
  • PreWriteActionの利用: Jxlsライブラリを使うことで、帳票として必要な表本体はテンプレートを使うことで出力できるが、その表のデータを使ったグラフの描画等はできないため、PreWriteActionを定義することで出力前に処理を追加できるように実装

基底クラス(AbstractReportProcessor.java)

各帳票用の Bean クラスでは、この基底クラスを使うことで、データ抽出とPreWriteAction等の定義を行うだけになるため、開発コスト、メンテナンスコストを大幅に下げることができます。

AbstractReportProcessor.java
@Slf4j
public abstract class AbstractReportProcessor<T> {

  /** 帳票出力用のメソッド */
  public byte[] generateReport(String templatePath, String jsonParam) {
    var mapper = new ObjectMapper();
    try {
      if (jsonParam == null) {
        jsonParam = "{}";
      }
      // 1. JSON文字列のパラメータをオブジェクト化
      T param = mapper.readValue(jsonParam, getParameterClass());

      // 2. Jxlsに渡すためのMapデータを取得
      var data = getData(param);
      log.debug("Generating report with data: {}", data); // データの構造をログに出力

      // 3. テンプレートファイルを入力ストリームとして開く
      try (var is =
          getClass().getClassLoader().getResourceAsStream("template/report/" + templatePath)) {
        if (is == null) {
          throw new IllegalArgumentException("Template not found :" + templatePath);
        }

        // 4. Jxlsの出力ストリームを開く
        try (var os = new ByteArrayOutputStream()) {
          var jxlsOutputStream =
              new JxlsOutput() {
                @Override
                public OutputStream getOutputStream() throws IOException {
                  return os;
                }
              };

          // 5. Jxlsのテンプレートエンジンの設定を実行
          JxlsPoiTemplateFillerBuilder.newInstance()
              .withTemplate(is)
              .withTransformerFactory(new PoiTransformerFactory())
              .withLogger(new JxlsSlf4jLogger())
              .withPreWriteAction(
                  ((transformer, publicContext) -> {
                    for (PreWriteAction action : getPreWriteActions(param)) {
                      action.preWrite(transformer, publicContext);
                    }
                  }))
              .build()
              .fill(data, jxlsOutputStream);
          // 6. エクセルファイルをバイト配列として返す
          return os.toByteArray();
        }
      }
    } catch (Exception e) {
      log.error("Error generating report", e);
      throw new RuntimeException("Error generating report", e);
    }
  }

  protected abstract Class<T> getParameterClass();

  public abstract Map<String, Object> getData(T params);

  protected abstract List<PreWriteAction> getPreWriteActions(T params);
}

管理用データベース定義の例(ReportRecords)

帳票管理用のデータベースの例は以下のようになります。

-- 帳票定義マスタテーブル
CREATE TABLE report_master (
    report_id       INT AUTO_INCREMENT PRIMARY KEY COMMENT '帳票ID',
    report_code     VARCHAR(50) NOT NULL UNIQUE COMMENT '帳票コード(リクエストで使用)',
    report_name     VARCHAR(100) NOT NULL COMMENT '帳票名(表示用)',
    bean_name       VARCHAR(100) NOT NULL COMMENT '実行するSpring Bean名',
    template_path   VARCHAR(255) NOT NULL COMMENT 'Jxlsテンプレートファイルのパス',
    description     VARCHAR(255) COMMENT '備考',
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '作成日',
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日'
);

-- インデックス付与(検索速度向上のため)
CREATE INDEX idx_report_code ON report_master(report_code);

登録するデータのサンプルは以下のようになります。

INSERT INTO report_master (report_code, report_name, bean_name, template_path, description) 
VALUES 
('SALES_REPORT', '月次売上報告書', 'SampleReportProcessor', 'sales_report.xlsx', '部署別の月次売上を集計して出力する'),
('USER_LIST', 'ユーザー一覧帳票', 'UserListProcessor', 'user_list.xlsx', '全ユーザーの基本情報をリスト出力する'),
('INVENTORY_REPORT', '在庫管理レポート', 'InventoryProcessor', 'inventory.xlsx', '倉庫別の在庫数を出力し、グラフを付与する');

本テーブル定義に関しては、以下のような設計意図をもって定義しています。

  • report_codebean_nameの分離: 「リクエストで受け取るコード(外部向け)」と「JavaのBean名(内部実装)」を分けて管理することで、将来的にクラス名を変更してもDBの定義を変えるだけで済み、API側のインターフェース(URLやパラメータ)を変更せずに対応可能
  • template_path の外だし: 「テンプレートの保存場所をDBで管理しているため、ファイル名が変わった場合や、将来的にS3などの外部ストレージから取得するように変更する場合も柔軟に対応可能
  • 運用のメリット: 「新しい帳票を追加する際は、Processor クラスを作成し、このテーブルに1レコード追加するだけで完了するため、既存コードの修正が一切不要

帳票出力サービスの実装例(ReportService.java)

本サービスクラスでは、以下の点を考慮して実装してあります。

  • DBに指定する reportCode がない場合: 該当するレポート情報が無い場合(nullチェック)の実施と例外のスロー
  • DBに登録されていたbean_nameが誤っている場合: getBean(beanName)try/catch で囲むことで例外の補足を確実に実施

また、 ApplicationContext から getBean(beanName) を使って、Beanを取得、かつ、AbstractReportProcessor<?> でキャストすることで、型安全にパラメータを渡すことができるようにしています。

ReportService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class ReportService {
  private final ApplicationContext applicationContext;
  private final ReportMasterRepository reportMasterRepository;

  public byte[] getReport(ReportRequest reportRequest) {
    // 1. DBから帳票定義を検索
    var report = reportMasterRepository.getReportByReportCode(reportRequest.reportCode());
    if (report == null) {
      // DBに登録されていない帳票名が指定された場合には例外を発生させる
      throw new IllegalArgumentException("Report not found :" + reportRequest.reportCode());
    }
    try {
      // 2. DBに登録されたBean名を基に、Springコンテナから実装クラスを取得 (Strategyパターン)
      var processor = (AbstractReportProcessor<?>) applicationContext.getBean(report.getBeanName());

      // 3. データ抽出とExcel生成を実行
      return processor.generateReport(report.getTemplatePath(), reportRequest.jsonParam());

    } catch (Exception e) {
      log.error("帳票出力エラー: {}", e.getMessage(), e);
      throw new RuntimeException(e);
    }
  }
}

帳票出力Beanの実装例 (SampleReportProcessor.java)

以下に、簡単なレポート出力用のBeanのサンプルを示します。
実際の帳票出力の実装では、以下のサンプルのように getData(Request params)の実装と、テンプレートファイルの作成、および、データベースへのレコード追加のみになります。

SampleReportProcessor.java
@Component("SampleReportProcessor") // ここで定義するbean名をDBに登録する
@AllArgsConstructor
@Slf4j
public class SampleReportProcessor extends AbstractReportProcessor<Request> {

  @Override
  protected Class<Request> getParameterClass() {
    return Request.class;
  }

  @Override
  public Map<String, Object> getData(Request params) {
    // 帳票に必要なデータを、map<String, Object>の形で詰め込む(Jxlsのテンプレート変数名と一致させる)
    // 本クラスで定義している `Request` クラスに帳票出力用のパラメータが渡ってきます
    
    var map = new HashMap<String, Object>();

    // サンプルなので適当に・・・
    // 本来はデータベースからデータを取得してJxlsに流せるように設定する
    var recordList = new ArrayList<Record>();
    for ( int i = 1 ; i <= params.getCount(); i++ ) {
        var record = new Record();
        record.setCategoryCode(String.format("%04d", i));
        record.setCategoryName(String.format("カテゴリ %04d", i));
        record.setCount(i * 100L);
        recordList.add(record);
    }
    
    map.put("records", recordList);
    return map;
  }

  @Override
  protected List<PreWriteAction> getPreWriteActions(Request params, boolean withChart) {
    // テンプレートの処理が終わってからファイル出力までの間に実行する処理のリストを定義する
    // ここで使っている CircleChartAction は、円グラフを描画する処理が定義されているクラスの例

    var list = new ArrayList<PreWriteAction>();
    list.add(new CircleChartAction());
    return list;
  }

  @NoArgsConstructor
  @Getter
  @Setter
  public static class Request {
    private Integer count;
  }
  
  // Jxlsのテンプレートに渡すデータ構造の定義
  @NoArgsConstructor
  @Getter
  @Setter
  public static class Record {
    private String categoryCode;
    private String categoryName;
    private Long count;
  }
}

運用のポイントとメリット

テンプレート管理の柔軟性

今回の実装例では、テンプレートファイルをリソースファイルとして保持する形にしています。
運用時には、この方法でも問題はあまり起きませんが、開発時にはテンプレートファイルのデバッグ作業に手間がかかる(毎回ビルドしなおす必要がある)ため、ファイルシステムから読み込むような工夫をすると生産性があがります。
また、テンプレートファイルをリソースファイルとしてではなく S3 等の外部ストレージに配置する場合には、データベースのテンプレートパスの修正と、AbstractReportProcessor でのテンプレート取得の処理を修正するだけで対応可能になっています。

導入後の効果

  • 実装の簡素化と実装コスト減: 「データの抽出」と「エクセルへの書き出し(Jxls)」が完全に分離され、開発者が集中すべきは getData() メソッド内の処理となり、実装コストの削減が可能です
  • 保守性の向上: 新しい帳票の追加が「クラス作成 + DB登録」だけで完結するため、メンテナンスコストの削減が可能です
  • リスク軽減: メインの出力ロジック(基底クラス)を触る必要がないため、他の帳票に影響を与えるバグの発生リスクを極限まで減らすことができます(設計上、発生しないはず)

まとめ

Jxlsに関する学習コストは若干かかりますが、本実装例を使うことで、開発性、保守性に優れた形での業務システムにおけるエクセル形式での帳票出力基盤を構築することができます。特に大規模な業務システムで、今後も帳票数が増え続けると予想されるプロジェクトでは、非常に有効なアプローチではないかと考えています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?