【第三回】経費申請のフローで学ぶCamundaの基本(BPMN実行編)

前回は、Camunda Modelerを用いて「経費申請」フローの簡単なBPMNモデリングをしてみました。第一回と第二回までの作業が終了したプロジェクトがGitHubにあがっているので、今回はここからスタートします。

今回は、前回作成したBPMNモデルをCamunda上で実行可能な状態にし、申請、承認、確認に必要なフォーム(html/JavaScript)とService Taskで実行するJavaコードを実装し、実際に使える業務アプリケーションとして動作させてみましょう。

まず先に業務要件をモデリングしてからアプリケーションの実装を行うということで、モデル駆動開発とも呼べる流れになっています。この方法論も楽しんでもらえればと思います。

前回作成した経費申請のBPMNモデル

f:id:itohiro73:20180115112911p:plain

BPMNを実行可能にする

まずは前回作成したBPMNモデルをCamunda上で実行可能にする必要があります。「経費申請」プールを選択して、右側に現れるメニューからExecutableという項目にチェックを入れれば実行可能になります。

また、Process Idにはわかりやすい名前をつけておきましょう。今回はExpenseApplication としました。

f:id:itohiro73:20180115185002p:plain

ちなみに、Camunda ModelerでつくるBPMNモデルは普通に考えてCamunda上で実行するんだから、デフォルトで実行可能にしてくれよ、っていうissueがあがっていて、どうやら次のバージョン(v1.12.0)にマージされているようです。リリースはよ。

フォームの作成

BPMNのワークフローを実行するにあたって、人間による作業や確認をするためのフォームが必要になってきます。

今回の経費申請フローでは、下記のようにそれぞれのイベント・ユーザータスクのBPMNコンポーネントに対応する、3つのフォームを作成します。

BPMNコンポーネント フォーム名 ファイル名
f:id:itohiro73:20180115165914p:plain 経費申請フォーム apply-form.html
f:id:itohiro73:20180115165959p:plain 経費承認フォーム approve-form.html
f:id:itohiro73:20180115174804p:plain 経費確認フォーム confirm-form.html
src/main/resources/static/forms/apply-form.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>経費申請フォーム</title>
</head>
<body>
<h2>経費申請フォーム</h2>
<form role="form" name="expenseForm"
      class="form-horizontal">

    <div class="form-group">
        <label class="control-label col-md-4"
               for="expenseReceiptUpload">領収書のアップロード</label>
        <div class="col-md-8">
            <input type="file"
                   id="expenseReceiptUpload"
                   cam-variable-name="expenseReceipt"
                   cam-variable-type="File"
                   cam-max-filesize="10000000"
                   class="form-control"/>
            <div class="help-block">経費の領収書の画像を選択してください</div>
        </div>
    </div>

    <script cam-script type="text/form-script">
            var fileUpload = $('#expenseReceiptUpload');
            var fileUploadHelpBlock = $('.help-block', fileUpload.parent());

            function flagFileUpload() {
              var hasFile = fileUpload.get(0).files.length > 0;
              fileUpload[hasFile ? 'removeClass' : 'addClass']('ng-invalid');
              fileUploadHelpBlock[hasFile ? 'removeClass' : 'addClass']('error');
              return hasFile;
            }

            fileUpload.on('change', function () {
              flagFileUpload();
            });

            camForm.on('submit', function(evt) {
              var hasFile = flagFileUpload();

              // prevent submit if user has not provided a file
              evt.submitPrevented = !hasFile;
            });

    </script>

    <div class="form-group">
        <label class="control-label col-md-4"
               for="detail">内容</label>
        <div class="col-md-8">
            <input cam-variable-name="detail"
                   cam-variable-type="String"
                   id="detail"
                   class="form-control"
                   type="text"
                   required />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-4"
               for="amount">金額(円)</label>
        <div class="col-md-8">
            <input cam-variable-name="amount"
                   cam-variable-type="Double"
                   id="amount"
                   name="amount"
                   class="form-control"
                   type="text"
                   required />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-4"
               for="expenseCategory">経費種別</label>
        <div class="col-md-8">
            <select cam-variable-name="expenseCategory"
                    cam-variable-type="String"
                    id="expenseCategory"
                    class="form-control">
                <option value="TRAVEL" label="旅費交通費"/>
                <option value="MEAL" label="接待交際費"/>
                <option value="EXPENDABLES" label="消耗品"/>
                <option value="OTHER" label="その他"/>
            </select>
        </div>
    </div>

</form>

</body>
src/main/resources/static/forms/approve-form.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>経費申請フォーム</title>
</head>
<body>
<h2>経費申請フォーム</h2>
<form role="form" name="expenseForm"
      class="form-horizontal">

    <p>経費情報を確認の上、承認してください</p>

    <div class="form-group">
        <label class="control-label col-md-2">経費領収書</label>
        <div class="form-control-static col-md-10">
            <input type="hidden" cam-variable-name="expenseReceipt"/>
            <img src="{{ camForm.variableManager.variable('expenseReceipt').contentUrl }}" class="img-responsive">
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">内容</label>
        <div class="col-md-10">
            <input cam-variable-name="detail"
                   type="text"
                   name="detail"
                   readonly="true"
                   class="form-control" />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">金額(円)</label>
        <div class="col-md-10">
            <input cam-variable-name="amount"
                   type="text"
                   name="amount"
                   readonly="true"
                   class="form-control" />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">経費種別</label>
        <div class="col-md-10">
            <select cam-variable-name="expenseCategory"
                    cam-variable-type="String"
                    name="expenseCategory"
                    disabled="true"
                    class="form-control">
                <option value="TRAVEL" label="旅費交通費"/>
                <option value="MEAL" label="接待交際費"/>
                <option value="EXPENDABLES" label="消耗品"/>
                <option value="OTHER" label="その他"/>
            </select>
        </div>
    </div>

    <div class="form-group">
        <div class="col-md-10 col-md-offset-2">
            <label>
                承認しますか?
                <select cam-variable-name="approved"
                        cam-variable-type="Boolean"
                        id="approved"
                        name="approved"
                        class="form-control">
                    <option value="true" label="承認"/>
                    <option value="false" label="却下"/>
                </select>
            </label>
        </div>
    </div>

</form>

</body>
src/main/resources/static/forms/confirm-form.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>経費申請フォーム</title>
</head>
<body>
<h2>経費申請フォーム</h2>
<form role="form" name="expenseForm"
      class="form-horizontal">

    <p>経費情報を確認の上、タスクをCompleteにしてください</p>

    <div class="form-group">
        <label class="control-label col-md-2">経費領収書</label>
        <div class="form-control-static col-md-10">
            <input type="hidden" cam-variable-name="expenseReceipt"/>
            <img src="{{ camForm.variableManager.variable('expenseReceipt').contentUrl }}" class="img-responsive">
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">内容</label>
        <div class="col-md-10">
            <input cam-variable-name="detail"
                   type="text"
                   name="detail"
                   readonly="true"
                   class="form-control" />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">金額(円)</label>
        <div class="col-md-10">
            <input cam-variable-name="amount"
                   type="text"
                   name="amount"
                   readonly="true"
                   class="form-control" />
        </div>
    </div>

    <div class="form-group">
        <label class="control-label col-md-2">経費種別</label>
        <div class="col-md-10">
            <select cam-variable-name="expenseCategory"
                    cam-variable-type="String"
                    name="expenseCategory"
                    disabled="true"
                    class="form-control">
                <option value="TRAVEL" label="旅費交通費"/>
                <option value="MEAL" label="接待交際費"/>
                <option value="EXPENDABLES" label="消耗品"/>
                <option value="OTHER" label="その他"/>
            </select>
        </div>
    </div>

    <div class="form-group">
        <div class="col-md-10 col-md-offset-2">
            <label>
                承認情報:
                <select cam-variable-name="approved"
                        cam-variable-type="Boolean"
                        id="approved"
                        name="approved"
                        disabled="true"
                        class="form-control">
                    <option value="true" label="承認"/>
                    <option value="false" label="却下"/>
                </select>
            </label>
        </div>
    </div>

</form>

</body>
BPMNコンポーネントとフォームの紐付け

これらのフォームをそれぞれのイベント・タスクと結びつけるために、BPMNコンポーネントを選択肢、右メニューのFormsタブにあるForm Keyという項目に、対応するフォームのファイルパスをembedded:app:に続けて入力します。

f:id:itohiro73:20180115174130p:plain

embedded:app:forms/apply-form.html
embedded:app:forms/approve-form.html
embedded:app:forms/confirm-form.html

以上でフォームに関する設定は完了です。

Exclusive Gatewayの分岐条件の設定

次に、「承認」タスクで承認者がタスクをCompleteした後に、ゲートウェイにおいて承認済みか否かの分岐判定が必要になります。

「承認済み?」のExclusive Gatewayから伸びている「いいえ」と「はい」のフローコンポーネントそれぞれに、「承認」タスクでフォームで入力されたapproved変数を用いた条件式を設定します。

「いいえ」の分岐

f:id:itohiro73:20180115182800p:plain

f:id:itohiro73:20180115183128p:plain

「はい」の分岐

f:id:itohiro73:20180115183236p:plain

f:id:itohiro73:20180115183322p:plain

Service Taskの実装

「経費記録」のサービスタスクでは、今回は単純に経費情報をログに出力してみましょう。

f:id:itohiro73:20180115165141p:plain

Service Taskは実装方法を選択する必要があるので、「経費記録」のService Taskの詳細に、今回はJava Classとして下記のように入力しておきます。

f:id:itohiro73:20180115164728p:plain

Javaの実装としては、経費種別のラベリングのためのenum Category.javaと、サービスタスク ExpenseRecorder.javaを以下のように作成します。

src/main/java/sample/Category.java
package sample;

public enum Category {
    TRAVEL {
        @Override
        public String toString() {
            return "旅費交通費";
        }
    },
    MEAL {
        @Override
        public String toString() {
            return "接待交際費";
        }
    },
    EXPENDABLES {
        @Override
        public String toString() {
            return "消耗品";
        }
    },
    OTHER {
        @Override
        public String toString() {
            return "その他";
        }
    }
}
src/main/java/sample/service/ExpenseRecorder.java
package sample.service;

import org.camunda.bpm.engine.delegate.DelegateExecution;
import org.camunda.bpm.engine.delegate.JavaDelegate;
import sample.Category;

import java.util.logging.Logger;

public class ExpenseRecorder implements JavaDelegate {
    private final Logger LOGGER = Logger.getLogger(ExpenseRecorder.class.getName());

    @Override
    public void execute(DelegateExecution execution) throws Exception {

        LOGGER.info("内容: " + execution.getVariable("detail"));
        LOGGER.info("金額(円): " + execution.getVariable("amount"));
        LOGGER.info("経費種別: " + Category.valueOf((String)execution.getVariable("expenseCategory")));
        LOGGER.info("承認済み?: " + (((Boolean)execution.getVariable("approved")).booleanValue() ? "はい" : "いいえ"));
    }
}

BPMNの配備とプロセス設定

作成したBPMNはsrc/main/resources直下に配備しておきましょう。

src/main/resources/expense_application.bpmn

次に、src/main/resources/META-INF/processes.xmlを作成し、経費申請BPMN(expense_application.bpmn)をデフォルトで読み込むように設定します。

<process-application
  xmlns="http://www.camunda.org/schema/1.0/ProcessApplication"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

  <process-archive>
    <process-engine>default</process-engine>
    <resource>expense_application.bpmn</resource>
    <properties>
      <property name="isDeleteUponUndeploy">false</property>
      <property name="isScanForProcessDefinitions">false</property>
    </properties>
  </process-archive>

</process-application>

ここまででBPMNはCamundaで自動的にロードされ実行可能な状態になりました。試しにSpring BootからCamundaを立ち上げてみましょう。

$ gradle bootRun

立ち上がったら http://localhost:8080/ にアクセスし、demo/demoでログインします。

f:id:itohiro73:20180116142223p:plain

Applications項目にあるTasklistをクリックします。

f:id:itohiro73:20180116142346p:plain

Tasklistが開き、現状ではリストは空の状態です。

f:id:itohiro73:20180116142650p:plain

さぁ、ここから経費申請フローを動かしてみましょう。

経費申請フローのデモ

今回は、承認者が承認する条件分岐で経費申請フローを試してみます。また、簡単のために申請者、承認者、経理の役割ははすべてdemoユーザーで実行します。

CamundaにデプロイされたBPMNのプロセスを起動するために、Tasklistの右上のメニューにあるStart Processをクリックします。

f:id:itohiro73:20180116142619p:plain

ポップアップで現れるメニューからExpenseApplicationをクリックすると、経費申請のプロセスが開始します。

f:id:itohiro73:20180116142859p:plain

この時点で「経費申請フォーム送信」イベントがトリガーされ、イベントに紐づけられたフォームが出現します。

f:id:itohiro73:20180116143715p:plain

適当な領収書の画像ファイルを選択し、必要な項目を入力し、Startボタンを押します。

f:id:itohiro73:20180116144114p:plain

ここで一旦Tasklistのページをリロードすると、承認タスクが現れます。

f:id:itohiro73:20180116145113p:plain

この承認タスクをクリックしてみると、今度は承認タスクに紐づけられたフォームが出現します。

f:id:itohiro73:20180116145031p:plain

demoユーザーにアサインするために、右上のClaimボタンを押すと、

f:id:itohiro73:20180116145307p:plain

承認タスクがdemoユーザー担当になります。

f:id:itohiro73:20180116145337p:plain

これで承認できるようになったので、ドロップボックスを「承認」のままCompleteボタンを押してみましょう。

f:id:itohiro73:20180116145624p:plain

すると、今度はTasklistに経理担当の確認タスクが出現します。

f:id:itohiro73:20180116150004p:plain

以上で経費申請のワークフローが経理の確認待ちという状態まで遷移しました。

(閑話休題)Cockpitでの実行中のプロセスの確認

デモの最中ですが、ここでいったんCockpitという機能を紹介したいと思います。

CamundaにはCockpitというダッシュボード機能がデフォルトで備わっており、デプロイされたBPMNや、現在実行中のプロセス、現在タスク完了待ちのユーザータスクなどが確認できます。

試しに上記のデモの途中の、経費を承認後(つまり、経理の確認待ちの状態)に、実行中のプロセスの状態をCockpitで確認してみましょう。

Cockpitにアクセスし、Running Process Instancesをクリックします。

http://localhost:8080/app/cockpit/default/#/dashboard

f:id:itohiro73:20180116150227p:plain

実行中のプロセスが表示されるので、ExpenseApplicationを選択します。

f:id:itohiro73:20180116151046p:plain

すると、下のように現在①プロセスが「確認」タスクのところにとどまっていることが可視化されます。

f:id:itohiro73:20180116152236p:plain

このように、Cockpitは実行中のプロセスをモニターするのに便利な機能を提供します。

確認タスクの実行により経費記録のサービスタスクをトリガー

さて、デモに戻ります。

上のCockpitでも確認できるように、最後に残るユーザータスクは経理の確認のみで、そのあとはサービスタスクである経費記録(ロギング)がトリガーされるはずです。

さっそく「確認」タスクを完了してみましょう。

Tasklistに戻り、「承認」タスクの場合と同様に「確認」タスクをClaimし、demoユーザーにアサインします。

f:id:itohiro73:20180116153128p:plain

f:id:itohiro73:20180116153156p:plain

今回のタスクは確認するのみなので、特に何も編集する必要なしにCompleteボタンを押します。

f:id:itohiro73:20180116153336p:plain

「確認」タスクをCompleteするとすぐに「経費記録」のサービスタスクが実行されるので、Spring Bootのログを確認してみましょう。

2018-01-16 15:41:35.131  INFO 16948 --- [io-8080-exec-10] sample.service.ExpenseRecorder           : 内容: Scala関西の交通費
2018-01-16 15:41:35.131  INFO 16948 --- [io-8080-exec-10] sample.service.ExpenseRecorder           : 金額(円): 28000.0
2018-01-16 15:41:35.140  INFO 16948 --- [io-8080-exec-10] sample.service.ExpenseRecorder           : 経費種別: 旅費交通費
2018-01-16 15:41:35.140  INFO 16948 --- [io-8080-exec-10] sample.service.ExpenseRecorder           : 承認済み?: はい

見事ログが出力されましたね!

今回は簡単なデモのためにログ出力としましたが、サービスタスクの実装はJavaのクラスで行われているので、外部のAPIを叩くもよし、データベースにストアするもよし、メッセージキューに送信するもよし、なんでもできます。

以上で今回の経費申請デモは完了です。今回の記事で実装したプロジェクトはこちらになります。

github.com

皆さんの手元でも、承認が「却下」になる分岐を実行してみたり、「却下」の場合の情報も出力するサービスタスクを追加して実行してみたり、遊んでみてください。

業務ワークフローをBPMNエンジンで実装するメリット

CamundaのようなBPMNワークフローエンジンを利用するメリットとして、私個人として一番大きいと思っているのは、ワークフローの「状態遷移」をバックエンドの実装と分離することができるところです。BPMNでモデル化できるような業務フローをバックエンドサービスとしてフロムスクラッチで実装しようとすると、どうしてもステートマシンのようなロジックを実装する必要がでてきます。手動で実装するステートマシンは結構楽しいんですが、業務要件に変更が加わった際に牙を向きます。ある状態から他の状態へ遷移する途中に新しい状態や分岐が必要となった場合、影響範囲の調査もなかなか厳しいですし、実装の変更工数はもとよりテストの工数もかさみます。さらにいうと本番環境でステートマシンから外れた例外的な状態遷移を実行する必要が出てきたときにニッチもサッチもいかなくなるという悲しみも抱えることになります(経験談)。

BPMNワークフローエンジンを用いて状態遷移をBPMNのモデルに集約するようにすると、状態遷移のロジックは分離され、バックエンドの実装は情報のストア(CRUD)やメッセージングに専念することができます。BPMNモデルそのものが業務要件を表現しているので、変更の影響範囲は可視化されます。新しい状態「遷移」を追加するにはBPMNのモデル側を変更すればよく、状態「情報」そのものはサービスタスクを用いてバックエンドにストアするような形になるでしょう。バックエンド側に遷移ロジックが入り込まないことで、変更に伴う状態の矛盾のようなバグが入り込む余地がなくなり、アジャイルで柔軟かつロバストなシステム開発を実現できるのではないかと考えています。

さらに、バックエンドの情報ストアに関しては、Reladomoのように履歴管理が容易なデータアクセスレイヤーを利用できると業務アプリケーションとしてはさらにいろいろ面白いことができるでしょう。この辺はいつか別のブログで書けると良いなと思います。

BPMNに変更を加える際のデプロイの方法論に関してはいろいろ検証が必要だと思いますが、デプロイ前にワークフローの中間状態のプロセスを排除(全て終了まで持っていくか、全てを始点に戻す等)するような施策が必要になってくるかなと思います。

第三回のまとめ

今回はBPMNでモデル化した経費申請フローを用いて、Camunda上で実行させるのに必要なフォーム実装とサービスタスク実装、BPMNの設定を行いました。第一回〜第三回までの準備・モデリング・実装プロセスだけで実際に動作する業務アプリケーションが完成しました。

いかがだったでしょうか。思いのほか簡単にモデリングできるし、アプリケーションの実装も非常にシンプルです(実装のほとんどはフォーム側で、バックエンドに相当するJavaのコードは今回はほとんど書いてませんw)。

ここまででCamundaの持つモデリング駆動の開発の可能性と、ワークフローエンジンとバックエンドストアの責務分離の強力さをなんとなく感じてもらえたら幸いです。

次回は、BPMN単独ではなかなかモデリングが難しい、意思決定のためのモデリング記法であるDMNを学んでみましょう。今回までに作成した経費申請ワークフローを拡張し、意思決定プロセスを導入してみます。お楽しみに!