システムの構成として、GUIやWebの画面の裏側でバッチ処理を用意することがあります。バッチプログラムを起動すると、表の入力から貯められたデータを処理し、結果を表から見れるように保存します。このように聞くと、プログラミングを始めの頃の演習課題のように見えるので、「あぁ、 main
から始めれば良いんでしょ」という感じで突撃したくなるかもしれません。あるいは、WebアプリケーションのMVCのような基本方針が見当たらないので手が止まってしまう方もいるでしょう。そのような方向けに、今回は真面目にバッチ処理を作るための基本形を提案したいと思います。
なぜバッチ処理を作るのか
例えば、人気記事の月次ランキング機能は、アクセスごとに集計するのは非効率です。このような場合は、毎月決まったタイミングでバッチ処理を起動し、上位10件のリストを作成することによって高速なレスポンスを実現します。
メールを一斉送信する場合は、Webの画面で受け付けて送信後にレスポンスを返す実装にすると、リクエストしたユーザを長い時間待たせてしまいます。このような場合は、リクエストによってバッチ処理を開始し、ユーザにレスポンスを返します。裏側のバッチ処理でメールを送信すれば、ユーザを待たせずにレスポンスを返すことができます。
読み出す際の処理をできる限り抑えるために、事前に表示するデータを用意したり、時間のかかる処理を裏側で実行するために、バッチ処理を書く必要が出てきます。
バッチ処理の抽象化
設計を考えるにあたって、バッチ処理の流れを抽象化して考えてみましょう。バッチ処理の大まかな流れは以下のようになります。
- 起動
- パラメータの解釈
- データの読み込み
- データの処理
- 処理結果の書き出し
- 成功・失敗の通知
バッチ処理の起動
例えば、月次ランキングの例では、 スケジューラ を用いるべきです。スケジューラの代表例はWindowsのタスクスケジューラやLinuxの crontab
です。決まった時間にプログラムを実行することができます。
メールを一斉送信する例の場合は、RabbitMQのような Message Queue が便利です。Message Queueの使い方は、 チュートリアル の図がイメージしやすいでしょう。Work queuesで複数に分散してメールを一斉送信することができます。
バッチの起動タイミングに応じて、適切なツールを選択するようにしましょう。
パラメータの解釈
Webアプリケーションがリクエストのパラメータを受け付けるように、main
から始まるアプリケーションも起動時のパラメータを受け付けます。このようなパラメータを プログラム引数 と呼びます。プログラム引数をイメージできない場合は、一度 CUI でコンピュータを操作してみましょう。読み込むデータや出力先の指定などに利用します。
Javaであれば、main
メソッドの引数の String
配列から受け取ることができます。単純な解析なら自分で書いても構いませんが、多くの言語ではライブラリが用意されています。
読み込み・処理・書き出し
目的のデータを読み込む前に、多くの場合は設定を読み込みます。起動ごとに変わらないような内容は、設定ファイルから読み取ると良いでしょう。読み込んだ設定をログレベルに応じて出力したりすると、設定した人は安心します。
設定に応じて読み込み・処理・書き出しを行うオブジェクトを生成することができます。読み込みや書き出し先を多態で変えることで、柔軟なバッチプログラムを作成することができます。
成功・失敗の通知
処理が成功したか失敗したかという情報は重要です。
コマンドラインから起動するプログラムの場合は、 終了コード で表現します。 0 が成功、それ以外が失敗(C言語のBoolとは逆)で、Javaなら System.exit(0)
のようにします。終了コードを正しく返すことで、終了後に他のプログラムを呼び出すことができるようになります。連携可能なプログラムとなるように、終了コードは適切に返すようにしましょう。
Message Queueの多くは、成功時にメッセージを通知することで1度だけ成功するようにさせることができます。このメッセージをACK (acknowledgment)と呼びます。ACKが送られるずに終了した場合は、処理を再実行させることができるようになります。
大量のデータへの対処
バッチは大量のデータをまとめて処理します。データの量が異なるため、ユーザとのインタラクティブな処理とは違う傾向の課題が出てきます。良いアルゴリズムを選択するで片付く問題ではありますが、バッチ処理特有の課題やテクニックについて考えてみます。
読み捨て
データを1件読み取った後にそのデータを処理し、次以降そのデータが必要無い場合は、そのデータをメモリから破棄するようにします。データベースから読み取ったデータを List
や Map
に保存せずに、 1 件ずつ読み込みと処理を交互に繰り返します。このようにすることで、処理に必要なメモリ量を抑えることができるようになります。
全件と1件では極端ですが、10件ごとに読み捨てたり、パイプライン状に並列度を高める方法も考えられます。最初は1件ごとの読み捨てで作って、サービスの成長に合わせて将来並列性を高められるように設計すると良いでしょう。
中間データの保存
処理の途中で失敗した場合に、最初からやりなおす必要は無いかもしれません。中間データを保存することで、やりなおしのリスクを小さくすることができます。
そのプログラムが停止しているのか、処理に時間がかかっているのかは、簡単には判別できません。途中経過の表示は、ユーザや運用者のストレスを軽減するのに有効です。処理を分割し、どこまで終わったかを書き出して、外部から取得できるようになっている方が良いです。
並列処理
単純な場合は、スレッドの立ち上げにかかる時間によってかってかえって遅くなりますが、大規模なデータに対しては並列化することで処理を短時間で終わらせられるようになるかもしれません。
データ別の並列化
データごとに独立して同じ処理を適用する場合は、それらの処理を同時に行うことで高速化できる可能性があります。並列化させて動かす先にデータを配るコストとのバランス次第では、同時に動く分だけ速くなります。
並列リダクション
可換で結合則を満す操作は、 リダクション と呼ばれる半分にしていく方法が適用できます。例えば a1+a2+a3+a4
の合計を求める場合は、単純にループするよりも、 a1+a2
と a3+a4
を同時に行って (a1+a2)+(a3+a4)
を計算する方が2ステップで計算できます。入力が多く、出力が少ない場合は、リダクションが適用できないか考えてみましょう。
まとめ
- 事前にバッチ処理でデータを準備したり、一旦開始の成功のみを返答することで、応答速度を改善できます
- プログラム引数やファイルによる設定を活用しましょう
- 多態による入れ替えで、読み込み・本体の処理・書き込みの変更が容易になります
- 成功・失敗を呼び適切に通知することで、他のプログラムと連携しやすくなります
- (時間・空間)計算量、並列性、中間状態を意識することで、大量のデータに対処できます