TypeScriptでデシジョンテーブルを実装する

## 1. はじめに

今回はTypeScriptによるデシジョンテーブルの実装について説明したいと思います。以前にJava版で同様の処理を実装しましたが、所要によりTypeScript版が必要になったことからTypeScriptにマイグレーションしました。Java版やクラス構成等の詳細を確認したい方は「[条件分岐を使わずにデシジョンテーブルを実現する](https://qiita.com/5zm/items/19435701ebf9314710d0#4-%E9%A7%90%E8%BB%8A%E5%A0%B4%E6%96%99%E9%87%91%E3%81%AE%E5%89%B2%E5%BC%95%E8%A8%88%E7%AE%97%E3%83%87%E3%82%B7%E3%82%B8%E3%83%A7%E3%83%B3%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%81%AE%E5%AE%9F%E8%A3%85%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E7%89%88)」を参照ねがいます。

デシジョンテーブルの基本的な構成について以下に示します。
なお、このデシジョンテーブルについては「[デシジョンテーブルの解説](https://softest.jp/?p=72)」のサイトから引用させて頂きました。

| 条件記述部（condition stub）| 条件指定部（condition entry）|
|:-----:|:-----:|
| ![photo_2.png](https://qiita-image-store.s3.amazonaws.com/0/195748/b60d0186-6589-759d-e24b-c1ea25c0d0c6.png) | ![photo_4.png](https://qiita-image-store.s3.amazonaws.com/0/195748/94afe09e-8aa2-35d9-c5f9-2d4e6cf328cb.png) |
| **動作記述部（action stub）** | **動作指定部（action entry）** |
| ![photo_3.png](https://qiita-image-store.s3.amazonaws.com/0/195748/d5d2bb0a-b0b2-be6f-89e5-d941b53539e6.png) | ![photo_5.png](https://qiita-image-store.s3.amazonaws.com/0/195748/d5191825-5057-4186-9320-d72f6dcbcbbe.png) |

## 2. 汎用的な部分

デシジョンテーブルを実現する汎用的な部分です。クラス構成は以下の図の通りです。実際に利用する際は共通的なライブラリにするとよいかと思います。

![class-structure.jpg](https://qiita-image-store.s3.amazonaws.com/0/195748/824b17ac-d753-48ec-cfb3-62bfffca93b9.jpeg)

```typescript
import { createHash } from 'crypto';

interface DecisionAction {}
interface RuleInput {}
interface Rule<I extends RuleInput> {
    getRuleText(): string;
    getInput(input: RuleInput): I;
    evaluate(input: RuleInput): any;
}

class ConditionStub {
    _rules = new Map<Rule<any>, RuleInput>();
    when(rule: Rule<any>, input: RuleInput): void { this._rules.set(rule, input); }
    getRules(): Map<Rule<any>, RuleInput> { return this._rules; }
}

class ConditionEntry<A extends DecisionAction> {
    _conditionMap = new Set<string>();
    _action: A;
    when(ruleText: string, entry: any) {
        this._conditionMap.add(ruleText + "/" + entry);
    }
    then(action: A): void {
        this._action = action;
        console.debug("key : " + this.getId());
        console.debug(this);
    }
    getId(): string {
        let item:string = "";
        this._conditionMap.forEach((E) => {
            item = item + '\t' + E;
        })
        return createHash('sha256').update(item).digest('hex');
    }
    getAction(): A { return this._action; }
}

abstract class DecisionTable<A extends DecisionAction> {
    _decisionTable: Map<string, ConditionEntry<A>>;
    constructor() { this._decisionTable = this.initDecisionTable(); }
    protected abstract initDecisionTable(): Map<string, ConditionEntry<A>>;

    resolve(conditionStub: ConditionStub): A | undefined {
        // execute rule
        const result = new ConditionEntry<A>();
        conditionStub.getRules().forEach((I, R) => {
            result.when(R.getRuleText(), R.evaluate(I));
        });
        // search action
        const key = result.getId();
        console.debug("resolve: key=" + key)
        return this._decisionTable.get(key)?.getAction();
    }
}
```

参考までにJava版との実装上の違いについて記載します。
* Javaのインターフェイスにはデフォルト実装がありますが、TypeScriptでのデフォルト実装方法が分かりませんでした。そのため`Rule`インターフェースの`getInput`メソッドを実装クラスに実装する手間が増えています。
* Javaでは`Class`の`getSimpleName()`メソッドでクラス名というメタ情報を利用していますが、もちろんTypeScriptにはそのようなものはありません。「3000円以上10000円未満」のようにルールを示す文字列を利用するように変更しました。
* Javaには全てクラスで`hashCode()`メソッドが利用できますが、TypeScriptにはそのようなものはありません。素直に`crypto`モジュールを利用することにしました。

## 3. 要件に応じたデシジョンテーブルの実装

要件のデシジョンテーブルを実現するため、`RuleInput`、`Rule`、`DecisionAction`、`ConditionEntry`、`DecisionTable`およびそれらを利用してデシジョンテーブルを解決するServiceクラスを実装します。Serviceクラスは昨今のFWはコンストラクタインジェクションが主流のため、それに即した形にしています。

```typescript
class PriceRuleInput implements RuleInput {
    private _paymentPrice: Number;
    constructor(paymentPrice: Number) { this._paymentPrice = paymentPrice; }
    get paymentPrice(): Number {
        return this._paymentPrice;
    }
}

class Rule1 implements Rule<PriceRuleInput> {
    getRuleText(): string { return "3000円以上10000円未満" };
    getInput(input: RuleInput): PriceRuleInput { return input as PriceRuleInput; }
    evaluate(input: RuleInput) {
        const priceRuleInput = this.getInput(input);
        const paymentPrice = priceRuleInput.paymentPrice;
        if (paymentPrice.valueOf() >= 3000 && paymentPrice.valueOf() < 10000) {
            return true;
        }
        return false;
    }
}

class Rule2 implements Rule<PriceRuleInput> {
    getRuleText(): string { return "10000円以上30000円未満"; }
    getInput(input: RuleInput): PriceRuleInput { return input as PriceRuleInput; }
    evaluate(input: RuleInput) {
        const priceRuleInput = this.getInput(input);
        const paymentPrice = priceRuleInput.paymentPrice;
        if (paymentPrice.valueOf() >= 10000 && paymentPrice.valueOf() < 30000) {
            return true;
        }
        return false;
    }
}

class Rule3 implements Rule<PriceRuleInput> {
    getRuleText(): string { return "30000円以上"; }
    getInput(input: RuleInput): PriceRuleInput { return input as PriceRuleInput; }
    evaluate(input: RuleInput) {
        const priceRuleInput = this.getInput(input);
        const paymentPrice = priceRuleInput.paymentPrice;
        if (paymentPrice.valueOf() >= 30000) {
            return true;
        }
        return false;
    }
}

class CinemaRuleInput implements RuleInput {
    private _watchCinema: boolean;
    constructor(watchCinema: boolean) { this._watchCinema = watchCinema; }
    get watchCinema(): boolean {
        return this._watchCinema;
    }
}

class Rule4 implements Rule<CinemaRuleInput> {
    getRuleText(): string { return "シネマ利用" };
    getInput(input: RuleInput): CinemaRuleInput { return input as CinemaRuleInput; }
    evaluate(input: RuleInput) {
        const cinemaRuleInput = this.getInput(input);
        return cinemaRuleInput.watchCinema;
    }
}

class ParkingDiscountAction implements DecisionAction {
    private _discount30Minute: boolean;
    private _discount1Hour: boolean;
    private _discount2Hour30Minute: boolean;
    private _discount3Hour30Minute: boolean;

    constructor(discount30Minute: boolean, discount1Hour: boolean,
        discount2Hour30Minute: boolean, discount3Hour30Minute: boolean) {
            this._discount30Minute = discount30Minute;
            this._discount1Hour = discount1Hour;
            this._discount2Hour30Minute = discount2Hour30Minute;
            this._discount3Hour30Minute = discount3Hour30Minute;
    }
}

const pattern01 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", false);
    pattern.then(new ParkingDiscountAction(true, false, false, false));
    return [pattern.getId(), pattern];
}

const pattern02 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", true);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", false);
    pattern.then(new ParkingDiscountAction(false, true, false, false));
    return [pattern.getId(), pattern];
}

const pattern03 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", true);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", false);
    pattern.then(new ParkingDiscountAction(false, false, true, false));
    return [pattern.getId(), pattern];
}

const pattern04 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", true);
    pattern.when("シネマ利用", false);
    pattern.then(new ParkingDiscountAction(false, false, false, true));
    return [pattern.getId(), pattern];
}

const pattern05 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", true);
    pattern.then(new ParkingDiscountAction(false, false, true, false));
    return [pattern.getId(), pattern];
}

const pattern06 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", true);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", true);
    pattern.then(new ParkingDiscountAction(false, false, true, false));
    return [pattern.getId(), pattern];
}

const pattern07 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", true);
    pattern.when("30000円以上", false);
    pattern.when("シネマ利用", true);
    pattern.then(new ParkingDiscountAction(false, false, true, false));
    return [pattern.getId(), pattern];
}

const pattern08 = (): [string, ConditionEntry<ParkingDiscountAction>] => {
    const pattern = new ConditionEntry<ParkingDiscountAction>();
    pattern.when("3000円以上10000円未満", false);
    pattern.when("10000円以上30000円未満", false);
    pattern.when("30000円以上", true);
    pattern.when("シネマ利用", true);
    pattern.then(new ParkingDiscountAction(false, false, false, true));
    return [pattern.getId(), pattern]; 
}

class ParkingDiscountDecisionTable extends DecisionTable<ParkingDiscountAction> {
    protected initDecisionTable(): Map<string, ConditionEntry<ParkingDiscountAction>> {
        const tables = new Map<string, ConditionEntry<ParkingDiscountAction>>();
        tables.set(...pattern01()); // #1
        tables.set(...pattern02()); // #2
        tables.set(...pattern03()); // #3
        tables.set(...pattern04()); // #4
        tables.set(...pattern05()); // #5
        tables.set(...pattern06()); // #6
        tables.set(...pattern07()); // #7
        tables.set(...pattern08()); // #8 
        return tables;
    }
}

class ParkingDiscountService {
    _rule1: Rule1;
    _rule2: Rule2;
    _rule3: Rule3;
    _rule4: Rule4;
    _decisionTable: ParkingDiscountDecisionTable;

    constructor(rule1: Rule1, rule2: Rule2, rule3: Rule3,
        rule4: Rule4, decisionTable: ParkingDiscountDecisionTable) {
        this._rule1 = rule1;
        this._rule2 = rule2
        this._rule3 = rule3;
        this._rule4 = rule4;
        this._decisionTable = decisionTable;
    }

    public business(paymentPrice: Number, watchCinema: boolean) {
        // create input data
        const priceRuleInput = new PriceRuleInput(paymentPrice);
        const cinemaRuleInput = new CinemaRuleInput(watchCinema);
        // create conditionStub
        const conditionStub = new ConditionStub();
        conditionStub.when(this._rule1, priceRuleInput);
        conditionStub.when(this._rule2, priceRuleInput);
        conditionStub.when(this._rule3, priceRuleInput);
        conditionStub.when(this._rule4, cinemaRuleInput);
        // resolve by decisionTable
        const parkingDiscountAction = this._decisionTable.resolve(conditionStub);
        console.log("paymentPrice : " + paymentPrice + ", watchCinema : " + watchCinema);
        console.log(parkingDiscountAction);
    }
}
```

## 4. デシジョンテーブルの利用

Serviceクラスのインスタンスを生成した後、デシジョンテーブルの入力になる`RuleInput`に設定する値を渡してあげればデシジョンテーブルの結果(`DecisionAction`)が返ってきます。

```typescript
const rule1 = new Rule1();
const rule2 = new Rule2();
const rule3 = new Rule3();
const rule4 = new Rule4();
const decisionTable = new ParkingDiscountDecisionTable;
const service = new ParkingDiscountService(rule1, rule2, rule3, rule4, decisionTable);

service.business(2000, false); // # 1
service.business(5000, false); // # 2
service.business(17000, false); // # 3
service.business(45000, false); // # 4
service.business(100, true); // # 5
service.business(7000, true); // # 6
service.business(20000, true); // # 7
service.business(100000, true); // # 8
```

実行結果を以下に示します。先頭の方は`ConditionEntry`の`then`で出力している`console.debug`の部分的になります。

```console
key : 1cd4d835cac3bd0f88da58734f0e4bcaa1a74250f15e606caee8875cfc0f5138
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/false',
    '30000円以上/false',
    'シネマ利用/false'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: true,
    _discount1Hour: false,
    _discount2Hour30Minute: false,
    _discount3Hour30Minute: false
  }
}
key : 8d3945528bb3190b088f38bf754d0f9464de282cbb06d4d523cdbc706ceea0d5
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/true',
    '10000円以上30000円未満/false',
    '30000円以上/false',
    'シネマ利用/false'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: true,
    _discount2Hour30Minute: false,
    _discount3Hour30Minute: false
  }
}
key : 4c303ba7f4fa5431b3983a97ee135b812930ad8320a6b09a0cc28c4c013b0bf6
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/true',
    '30000円以上/false',
    'シネマ利用/false'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: true,
    _discount3Hour30Minute: false
  }
}
key : 6cf9b32c36ac91fd97f3b617b54736928d3a4b5a87846c9266b24c425402167e
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/false',
    '30000円以上/true',
    'シネマ利用/false'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: false,
    _discount3Hour30Minute: true
  }
}
key : 2bc41e2637a6ef7b7f69bb9ad9d60cafc07a965d0390adef7e0c379f12adb23c
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/false',
    '30000円以上/false',
    'シネマ利用/true'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: true,
    _discount3Hour30Minute: false
  }
}
key : b4ce0b7d2a1858e94b33de5c5b7c43dc8849bf203409dd539f9c4499c1e23014
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/true',
    '10000円以上30000円未満/false',
    '30000円以上/false',
    'シネマ利用/true'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: true,
    _discount3Hour30Minute: false
  }
}
key : 46293f2e368f3b3c05ca518224497c0b10cbe70fbe379c949064bc9805915641
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/true',
    '30000円以上/false',
    'シネマ利用/true'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: true,
    _discount3Hour30Minute: false
  }
}
key : 4e3f8bd8c32d4f8b437c6f91d68897e7dbf4ace2ab9dc9898f26afbc3641d11b
ConditionEntry {
  _conditionMap: Set(4) {
    '3000円以上10000円未満/false',
    '10000円以上30000円未満/false',
    '30000円以上/true',
    'シネマ利用/true'
  },
  _action: ParkingDiscountAction {
    _discount30Minute: false,
    _discount1Hour: false,
    _discount2Hour30Minute: false,
    _discount3Hour30Minute: true
  }
}
resolve: key=1cd4d835cac3bd0f88da58734f0e4bcaa1a74250f15e606caee8875cfc0f5138
paymentPrice : 2000, watchCinema : false
ParkingDiscountAction {
  _discount30Minute: true,
  _discount1Hour: false,
  _discount2Hour30Minute: false,
  _discount3Hour30Minute: false
}
resolve: key=8d3945528bb3190b088f38bf754d0f9464de282cbb06d4d523cdbc706ceea0d5
paymentPrice : 5000, watchCinema : false
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: true,
  _discount2Hour30Minute: false,
  _discount3Hour30Minute: false
}
resolve: key=4c303ba7f4fa5431b3983a97ee135b812930ad8320a6b09a0cc28c4c013b0bf6
paymentPrice : 17000, watchCinema : false
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: true,
  _discount3Hour30Minute: false
}
resolve: key=6cf9b32c36ac91fd97f3b617b54736928d3a4b5a87846c9266b24c425402167e
paymentPrice : 45000, watchCinema : false
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: false,
  _discount3Hour30Minute: true
}
resolve: key=2bc41e2637a6ef7b7f69bb9ad9d60cafc07a965d0390adef7e0c379f12adb23c
paymentPrice : 100, watchCinema : true
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: true,
  _discount3Hour30Minute: false
}
resolve: key=b4ce0b7d2a1858e94b33de5c5b7c43dc8849bf203409dd539f9c4499c1e23014
paymentPrice : 7000, watchCinema : true
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: true,
  _discount3Hour30Minute: false
}
resolve: key=46293f2e368f3b3c05ca518224497c0b10cbe70fbe379c949064bc9805915641
paymentPrice : 20000, watchCinema : true
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: true,
  _discount3Hour30Minute: false
}
resolve: key=4e3f8bd8c32d4f8b437c6f91d68897e7dbf4ace2ab9dc9898f26afbc3641d11b
paymentPrice : 100000, watchCinema : true
ParkingDiscountAction {
  _discount30Minute: false,
  _discount1Hour: false,
  _discount2Hour30Minute: false,
  _discount3Hour30Minute: true
}
```

## 5. さいごに

今回はTypeScriptでデシジョンテーブルを見た目そのままに実装する方法について説明しました。デシジョンテーブルの要件に応じた実装は定型的な内容のため、設計書からの自動生成や生成AIでの実装も可能かもしれませんね。

なお、Java版では「駐車場料金の割引計算デシジョンテーブルの実装（シンプル版）」も実装しています。今回は時間がなかったので実装していませんが、TypeScriptでもこの実装は可能ですので試してみてください。

![compact-desition.jpg](https://qiita-image-store.s3.amazonaws.com/0/195748/ee38ed12-4dd1-a124-dcae-f08ab200efcb.jpeg)