OMNeT++による離散シミュレーション～RESOURCEブロック編

#1.はじめに
　[前回記事](https://qiita.com/tsugulin/items/021bbc65094baaa56ee1)では教科書の「SLAMⅡによるシステム・シミュレーション入門」から例題2.1「２つの機械工程を持つ生産ライン」に取り組み，主にQUEUEノードによる待ち行列の扱い方を学びましたが，本稿では例題2.3「故障を伴う機械システムのシミュレーション」にて優先度のあるリソースの取扱いを学びたいと考えます．なお例題2.2「到着時点の状況により作業時間が変わるシステム」は実行結果が教科書に未掲載のためパスします．
　本稿のテーマはリソースの管理です．リソースの管理をResourceブロックにて行い，操作をAwait，Preempt及びReleaseの各ノードにて行います．メッセージはAwaitノードに到着するとResourceブロックにリソースを要求します．このときリソースを獲得できれば次の工程に進み，確保できなければ待ち行列に入ります．PreemptノードはAwaitノードと基本機能は同じですが優先度が高いので，もしAwaitノードがリソースを使用していても割込みしてリソースを確保することが可能です．もし割込みが発生するとリソースを奪われたメッセージは待ち行列の先頭にてリソースの解放を待ちます．メッセージはReleaseノードに到着すると獲得したリソースを解放します．

#2.例題の概要
　例題2.3の概要を図に示します．ネットワークは生産Networkと故障Networkの２つから構成されます．生産Networkの最初のenter1ノードは平均1.0時間の指数分布に従う間隔にてメッセージを生成します．次のawaitノードは当該メッセージのためにresourceブロックからTOOLリソースを確保し，もし確保できなければ待ち行列に保管します．awaitノードからgoonノード間のアクティビティはセットアップを表し，0.2～0.5時間の一様分布に従う時間を要します．goonノードは単にセットアップ・アクティビティと処理アクティビティを接続するだけのノードです．goonノードからrelease1ノードの間の処理アクティビティには平均0.5時間，標準偏差0.1時間の正規分布に従う時間を要します．release1ノードは当該メッセージが確保したTOOLリソースを解放しますが，もしawaitノードに処理待ちメッセージがあればセットアップ開始を要求します．最後のleaveノードは製造リードタイム等を集計してメッセージを削除します．
　いっぽう故障Networkのenter2ノードはシミュレーション開始後20時間目に故障メッセージを生成します．次のpreemptノードは強制的にresourceブロックのTOOLリソースを占有します．もしawaitノードがリソースを利用中の場合は横取りし，セットアップ中や処理中のメッセージを待ち行列の先頭に退避します．preemptノードとrelease2ノード間のアクティビティは故障の修理を表します．修理には３段階の工程があり各々平均0.75時間の指数分布に従う時間を要します（したがって修理時間は位相３のアーラン分布になります）．修理完了後にrelease2ノードはTOOLリソースを解放し，中断していたセットアップや処理の再開を要求します．その後も平均20時間，標準偏差0.2時間の正規分布に従う間隔にて故障が再発します．
　なおSLAMネットワークではResourceブロックに対するアクティビティの線はないのですが，OMNet++ではメソッド呼出のために必要なのでawaitやpreemptやreleaseから線を伸ばしています  
![03A2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507413/ed052cff-aa81-f9a2-1ea0-40c30f947b64.png)

#3.ソースコードの説明
　メニューから「File」～「New」～「OMNeT++ Project」を選択して適当なプロジェクト名を付与して下さい（今回はSimSlam2.3としています）．プロジェクトを構成するソースファイルは表の通りですが，GitHub[（https://github.com/tsugulin/SimSlam2.3）](https://github.com/tsugulin/SimSlam2.3)から入手可能です．git cloneにてローカルにダウンロードし，Windowsのファイル・エクスプローラーからIDEのプロジェクト・エクスプローラーにドラッグ＆ドロップすることにより取り込み可能です．  

|フォルダ名|ファイル名|説明|
|---|---|---|
|simulations|omnetpp.ini|シミュレーションのパラメータ変数の定義|
|simulations|Simulation01.ned|ノードとモジュールの関連付け（＝ネットワーク）を定義|
|src|Create.ned|メッセージを生成するモジュールの宣言|
|src|Terminate.ned|メッセージを削除するモジュールの宣言|
|src|Await.ned|リソースを要求する待ち行列モジュールの宣言|
|src|Goon.ned|アクティビティを連結するモジュールの宣言|
|src|Release.ned|リソースを解放するモジュールの宣言|
|src|Preempt.ned|リソースを割込み使用するモジュールの宣言|
|src|Resource.ned|リソース使用を管理するモジュールの宣言|
|src|Create.cc|Createモジュールの定義|
|src|Create.h|Createモジュールのクラス宣言|
|src|Terminate.cc|Terminateモジュールの定義|
|src|Terminate.h|Terminateモジュールのクラス宣言|
|src|Await.cc|Awaitモジュールの定義|
|src|Await.h|Awaitモジュールのクラス宣言|
|src|Goon.cc|Goonモジュールの定義|
|src|Goon.h|Goonモジュールのクラス宣言|
|src|Release.cc|Releaseモジュールの定義|
|src|Release.h|Releaseモジュールのクラス宣言|
|src|Preempt.cc|Preemptモジュールの定義|
|src|Preempt.h|Preemptモジュールのクラス宣言|
|src|Wavg.cc|待ち行列長さの加重平均を計算する「その他」クラス|
|src|Wavg.h|Wavgクラスのヘッダー|

　例題2.3のネットワークはSimulation01.nedの内容の通りです．  

```Simulation01.ned
network Simulation01
{
    submodules: 
        enter1: Create { 
            parameters: 
            	@display("p=50,100;i=block/source");
        } 
        await: Await { 
            parameters: 
            	@display("p=200,100;i=block/circle;q=queue");
        } 
        goon: Goon { 
            parameters: 
            	@display("p=350,100;i=block/process");
        } 
        release1: Release { 
            parameters: 
            	@display("p=500,100;i=block/square");
        } 
        leave: Terminate { 
            parameters:
            	@display("p=650,100;i=block/sink");
        } 
        enter2: Create {
            parameters: 
            	@display("p=50,200;i=block/source");
        } 
        preempt: Preempt { 
            parameters: 
            	@display("p=200,200;i=block/star");
        } 
        release2: Release {
            parameters: 
            	@display("p=500,200;i=block/square");
        } 
        resource: Resource { 
            parameters: 
            	@display("p=350,300;i=block/table");
        } 
    connections:
        enter1.out --> await.in; 
        await.out --> goon.in;
        goon.out --> release1.in;
        release1.out --> leave.in; 
        enter2.out --> preempt.in; 
        preempt.out --> release2.in;
        release2.out --> preempt.in_loop;
        await.res --> resource.from_setup; 
        goon.res --> resource.from_process; 
        release1.res --> resource.from_release1;
        preempt.res --> resource.from_preempt;
        release2.res --> resource.from_release2;
}
```

　各モジュールが使用する変数と入口及び出口は以下の通りです．  

|宣言ファイル名|変数|入口|出口|主な役割|
|---|---|---|---|---|
|Create.ned|initialTimeおよびintervalTime|なし|out|メッセージを作成する|
|Await.ned|productionTime|in|out, res|リソースを確保できるまで待つ|
|Goon.ned|productionTime|in|out, res|アクティビティを連結する|
|Release.ned|intervalTime|in|out, res|リソースを解放する|
|Preempt.ned|productionTime|in, in_loop|out, res|リソースを割込み使用する|
|Termindate.ned|なし|in|なし|メッセージを削除する|
|Resouce.ned|なし|from_setup, from_process, from_release1, from_preempt, from_release2|なし|リソース使用を管理する|

　主要なモジュールであるAwait，Release，Preempt及びResouceの各モジュールについて説明します．

　Awaitモジュールの主な機能はリソースの確保です．前工程からのメッセージを受け取るとresouceブロックにリソースを獲得を要求します．首尾良く獲得に成功した場合はセットアップを行って後工程にメッセージを送ります．いっぽうリソースを獲得できない場合は待ち行列にメッセージを保管してリソースの解放を待ちます．  
　各メソッドの役割ですが，initializeメソッドでは変数初期化やresouceブロックのポインタ取得を行います．handleMessageメソッドでは発信元が前工程てあればセットアップを開始し，発信元が自分自身であれば後工程にメッセージを送ります．startSetupメソッドでは待ち行列からメッセージを取り出して自身にメッセージを送ります．suspendSetupメソッドではresouceブロックからの要求によりセットアップを中断して残り時間を記録します．resumeSetupメソッドではresouceブロックの要求によセットアップを再開して残りの処理を行います．pushメソッドではGoonモジュールの要求により待ち行列の先頭にメッセージをセットします．popメソッドではGoonモジュールの要求により待ち行列の先頭からメッセージを取り出します．finishメソッドでは待ち行列の平均長さやアクティビティの使用状況等の統計情報を表示します 

```Await.cc
// 前工程からのメッセージを受取り，resouceブロックのリソースを獲得してセットアップ時間（omenetpp.iniのproductionTimeにて指定）経過後に後工程にメッセージを送る
// もしリソースを獲得できない場合は待ち行列にメッセージを保管してリソースの解放を待つ
// Resouceモジュールからの要求によりセットアップを中断/再開を行う
// Goonモジュールからの要求により待ち行列へのメッセージ保管/取り出しを行う

#include "Await.h"
Define_Module(Await);
#include "Resource.h"

void Await::initialize()
{
    // 変数の初期化
    onSetup = false;
    onInterruption = false;
    remainingTime = 0;
    dummy = new cMessage("dummy");

    // キューの初期化
    queue.setName("queue");     // GUIに待ち行列長さを表示するための名前

    // 待ち行列の平均長さを計算するクラスを初期化
    queuelen.init(simTime(), MAX_QUEUE);

    // リソースのポインタを取得
    cModule *mod = gate("res")->getNextGate()->getOwnerModule();
    rsc = check_and_cast<Resource *>(mod);
}

void Await::handleMessage(cMessage *msg)
{
    if ( msg->isSelfMessage() ) {
        // 自身のメッセージであれば後工程に送る
        proctime.collect(simTime() - msg->getTimestamp());      // 統計情報に保管
        send(msg, "out");                                       // 後工程に送る
        onSetup = false;                                        // セットアップ終了
    }
    else {
        // 前工程から届いたメッセージの場合
        queuelen.set(simTime(), queue.getLength());     // 待ち行列平均長さを計算
        msg->setTimestamp(simTime());       // キュー滞留開始時間をセット
        queue.insert(msg);                  // 待ち行列の最後尾にめメッセージを追加
        startSetup();       // 待ち行列に溜まっているメッセージを処理する
    }
}

// 在庫があればセットアップを開始
void Await::startSetup(void)
{
    Enter_Method("start");
    if ( !onSetup ) {
        if (queue.getLength() > 0) {
            cMessage *msg = check_and_cast<cMessage *>(queue.front());      // 待ち行列の先頭メッセージを取り出す
            if ( rsc->request(msg->getName(), msg->getId()) ) {             // TOOLリソースを要求
                waittime.collect(simTime() - msg->getTimestamp());          // キュー滞留時間の統計処理
                queuelen.set(simTime(), queue.getLength());                 // 待ち行列平均長さを計算
                queue.pop();                                                // 待ち行列からメッセージを取り出す
                msg->setTimestamp(simTime());                               // リードタイムの開始時間をセット
                curMsg = msg;                                               // 中断処理のため現メッセージを保管
                onSetup = true;                                             // セットアップON
                expectedTime = simTime() + par("productionTime");           // 予定時刻
                scheduleAt(expectedTime, msg);                              // セットアップ開始
            }
        }
    }
}

// resouceブロックの要求によりセットアップを中断．残り時間を記録する
void Await::suspendSetup()
{
    Enter_Method("suspend");
    if ( onSetup ) {
        // セットアップ中の場合
        onSetup = false;                            // セットアップ停止
        onInterruption = true;                      // 割り込み中
        cancelEvent(curMsg);                        // セットアップをキャンセル
        queuelen.set(simTime(), queue.getLength()); // 待ち行列平均長さを計算
        if (queue.getLength() > 0) queue.insertBefore(queue.front(), dummy); else queue.insert(dummy);// 待ち行列の先頭にメッセージを戻す
        suspendedTime = simTime();                  // 中断開始時刻
        remainingTime = expectedTime - simTime();   // 残り時間
    }
}

// resouceブロックの要求によりセットアップを再開．残りの処理を行う
void Await::resumeSetup()
{
    Enter_Method("resume");
    if ( onInterruption ) {
        // 割り込み中の場合
        onInterruption = false;                         // 割り込みを解除
        onSetup = true;                                 // セットアップ再開フラグ
        queuelen.set(simTime(), queue.getLength());     // 待ち行列平均長さを計算
        queue.pop();                                    // 待ち行列からメッセージを取り出す
        expectedTime = simTime() + remainingTime;       // 予定時刻
        curMsg->setTimestamp(curMsg->getTimestamp() + simTime() - suspendedTime);  // 開始時間を補正
        scheduleAt(expectedTime, curMsg);               // セットアップ再開
    }
}

//　Goonモジュールの要求によりキューの先頭にメッセージをセット
void Await::push()
{
    Enter_Method("push");
    if ( !onSetup ) {
        // セットアップ中で無ければ
        queuelen.set(simTime(), queue.getLength());     // 待ち行列平均長さを計算
        if (queue.getLength() > 0) queue.insertBefore(queue.front(), dummy); else queue.insert(dummy);// 待ち行列の先頭にメッセージを戻す
    }
}

//　Goonモジュールの要求によりキューからメッセージを戻す
void Await::pop()
{
    Enter_Method("pop");
    if ( !onSetup ) {
        // セットアップ中で無ければ
        if (queue.getLength() > 0) {
            queuelen.set(simTime(), queue.getLength());     // 待ち行列平均長さを計算
            queue.pop();                                    // 待ち行列からメッセージを取り出す
        }
    }
}

//　ノードの統計情報を表示
void Await::finish()
{
    EV << "Await Jobs Count:   " << waittime.getCount() << endl;
    EV << "Await Queue AVG Length: " << queuelen.get(simTime()) << endl;
    EV << "Await Min WaitTime: " << waittime.getMin() << endl;
    EV << "Await Avg WaitTime: " << waittime.getMean() << endl;
    EV << "Await Max WaitTime: " << waittime.getMax() << endl;
    EV << "Standard deviation: " << waittime.getStddev() << endl;
    EV << "Setup Utilization:  " << proctime.getSum() / simTime() << endl;
    EV << "Setup Min ProcTime: " << proctime.getMin() << endl;
    EV << "Setup Avg ProcTime: " << proctime.getMean() << endl;
    EV << "Setup Max ProcTime: " << proctime.getMax() << endl;
    EV << "Standard deviation: " << proctime.getStddev() << endl;

    waittime.recordAs("Await WaitTime");
    proctime.recordAs("Setup ProcTime");
}
```

　Releaseモジュールの主な機能はリソースの解放です．前工程からメッセージを受け取るとリソースを解放し，指定時間の経過後に後工程にメッセージを送ります． 

```Release.cc
// 前工程からメッセージを受け取り，リソースを解放して指定時間（omenetpp.iniのintervalTimeにて指定）経過後に後工程に送る

#include "Release.h"
Define_Module(Release);
#include "Resource.h"

void Release::initialize()
{
    // リソースのポインタを取得
    cModule *mod = gate("res")->getNextGate()->getOwnerModule();
    rsc = check_and_cast<Resource *>(mod);
}

void Release::handleMessage(cMessage *msg)
{
    if ( msg->isSelfMessage() ) {
        // 指定時間を経過した後
        proctime.collect(simTime() - msg->getTimestamp());      // 統計情報に保管
        send(msg, "out");                                       // 後工程に送る
    }
    else {
        // 前工程から届いたメッセージの場合
        msg->setTimestamp(simTime());                       // リードタイムの開始時間をセット
        rsc->release( msg->getName(), msg->getId() );       // リソースの開放
        scheduleAt(simTime() + par("intervalTime"), msg);   // 終了時刻にSelfMessageを送る
    }
}

//　ノードの統計情報を表示
void Release::finish()
{
    EV << "Release Min ProcTime: " << proctime.getMin() << endl;
    EV << "Release Avg ProcTime: " << proctime.getMean() << endl;
    EV << "Release Max ProcTime: " << proctime.getMax() << endl;
    EV << "Standard deviation:   " << proctime.getStddev() << endl;

    proctime.recordAs("Release ProcTime");
}
```

　Preemptモジュールの主な機能はAwait同様にリソースの確保です．ただしリソースを優先的に使用出来ますので待つ必要はありません．前工程からメッセージを受け取ると強制的にリソースを獲得して指定時間の経過後に後工程にメッセージを送ります．  

```Preempt.cc
// 前工程からメッセージを受け取るとリソースを獲得し，指定時間の経過後に後工程にメッセージを送る

#include "Preempt.h"
Define_Module(Preempt);
#include "Resource.h"

void Preempt::initialize()
{
    // リソースのポインタを取得
    cModule *mod = gate("res")->getNextGate()->getOwnerModule();
    rsc = check_and_cast<Resource *>(mod);
}

void Preempt::handleMessage(cMessage *msg)
{
    if ( msg->isSelfMessage() ) {
        // セルフメッセージ=生産完了時
        simtime_t_cref d = simTime() - msg->getTimestamp();     // 処理時間
        proctime.collect(d);                                    // 統計情報に保管
        send(msg, "out");                                       // 後工程に送る
    }
    else {
        // 前工程から届いたメッセージの場合
        if ( rsc->request(msg->getName(), msg->getId()) ) {         // TOOLリソースを要求
            msg->setTimestamp(simTime());                           // リードタイムの開始時間をセット
            scheduleAt(simTime() + par("productionTime"), msg);     // 生産終了後にSelfMessageを送る
        }
    }
}

//　ノードの統計情報を表示
void Preempt::finish()
{
    EV << "Preempt Jobs Count:   " << proctime.getCount() << endl;
    EV << "Preempt Utilization:  " << proctime.getSum() / simTime() << endl;
    EV << "Preempt Min ProcTime: " << proctime.getMin() << endl;
    EV << "Preempt Avg ProcTime: " << proctime.getMean() << endl;
    EV << "Preempt Max ProcTime: " << proctime.getMax() << endl;
    EV << "Standard deviation:   " << proctime.getStddev() << endl;

    proctime.recordAs("Prermpt ProcTime");
}
```

　Resourceモジュールの主な機能はリソースの確保と解放の制御です．各メソッドの役割ですが，initializeメソッドでは変数初期化とSetupモジュールとGoonモジュールのポインタ取得を行います．requestメソッドではリソース獲得の要求に対する処理を行い，成功すればTrueを返します．もし割り込みが発生した場合にはAwaitとGoonのアクティビティを中断するよう要求します．releaseメソッドではリソース解放の要求に対する処理を行い，成功すればTrueを返します．このとき状況に応じてAwaiやGoonに次のセットアップ開始やアクティビティ再開を要求します．  

```Resouce.cc
// リソースの確保と解放を制御する

#include "Resource.h"
Define_Module(Resource);
#include "Await.h"
#include "Goon.h"
#include "Release.h"

void Resource::initialize()
{
    // 変数を初期化
    owner = 0;              // リソースの所有者をクリア
    interruption = 0;
    ownerType = "product";

    // AwaitノードとGoonノードポインタを取得
    cModule *mod1 = gate("from_setup")->getPreviousGate()->getOwnerModule();
    setupNode = check_and_cast<Await *>(mod1);
    cModule *mod2 = gate("from_process")->getPreviousGate()->getOwnerModule();
    processNode = check_and_cast<Goon *>(mod2);
}

// リソースの獲得を行う
// リソースが未使用ならば所有者として要求者種類(productまたはaccident)とメッセージIDを登録してtrueを戻す
// リソースが使用中かつ所有者種別がproductかつ要求者種別がaccidentの場合にはリソースを横取りし，SetupノードとGoonノードに処理中断を要求する
// リソースを獲得できなかった場合はfalseを戻す

bool Resource::request(std::string name, long id)
{
    Enter_Method("request");
    bool ret_code = false;  // 戻り値を初期化
    if ( owner == 0 ) {
        // リソースが利用可能な場合
        owner = id;         // もしリソースが未使用であれば要求者が利用可能とする
        ownerType = name;   // productまたはaccident
        checkpoint = simTime();     // リソース確保時刻を記録
        ret_code = true;    // リソース獲得に成功
    }
    else {
        // リソースが使用中の場合
        if ( ownerType == "product" ) {
            if ( name == "accident" ) {
                // 割り込みでリソースを獲得する場合
                interruption = owner;   // 前オーナを保存
                owner = id;             // リソースを横取り
                ownerType = name;       // accident
                ret_code = true;        // リソース割り込みに成功
                setupNode->suspendSetup();          // セットアップを中断する
                processNode->suspendProduction();   // 生産を中断する
            }
        }
    }
    return ret_code;    // リソース獲得できればtrueを戻す
}

// リソースの解放を行う
// 要求者のメッセージIDが所有者と同じだが割込中でない場合は所有者の情報をクリアした後，Setupノードに次のセットアップを要求してtrueを戻す
// 要求者のメッセージIDが所有者と同じだが割込中の場合はリソースの所有者情報を元に戻してSetupノードとGoonノードに処理再開を要求した後にtrueを戻す
// リソースを解放できなかった場合はfalseを戻す

bool Resource::release(std::string name, long id)
{
    Enter_Method("release");
    bool ret_code = false;      // 戻り値を初期化
    if ( owner == id ) {
        // 所有者が解放する場合
        ret_code = true;            // リソース解放に成功
        if ( interruption == 0 ) {
            //　割り込み中でない場合
            owner = 0;                  // リソースを解放
            ownerType = "";
            proctime.collect(simTime() - checkpoint);     // 経過時間を記録
            setupNode->startSetup();    // 次のセットアップを開始する
        }
        else {
            // 割り込み中の場合
            owner = interruption;       // 前オーナーに戻す
            ownerType = "product";
            interruption = 0;
            setupNode->resumeSetup();           // 割込元のセットアップを再開する
            processNode->resumeProduction();    // 割込元の生産を再開する
        }
    }
    return ret_code;    // リソース解放できればtrueを戻す
}

//　ノードの統計情報を表示
void Resource::finish()
{
    EV << "Resouce Utilization: " << proctime.getSum() / simTime() << endl;
}
```

#4.設定ファイル（omnetpp.ini）の準備
　omnetpp.iniの内容は以下の通りです．preemptモジュールの平均値は平均0.75秒が３工程なので3=2.25をセットしています．教科書では0.75を設定していますが，シミュレーション結果を見ると故障ActivityのUtilizationが教科書と同様の値になっていますので設定はこれで正しいと考えます．おそらくSLAMとOMNeT++は関数の引数定義が異なるのでしょう．

```omnetpp.ini
[General]
network = Simulation01
record-eventlog = true

sim-time-limit = 500s
cpu-time-limit = 500s
total-stack = 7MiB  # increase if necessary
cmdenv-express-mode = true
cmdenv-event-banners = true
cmdenv-performance-display = false

[Config Run1]
*.enter1.datatype = "product"
*.enter1.initialTime = 0s
*.enter1.intervalTime = exponential(1.0s)
*.await.productionTime = uniform(0.2s, 0.5s)
*.goon.productionTime = normal(0.5s, 0.1s)
*.release1.intervalTime = 0s
*.enter2.datatype = "accident"
*.enter2.initialTime = 20s
*.enter2.intervalTime = 99999s
*.preempt.productionTime = erlang_k(3, 2.25s)
*.release2.intervalTime = normal(20.0s, 2.0s) 
```

#5.結果の確認
　実行結果は以下の通りです．実行環境が異なるため全く同じ結果にはなりませんが，待ち行列長さやアクティビティの使用状況を見ると同様のシミュレーション結果を得られたと考えます．

|項目|今回の結果|教科書|
|---|---|---|
|生産Networkにて処理した数|516|527|
|故障Networkにて処理した数|22|22|
|平均リードタイム|9.78612|11.82|
|平均待ち行列長さ|9.22306|13.0047|
|セットアップActivityの使用状況|36.0802%|37.02%|
|処理Activityの使用状況|51.5411%|51.99%|
|故障Activityの使用状況|9.3673%|9.79%|

#6.参考資料
SLAMⅡによるシステム・シミュレーション入門（初版発行:1993年，著者:森戸晋，中野一夫，相沢りえ子，発行者:構造計画研究所発行，発売所:共立出版）

#7.改訂履歴
|改定日|改定内容|
|---|---|
|2021.2.14|Resourceブロックに対する線について少し詳しく説明|
|2021.2.13|タイトルを変更．「1.前回までの記事」を「1.はじめに」に変更．全体に表現を修正|
|2021.2.5|例題の図のleave2の吹出しの矢印の向きを修正|
|2021.2.4|初期登録|