Posted at

複雑な条件分岐にサヨウナラ。PHPのルールエンジンRulerを使って複雑な条件をシンプルにしてみた

More than 3 years have passed since last update.


はじめに

コードを書いていると、どうしても複雑な条件って出てくるじゃないですか。

お酒を販売してよいか というシンプルな条件なら、こんな感じの超シンプルなコードでOKだと思います。


function canSaleSake($age)
{
return ($age > 20);
}

でも現実には、もっと複雑な条件が絡みあうことが多々有ります。

例えば、今回は、架空の女性【結婚花子】ちゃんが求婚してきた男性と結婚出来るかを判断する。というのをコードで書いてみたいと思います。

(この話はフィクションです)

結婚するときの条件って昔は3Kといって、「高収入・高身長・高学歴」という3つの条件をクリアしないと結婚相手としない。

みたいな話がありましたが、実際はもっと複雑だと思うんですよね。

顔とか、優しさとか、住んでる場所とか、両親の年齢とかとか。

これをコードで 結婚出来るか という条件を書くととんでもなく大変になってくるのはイメージつくと思います。

で、この複雑な条件を、if文を書かずに済むようにしてくれるルールエンジンというのを紹介してみようと思います。


まずは前提条件

結婚花子さんは、どんな条件で結婚出来るのか。

単純に、年収1000万以上、かつ(身長180cm以上 または 最終学歴が偏差値65以上)をクリアしてたら結婚OKとします。

花子さんは、年収と(身長or学歴)の2つの条件にしたわけです。

ただ、年収1000万はあんまいないかもしれないので、年収が1000万円未満でも、親の資産額が1億円以上なら年収の条件もクリアとします。

これを表にするとこうなります。

番号
年収
身長
学歴
親が資産家
結婚できるか

1




2




3




4




5




6




7




8




9




10




11




12




13




14




15




16





普通にifを使って書いてみる

単純化してますが、Kekkonクラスに、男性のステータスとかを持ったMenくらすのインスタンスを渡して、 canMarrige で結婚可能かどうかを返すと考えてください。

(ここのロジックはもう少し単純化出来ますが、条件を増やして複雑さを表現するのは大変だったので、あえて複雑なままにしてます)

class Kekkon

{
private $men = null;

public function __construct(Men $men)
{
$this->men = $men;
}

public function canMarriage()
{
// 年収が高ければ、身長か学歴のどちらかが高ければ結婚可能
if ($this->men->isHighIncome()) {
if ($this->men->isHighHiehgt()) {
return true;
}

if ($this->men->isHighSchooling()) {
return true;
}

return false;
}

// 年収が低くても嫌の資産が多ければ、身長か学歴のどちらかが高ければ結婚可能
if ($this->men->hasHightProperyByParent()) {
if ($this->men->isHighHiehgt()) {
return true;
}

if ($this->men->isHighSchooling()) {
return true;
}

return false;
}

// 自分の年収も親の資産も少なければ結婚出来ない
return false;
}
}

これだけでも、 canMarrige メソッドは少し読みづらくなってきますよね。。

さらに、ルックスとか、性格の条件や住んでる場所や親との同居の可能性等が増えた時を考えるとゾッとしますよね。


そこでルールエンジン

ルールエンジンというのは、「○○と☓☓または▲▲なら結婚可能」みたいな条件自体を、オブジェクトとして扱えるようにすることで、ルールさえ決まってしまえば、実装はルールを書くだけでOKという事に出来るようなものです。

一応 wikipedia にルールエンジンについて書かれた項目がありました。

そして、PHPにはRulerというライブラリがあり、composer経由で利用可能だっったので使ってみました。


Rulerを使って簡単な処理を書いてみる

<?php

require_once __DIR__.'/vendor/autoload.php';

use Ruler\RuleBuilder;
use Ruler\Context;

$rb = new RuleBuilder;
$rule = $rb->create(
$rb->logicalAnd(
$rb['minNumPeople']->lessThanOrEqualTo($rb['actualNumPeople']),
$rb['maxNumPeople']->greaterThanOrEqualTo($rb['actualNumPeople'])
),
function() {
echo 'パーティ出来るZ';
}
);

$context = new Context(array(
'minNumPeople' => 5,
'maxNumPeople' => 25,
'actualNumPeople' => function() {
return 6;
},
));

$rule->execute($context); // "パーティ出来るZ"

Ruler自体の使い方は非常に単純です。

RuleBuilderを使ってルールを生成し、あるContextにルールを適用した時にどうなるかを判断してるだけです。


少し実際のRulerのコードを見てみる

↑のサンプルでは、 $rule のexecuteを実行するとパーティ出来るZが出力されましたが、これが実際に中で何をやってるのか見てみます。

まず$rbはRuleBuilder->create(...)で生成してますが、

中では、conditionとactionを渡してRuleのインスタンスを生成してます。


RuleBuilder.php

 27     /**

28 * Create a Rule with the given propositional condition.
29 *
30 * @param Proposition $condition Propositional condition for this Rule
31 * @param callback $action Action (callable) to take upon successful Rule executio n (default: null)
32 *
33 * @return Rule
34 */

35 public function create(Proposition $condition, $action = null)
36 {
37 return new Rule($condition, $action);
38 }


Ruleのexecuteは何をしているのか

まずはRuleのevaluateを実行し(Contextにruleを適用)、ルールを適用した結果、trueがかえってきて、かつContextが`action(今回は echo "パーティ出来るZ")を持ってたらそれを実行する。

ということをしてるので、今回与えたContextの場合だと、パーティ出来るZがechoされたんですね。


Rule.php

 39     /**

40 * Evaluate the Rule with the given Context.
41 *
42 * @param Context $context Context with which to evaluate this Rule
43 *
44 * @return boolean
45 */

46 public function evaluate(Context $context)
47 {
48 return $this->condition->evaluate($context);
49 }
50
51 /**
52 * Execute the Rule with the given Context.
53 *
54 * The Rule will be evaluated, and if successful, will execute its
55 * $action callback.
56 *
57 * @param Context $context Context with which to execute this Rule
58 * @throws \LogicException
59 */

60 public function execute(Context $context)
61 {
62 if ($this->evaluate($context) && isset($this->action)) {
63 if (!is_callable($this->action)) {
64 throw new \LogicException('Rule actions must be callable.');
65 }
66
67 call_user_func($this->action);
68 }
69 }



じゃあ、さっきの結婚条件にRulerを使ってみる

※ 【注意】これは実際に私がやっていった思考の流れで説明していくので、最終的にルールエンジンを使うと何が嬉しいかを知りたい人は一番最後まで呼び飛ばしてください。

まずは、高収入かつ、(高身長or高学歴) というルールを作ってみます。

    public function isHighIncomeAndHighHeightOrHighSchooling()

{
$rb = new RuleBuilder;

// 高年収ルール
$incomeRule = $rb['is_high_income']->equalTo(true);

// 身長か学歴のルール
$heightRule = $rb['is_high_height']->equalTo(true);
$schoolingRule = $rb['is_high_schooling']->equalTo(true);
$heightOrSchoolingRule = $rb->logicalOr($heightRule, $schoolingRule);

// 年収と(身長か学歴)の両方をクリアしてたら結婚出来る
$rule = $rb->create(
$rb->logicalAnd(
$incomeRule,
$heightOrSchoolingRule
),
);

$context = new Context([
'is_high_income' => $this->men->isHighIncome(),
'is_high_height' => $this->men->isHighHiehgt(),
'is_high_schooling' => $this->men->isHighSchooling()
]);

return $rule->evaluate($context);
}

こんな感じですね。


余談

今回は、MenクラスがisHightIncomeというメソッドを持っているので、$rb['is_high_income']->equalTo(true)と書いてますが、ここで年収の判別もするようであれば下記のように書くことになると思います。

$rb = new RuleBuilder();

// 男性の年収が1000万円以上
$incomeRule = $rb['income']-> greaterThanOrEqualTo(1000);

$context = new Context([
'income' => $this->men->income
]);

さて、話を戻します。

まずは、高収入ルールである、$incomeRuleを生成します。

で、身長と学歴は妥協してどちらか1つでOKだったので、$rb->logicalOrを使ってORの条件ルールにしてます。

最終的には、高収入高身長or高学歴の両方を満たす$ruleを生成してます。

ただ、もうお気づきかとは思いますが、このメソッドって3つの部分で構成されてます。


1. 各ルールを生成する部分


2. 組み合わせたルールを生成する部分


3. Contextを生成し、Contextをルールで評価する部分

じゃあ、さらにメソッドに分割出来そうなので、まずは1の部分を分割してみます。


各ルールを生成する部分を切り出す

    public function isHighIncomeAndHighHeightOrHighSchooling()

{
$rb = new RuleBuilder;

$rules = $this->generateRules();

// 年収と(身長か学歴)の両方をクリアしてたら結婚出来る
$rule = $rb->create(
$rb->logicalAnd(
$rules['is_high_income'],
$rb->logicalOr($rules['is_high_height'], $rules['is_high_schooling'])
),
);

$context = new Context(array(
'is_high_income' => $this->men->isHighIncome(),
'is_high_height' => $this->men->isHighHiehgt(),
'is_high_schooling' => $this->men->isHighSchooling()
));

$rule->evaluate($context);
}

public function generateRules()
{
$rb = new RuleBuilder;

return [
'is_high_income_rule' => $rb['is_high_income']->equalTo(true),
'is_high_height' => $rb['is_high_height']->equalTo(true),
'is_high_schooling' => $rb['is_high_schooling']->equalTo(true),
];
}

こんな感じで、各種ルールを切り出せました。

ここで嬉しい事が1つあります。

今 generateRules で、各ルールを配列で返してます。

ということは、このキーの名前って【日本語で表現】できますよね。

これが大きな1歩です。

一気にわかりやすくすることが出来ます。


配列のキー名に日本語を使ってみる

さっきのgenerateRulesの各ルールのキー名を日本語にしてみましょう。

    public function generateRules()

{
$rb = new RuleBuilder;

return [
'高収入' => $rb['is_high_income']->equalTo(true),
'身長高い' => $rb['is_high_height']->equalTo(true),
'高学歴' => $rb['is_high_schooling']->equalTo(true),
];
}

どうですか?

英語のキー名より圧倒的にわかりやすくなったと思いませんか?

(私が英語ニガテなのもありますが。。泣)

さぁ、次のステップに進みましょう。

そもそも、各ルールの生成と、組み合わせたルールの生成も2つ合わせて切り出せそうですよね。

そうすれば呼び出し元はRuleBuilderを使う必要なくなりますし。


ルールの生成毎切り出してみる

    public function isHighIncomeAndHighHeightOrHighSchooling()

{
$rule = $this->generateRule();

$context = new Context(array(
'is_high_income' => $this->men->isHighIncome(),
'is_high_height' => $this->men->isHighHiehgt(),
'is_high_schooling' => $this->men->isHighSchooling()
));

$rule->evaluate($context);
}

public function generateRule()
{
$rb = new RuleBuilder;

$rules = [
'高収入' => $rb['is_high_income']->equalTo(true),
'身長高い' => $rb['is_high_height']->equalTo(true),
'高学歴' => $rb['is_high_schooling']->equalTo(true)
];

return $rb->logialAnd(
$rules['高収入'],
$rules['身長高い']
);
}

さぁ、ルール毎切り出せました。

ここからいっきに加速出来ます。

今って、高年収かつ(高身長or高学歴)という判別だけするメソッドを作ってて、「このメソッドをいくつも作るのか・・」って思ってたかもしれませんが、generateRuleが作れた事で、一気にルールを作れるようになるんですね。


ルールをすべて一気に生成してみる

    public function canMarriage()

{
$context = new Context(array(
'is_high_income' => $this->men->isHighIncome(),
'is_high_height' => $this->men->isHighHiehgt(),
'is_high_schooling' => $this->men->isHighSchooling()
'is_parent_high_property' => $this->men->hasHightProperyByParent()
));

$rule = $this->generateRule();
$rule->evaluate($context);
}

public function generateRule()
{
$rb = new RuleBuilder;

$rules = [
'高収入' => $rb['is_high_income']->equalTo(true),
'身長高い' => $rb['is_high_height']->equalTo(true),
'高学歴' => $rb['is_high_schooling']->equalTo(true),
'親が資産家' => $rb['is_parent_high_property']->equalTo(true),
];

     // これらの条件のどれかを満たしてればOK
return $rb->logialOr(
// 高収入で身長高ければOK
$rb->create(
$rb->logicalAnd(
$rules['高収入'],
$rules['身長高い']
)
),
// 高収入で学歴高ければOK
$rb->create(
$rb->logicalAnd(
$rules['高収入'],
$rules['高学歴']
)
),
// 親が資産家で身長高ければOK
$rb->create(
$rb->logicalAnd(
$rules['親が資産家'],
$rules['身長高い']
)
),
// 親が資産家で学歴高ければOK
$rb->create(
$rb->logicalAnd(
$rules['親が資産家'],
$rules['高学歴']
)
),
);
}

こうすることで、canMarrigeからif節がいっきに消えました。

しかも、ルールが増えても、generateRuleにルールを追加するだけでOKです。


まとめ

今回は複雑な条件に立ち向かう為に、ルールエンジンを使ってみました。

これによって何が良くなったか。


  • ルールエンジンを使って日本語でルールが組み立てられるようになって、ルールがパット見で分かりやすくなった

  • ネストが深くなりがちな複雑なロジックでも、条件分岐を使わずにロジックを組み立てられるようになった

ぜひ、皆さんもこれを機会にルールエンジンを導入して複雑さに立ち向かってみてはいかがでしょうか。

きっと素敵な世界が見えてくると思います。