Featured image of post コード探偵ロックの事件簿【Interpreter】偽りの翻訳者〜文字列が語る嘘と文法の真実〜

コード探偵ロックの事件簿【Interpreter】偽りの翻訳者〜文字列が語る嘘と文法の真実〜

evalで文字列ルールを実行するキャンペーンエンジンの脆弱性を、Interpreterパターンで文法をオブジェクトツリー化し安全に評価する手法をPerl/Mooで解説

サーバールーム横の会議室は、空調の風だけが低く唸っている。私はノートPCを開いて、約束の時刻を待っていた。

技術顧問の田中さんが言っていた。「変わった人だが、コードを見せればわかる」と。

10年間、evalの中身を手作業で書き換え続けてきた。先月の本番停止の始末書を書いたとき、さすがに限界だと思った。問題はわかっている。わかっていて、直す余裕がなかった。いや、直し方がわからなかったのかもしれない。

会議室のドアが開いた。

入ってきたのは、手にペンとノートだけを持った男だった。タブレットもノートPCも持っていない。部屋に入るなり、ホワイトボードを一瞥して立ち止まる。

「この部屋の最後の会議は3日前だね。マーカーのインクの乾き具合でわかる」

……ああ、この人か。田中さんの言った「変わった人」というのは、こういうことか。

「ロック。コードの探偵だ。で、君が10年間守り続けてきた呪文を見せてくれたまえ——ワトソン君」

「……好きに呼んでください。それより、呪文って何のことですか」

「evalの中身だよ」

ロックさんはペンを弄びながら、私の向かいに座った。

「evalを使うシステムの運用者は、呪文の暗唱者だ。意味を理解して唱えているのか、音だけ真似ているのか。さて、君はどちらかね」

私は黙ってノートPCの画面を向けた。DBから取得したルール文字列の一覧が映っている。

事件現場——evalの中身

画面に映っていたのは、こんなコードだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package CampaignEngine;
use Moo;
use v5.36;

has rules => (is => 'ro', default => sub { [] });

sub load_rules ($self, $rows) {
    push $self->rules->@*, $rows->@*;
}

sub evaluate ($self, $ctx) {
    my @matched;
    for my $rule ($self->rules->@*) {
        my $result = eval $rule->{condition_expr};
        if ($@) {
            warn "Rule '$rule->{name}' failed: $@";
            next;
        }
        push @matched, $rule if $result;
    }
    return \@matched;
}

1;

DBに格納されたルール文字列の例:

1
2
3
4
5
6
7
name: gold_large_order
condition_expr: $ctx->{total_amount} >= 10000 && $ctx->{member_level} eq "gold"
discount_pct: 15

name: bulk_or_premium
condition_expr: $ctx->{item_count} > 3 || $ctx->{total_amount} >= 20000
discount_pct: 10

キャンペーンのルールが200本以上。すべてが文字列としてDBに保存され、eval で実行されている。マーケティング部門から毎週新しいルールの追加依頼が来る。そのたびに誰かが文字列を手で書いて、DBにINSERTする。

「先月、括弧の閉じ忘れで3時間の本番停止を出しました。始末書も書きました」

ロックさんは画面を覗き込んだまま黙っている。ルール文字列を3つ、4つと目で追っている。長い沈黙だった。

容疑者の浮上——文法なき言語

「……ワトソン君、これは言語だ」

「言語? ただの条件式ですよ」

「いいや。$ctx->{total_amount} >= 10000 && $ctx->{member_level} eq "gold" は条件式に見えるが、200本集まればそれは言語だ。文法のない言語。そしてevalは、文法チェックなしにその言語を実行する偽りの翻訳者だ」

偽りの翻訳者。妙な言い方だと思った。しかし、的を射ている。

「翻訳者は原文の正しさに責任を持たない。括弧が足りなくても、スペルミスがあっても、渡された通りに実行する。先月の3時間の停止は、翻訳者の忠実さがもたらした裏切りだ」

ロックさんはペンをノートに走らせながら続けた。

「犯人はevalではない。文法を定義せずに言語を運用してきた、設計の不在そのものだ」

10年動いてきたシステムだ。evalが悪いのか、と言い切るのは抵抗がある。

「10年動いてきたんですよ。evalが悪いとは言い切れないのでは」

動いている理解できている は別の言葉だよ、ワトソン君。この呪文が10年間正しく唱えられてきたのは、君という祈祷師がいたからだ。——君がいなくなったら?」

返す言葉がなかった。実際、私が異動したら誰がこのルールを書けるのか。引き継ぎ書に「eval文字列の書き方」を残す未来を想像して、背筋が冷えた。

推理披露——Interpreter パターン

ロックさんはノートに木のような図を描き始めた。

「言語が存在するなら、文法を与えればいい。Interpreterパターン——文法規則をオブジェクトの木として表現し、再帰的に評価する」

	classDiagram
    class Expression {
        <<Role>>
        +interpret(ctx) bool
    }

    class Expr_GreaterThan {
        +field: Str
        +threshold: Num
        +interpret(ctx) bool
    }

    class Expr_GreaterThanOrEqual {
        +field: Str
        +threshold: Num
        +interpret(ctx) bool
    }

    class Expr_Equals {
        +field: Str
        +value: Str
        +interpret(ctx) bool
    }

    class Expr_And {
        +left: Expression
        +right: Expression
        +interpret(ctx) bool
    }

    class Expr_Or {
        +left: Expression
        +right: Expression
        +interpret(ctx) bool
    }

    class Expr_Not {
        +expr: Expression
        +interpret(ctx) bool
    }

    Expression <|.. Expr_GreaterThan
    Expression <|.. Expr_GreaterThanOrEqual
    Expression <|.. Expr_Equals
    Expression <|.. Expr_And
    Expression <|.. Expr_Or
    Expression <|.. Expr_Not

    Expr_And --> Expression : left
    Expr_And --> Expression : right
    Expr_Or --> Expression : left
    Expr_Or --> Expression : right
    Expr_Not --> Expression : expr

Expression ロール——文法の契約

「すべての式は、一つの質問に答えられなければならない。『このコンテキストで、お前は真か偽か?』」

1
2
3
4
5
6
7
package Expression;
use Moo::Role;
use v5.36;

requires 'interpret';

1;

「Specificationパターンと何が違うんですか? 条件をオブジェクトにするなら、同じことでは」

先日、社内勉強会でSpecificationパターンの記事を読んだばかりだった。条件をオブジェクトにするという点では似ている。

「Specificationは問いに名前を付ける。Interpreterは問い自体の文法を定義する。法廷で言えば、Specificationは証言者、Interpreterは訴訟手続法だ。evalは手続きなき裁判——つまりリンチだよ」

法廷の比喩は大袈裟だと思ったが、構造上の違いは理解できた。Specificationは「この条件を満たすか」を聞く。Interpreterは「条件の書き方そのものをルール化する」。レイヤーが一段深い。

TerminalExpression——終端式

「文法の最小単位。これ以上分解できない原子的な判定だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Expr::GreaterThan;
use Moo;
use v5.36;
with 'Expression';

has field     => (is => 'ro', required => 1);
has threshold => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return ($ctx->{$self->field} // 0) > $self->threshold;
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Expr::GreaterThanOrEqual;
use Moo;
use v5.36;
with 'Expression';

has field     => (is => 'ro', required => 1);
has threshold => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return ($ctx->{$self->field} // 0) >= $self->threshold;
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Expr::Equals;
use Moo;
use v5.36;
with 'Expression';

has field => (is => 'ro', required => 1);
has value => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return ($ctx->{$self->field} // '') eq $self->value;
}

1;

$ctx->{total_amount} > 5000 の代わりに Expr::GreaterThan->new(...) ですか。……長いですね」

正直な感想だった。文字列なら1行で書ける条件が、オブジェクトの生成で3行になる。

「呪文を一行で書くか、文法書を一冊用意するか。文法書があれば、新しい呪文は書く前に正しさを検証できる

NonterminalExpression——非終端式

「終端式を木として組み合わせる糊だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Expr::And;
use Moo;
use v5.36;
with 'Expression';

has left  => (is => 'ro', required => 1);
has right => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return $self->left->interpret($ctx) && $self->right->interpret($ctx);
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Expr::Or;
use Moo;
use v5.36;
with 'Expression';

has left  => (is => 'ro', required => 1);
has right => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return $self->left->interpret($ctx) || $self->right->interpret($ctx);
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package Expr::Not;
use Moo;
use v5.36;
with 'Expression';

has expr => (is => 'ro', required => 1);

sub interpret ($self, $ctx) {
    return !$self->expr->interpret($ctx);
}

1;

「木……構文木ですか。コンパイラの教科書で昔見た記憶があります」

「evalが文字列を直接実行するのは、翻訳なしで外国語の発音だけを真似るようなものだ。構文木は構造化された理解だ。木の各ノードは、自分の責任範囲だけを評価する」

構文木の構築——翻訳は構造に宿る

ロックさんはノートにBefore → Afterの対応を書き起こした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Before: eval '$ctx->{total_amount} >= 10000 && $ctx->{member_level} eq "gold"'
# After:
my $gold_large_order = Expr::And->new(
    left  => Expr::GreaterThanOrEqual->new(
        field     => 'total_amount',
        threshold => 10000,
    ),
    right => Expr::Equals->new(
        field => 'member_level',
        value => 'gold',
    ),
);

# Before: eval '$ctx->{item_count} > 3 || $ctx->{total_amount} >= 20000'
# After:
my $bulk_or_premium = Expr::Or->new(
    left  => Expr::GreaterThan->new(
        field     => 'item_count',
        threshold => 3,
    ),
    right => Expr::GreaterThanOrEqual->new(
        field     => 'total_amount',
        threshold => 20000,
    ),
);

文字列が、木になった。目で追える。枝の先に何があるか、たどれる。

「……待ってください」

私は画面から顔を上げた。10年の運用で染みついた勘が、何かを捉えていた。

「このオブジェクトツリーなら、interpret を呼ぶ前に木の構造を検査できる。括弧の閉じ忘れとか、存在しないフィールドの参照とか——デプロイ前に検出できる

「気づいたかね、ワトソン君」

ロックさんが初めて笑った。不敵な笑みではなく、職人が同業者の発見を認めるような、控えめな表情だった。

「文法があれば、正しさは実行前に保証できる。それがevalとの決定的な差だ」

先月の3時間。括弧の閉じ忘れ。あれは文字列の中に閉じ込められていたから、デプロイして実行するまで誰も気づけなかった。構文木なら、組み立ての時点で型が合わなければエラーになる。

事件の終わり——リファクタリング後の CampaignEngine

ロックさんの指示に従って、CampaignEngine を書き換えた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package CampaignEngine;
use Moo;
use v5.36;
use Types::Standard qw(ArrayRef HashRef);

has rules => (is => 'ro', isa => ArrayRef[HashRef], default => sub { [] });

sub add_rule ($self, %args) {
    push $self->rules->@*, \%args;
}

sub evaluate ($self, $ctx) {
    my @matched;
    for my $rule ($self->rules->@*) {
        push @matched, $rule if $rule->{condition}->interpret($ctx);
    }
    return \@matched;
}

1;

eval が消えた。condition_expr というキーも、文字列も。代わりに condition キーに Expression ロールを消費するオブジェクトが入る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use Expr::And;
use Expr::Or;
use Expr::GreaterThan;
use Expr::GreaterThanOrEqual;
use Expr::Equals;

my $engine = CampaignEngine->new;

$engine->add_rule(
    name      => 'gold_large_order',
    condition => Expr::And->new(
        left  => Expr::GreaterThanOrEqual->new(
            field     => 'total_amount',
            threshold => 10000,
        ),
        right => Expr::Equals->new(
            field => 'member_level',
            value => 'gold',
        ),
    ),
    discount_pct => 15,
);

$engine->add_rule(
    name      => 'bulk_or_premium',
    condition => Expr::Or->new(
        left  => Expr::GreaterThan->new(
            field     => 'item_count',
            threshold => 3,
        ),
        right => Expr::GreaterThanOrEqual->new(
            field     => 'total_amount',
            threshold => 20000,
        ),
    ),
    discount_pct => 10,
);

テストを書いた。TerminalExpression の単体テスト、NonterminalExpression の合成テスト、CampaignEngine の統合テスト。存在しないフィールドへのデフォルト挙動も確認した。

全テスト通過。

「テストが書ける。evalの文字列にはテストが書けなかった。10年間、祈りながらデプロイしていた」

ロックさんはペンをノートに挟み、立ち上がった。

「祈祷から工学へ。それがパターンの力だ」

「DBの200本のルール文字列はどうすればいいですか。全部書き直すんですか」

「パーサーを書きたまえ。文字列を読み取って構文木を生成する、正しい翻訳者だ。だが今日の仕事は文法の定義だ。翻訳者は、文法の上に立つ」

200本の移行は、一日の仕事ではない。しかし、文法が定義された今なら、パーサーを書くことは工学の問題であって呪文の問題ではない。

エピローグ

ロックさんが会議室のドアに手をかけた。

「ワトソン君、最後に一つ」

「なんですか」

「この文法に名前をつけておきたまえ。名前のない言語は、また呪文に戻る」

ドアが閉まった。空調のうなりだけが戻ってくる。

私は画面を見つめた。Expression ロール。Expr::AndExpr::GreaterThanOrEqual。10年間、文字列の中に閉じ込めていたものが、名前を持ったクラスとして並んでいる。

「……DiscountDSL、か」

10年越しの命名にしては、悪くない。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
eval地獄(文字列ベースのルール実行)Interpreter Pattern(文法のオブジェクトツリー化)構文エラーをデプロイ前に検出可能
文法なき言語(正規表現・文字列の乱用)抽象構文木(AST)による構造化各ノードが責任範囲だけを評価し、テスト可能に
設計の不在(暗黙の文法を人手で維持)Expression Role + Terminal/Nonterminal分離新ルール追加時にクラス追加のみ。既存コード変更不要

推理のステップ

  1. Expression ロール定義: Moo::Rolerequires 'interpret' を宣言し、すべての式が従う契約を定める
  2. TerminalExpression 実装: Expr::GreaterThan, Expr::GreaterThanOrEqual, Expr::Equals など、分解不能な原子的判定をクラス化
  3. NonterminalExpression 実装: Expr::And, Expr::Or, Expr::Not で終端式を木構造として合成
  4. 構文木構築: eval文字列をオブジェクトツリーに変換し、Before/After の対応を確認
  5. CampaignEngine 統合: condition_expr(文字列)を condition(Expression オブジェクト)に置き換え、eval を除去
  6. テスト検証: 終端式・非終端式の単体テスト、複合構文木のネストテスト、統合テスト、エッジケースをすべて通過

ロックより

10年間の祈祷を責めるつもりはない。システムが動き続けたのは、君の技量があったからだ。 だが、技量に依存するシステムは、技量とともに失われる。文法をオブジェクトとして定義することは、君の知識をコードに翻訳する行為だ。 翻訳が終われば、君がいなくても文法は残る。——それが工学というものだよ、ワトソン君。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。