Featured image of post コードドクター【Interpreter】急性eval中毒〜文法オブジェクトの処方箋〜

コードドクター【Interpreter】急性eval中毒〜文法オブジェクトの処方箋〜

eval()でクーポンルールを動的評価していた自信家テックリード。セキュリティ監査でコードインジェクション判定を受け来院。コードドクターが下した診断は「急性eval中毒」。Interpreterパターンによる文法オブジェクトの処方箋。

雑居ビルの2階。磨かれたリノリウムの床が、古めかしい蛍光灯の光を鈍く反射している。

俺——桐山蓮、29歳、中堅アパレルEC「ClothCraft」のバックエンド・テックリード——は、知人に渡されたメモを見ながら廊下を歩いていた。メモには住所と「コード診療所」とだけ書かれている。

「……マジか」

重厚な鉄扉に、白地に黒文字のプレート。飾り気のないゴシック体で「コード診療所」。それだけだ。病院のようなロゴも、営業時間の案内もない。

俺はドアの取っ手を引いた。

目の前に壁ができていた。O’Reilly本の壁。背表紙に動物のイラストが描かれた技術書が天井近くまで積まれ、その隙間からマザーボードやケーブルの束が顔を覗かせている。

「……病院っていうか、倉庫じゃねえか」

本の山を避けながら奥に進むと、ようやく受付カウンターらしきものが見えた。その向こう——ブラインドの降りた窓を背にしたトリプルディスプレイの前に、黒いシャツを着た男が座っている。微動だにしない。キーボードに両手を置いたまま、画面を凝視している。

「あの、すみません——」

「大丈夫ですよ、ここはコード診療所です」

カウンターの横から声がした。

「助手のナナコと申します。こちらがコードドクターの先生です。どうぞおかけになってください」

ナナコと名乗ったその人は、柔らかい白のブラウスにカーディガンを羽織り、穏やかな笑顔を浮かべていた。

先生と呼ばれた男は、俺が座っても振り向かなかった。画面から目を離さない。失礼なやつだ——というのが正直な第一印象だった。

来院

「えっと、知り合いのエンジニアに紹介されて来たんすけど」

俺はノートPCを鞄から取り出し、テーブルの上で開いた。管理画面のURLを叩く。ClothCraftの内部ツール——俺が2年かけて育てた、クーポンルールエンジンの管理画面だ。

「実は、セキュリティ監査ってやつに引っかかっちゃって。でも、正直、俺のシステムは問題ないと思ってるんすよ」

ナナコが隣に立ち、画面を覗き込んだ。

「監査の報告書、見せていただけますか?」

俺は監査レポートのPDFを開いた。赤字で「コードインジェクション: Critical」と書いてある。

「これっすね。でも、管理画面だから外部入力じゃないし——ちょっと見てもらえます?」

俺はルール入力欄に cart_total >= 5000 && member_rank eq "gold" と打ち込んで、実行ボタンを押した。画面の右側に「適用: ゴールド会員5000円以上割引」と表示される。

「ほら、リアルタイムで反映されるんすよ。コード変更なしで。マーケのやつらも大喜びで——」

背後で椅子が軋む音がした。

先生がいつの間にかトリプルディスプレイから離れ、俺のノートPCを覗き込んでいた。ただし、管理画面ではなく、俺が裏で開いていたソースコードのタブを。CouponEngine.pm の eval が書かれた行を、無言で指差している。

「あ、そこ見ます? eval っすけど、柔軟にやるにはこれが——」

先生が片手を上げた。「止まれ」とでも言うような、静かだが有無を言わせない動作だった。

「……劇薬だ」

先生の声は低く、短かった。

ナナコが俺の方を向いて微笑んだ。「eval は確かに即効性がありますけど、副作用も大きいお薬なんです。用法・用量を間違えると……大変なことになりますよ」

「いや、でも2年間ちゃんと——」

先生は俺の言葉には何の反応も見せず、ソースコードをスクロールし始めた。


触診

先生の指がトラックパッドを滑り、俺のコードが画面を流れていく。止まった場所は evaluate メソッドの中核部分だった。

1
2
3
4
5
6
7
# evalで評価! これが俺の最強の武器
my $result = eval $expr;  ## no critic -- 桐山は気にしない
if ($@) {
    # エラーは「条件不一致」として処理
    # FIXME: ログくらい出したほうがいい気がするけど...まあいいか
    next;
}

先生が eval の行を指で叩いた。コツ、コツ、と。

「注射……直接、フィルタなし」

ナナコが通訳した。「外部から入力された文字列を、そのまま Perl のコードとして実行しているんです。点滴に何でも混ぜられる状態——いえ、注射器で直接血管に異物を打ち込める状態ですね」

「いや、でも管理画面からしか——」

「管理画面にログインできる方全員が、Perl のコードを自由に実行できるということですよ」

ナナコの声は穏やかだったが、俺の背筋が凍った。

「マーケ部門の人も、アルバイトの子も、アカウントさえあれば?」

「はい」

俺は自分が作ったルール入力欄を見た。テキストボックスだ。自由入力のテキストボックス。その中身が eval に流し込まれる。

……やばい。

先生はさらにスクロールし、$@ の処理を指差した。

「もう一つ。痛覚が死んでいる」

俺は if ($@) { next; } の行を見つめた。エラーを……握りつぶしている。eval でエラーが起きても、「条件不一致」として静かに next しているだけ。ログも出していない。

ナナコが続けた。「エラーを握りつぶしているので、異変に気づけないんです。痛みを感じない体は、怪我をしても気づけないんですよ」

「あれは……ルールがマッチしなかったってことだと思って……」

「不正な式が混入しても、壊れたルールが紛れ込んでも、全部 “条件不一致” で片づけられてしまいますね」

先生が画面から目を離し、俺の方を向いた。いや、正確には俺を通り越した先の壁を見ているのかもしれない。判断がつかなかった。

「急性eval中毒。 重症


診断

ナナコが俺の前にメモを差し出した。きれいな字で箇条書きが並んでいる。

「整理しますね。桐山さんのシステムには3つの症状があります」

  1. eval 依存: ルール文字列をそのまま eval で実行。コードインジェクションのリスク
  2. エラーの握りつぶし: $@ を無視し、異常を「条件不一致」として処理
  3. デバッグ不能: 40以上のルールのどれがマッチ/エラーしたか追跡できない

「2年間動いていたのは偶然ではありません。桐山さんのルール設計がしっかりしていたからです。でも、eval という土台が危ないんです」

ナナコの言葉は俺の自尊心を傷つけないように選ばれていた——ように聞こえた。

「じゃあ、eval を外せばいいって話っすか? でもそしたらルールの動的評価ができなくなる——」

先生が口を開いた。

「できる」

一語。それだけだった。


処方箋

先生は自分の席に戻り、トリプルディスプレイに向かった。新しいファイルを開き、キーボードを叩き始める。あの小さなキーボード——HHKBというやつだろうか——から乾いた打鍵音が響く。

ナナコが俺の横に椅子を持ってきて座った。

「先生が今から書くコードを、一緒に見ていきましょう。eval を使わずにルールを動的に評価する方法があるんですよ」

成分の分離

先生の指が最初に作ったのは、驚くほど短いクラスだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package ClothCraft::Expression;
use v5.36;
use Carp qw(croak);

sub new ($class, %args) {
    return bless \%args, $class;
}

sub interpret ($self, $context) {
    croak ref($self) . '::interpret() is not implemented';
}

1;

「……文法」

先生がぼそりと呟いた。

「ルールの文法を、クラスで表現するんです」ナナコが俺に向かって言った。「文字列ではなくオブジェクトとして」

「オブジェクト? 文字列を eval に渡すんじゃなくて?」

「はい。まず、ルールを構成する最小の部品——単語カードのようなものを作ります」

先生は続けて、3つのクラスを書いた。数値や文字列を表す Literal、コンテキスト変数を参照する Variable、そして比較演算を行う Comparison

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package ClothCraft::Expression::Literal;
use v5.36;
use parent 'ClothCraft::Expression';

sub new ($class, $value) {
    return $class->SUPER::new(value => $value);
}

sub interpret ($self, $context) {
    return $self->{value};
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package ClothCraft::Expression::Variable;
use v5.36;
use parent 'ClothCraft::Expression';
use Carp qw(croak);

sub new ($class, $name) {
    return $class->SUPER::new(name => $name);
}

sub interpret ($self, $context) {
    my $name = $self->{name};
    croak "Unknown variable: $name" unless exists $context->{$name};
    return $context->{$name};
}

1;
 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
package ClothCraft::Expression::Comparison;
use v5.36;
use parent 'ClothCraft::Expression';
use Carp qw(croak);

my %OPS = (
    '>=' => sub ($a, $b) { $a >= $b },
    '<=' => sub ($a, $b) { $a <= $b },
    '==' => sub ($a, $b) { $a == $b },
    '!=' => sub ($a, $b) { $a != $b },
    'eq' => sub ($a, $b) { $a eq $b },
    'ne' => sub ($a, $b) { $a ne $b },
);

sub new ($class, $left, $op, $right) {
    croak "Unknown operator: $op" unless exists $OPS{$op};
    return $class->SUPER::new(left => $left, op => $op, right => $right);
}

sub interpret ($self, $context) {
    my $left_val  = $self->{left}->interpret($context);
    my $right_val = $self->{right}->interpret($context);
    return $OPS{$self->{op}}->($left_val, $right_val);
}

1;

ナナコが微笑んだ。「 cart_total >= 5000 という式は、 Variable("cart_total")Comparison(>=)Literal(5000) という3枚のカードに分解されるんです。1枚1枚のカードは安全で、 注射器(eval)を使わなくても結果が出せます 」

「なるほど……各カードが自分で interpret を呼ぶから、eval を通さなくていいのか」

先生がちらりと俺を見た。いや、俺の隣のナナコを見たのかもしれない。一瞬だったので分からなかった。

投薬プロトコル

先生の打鍵が続いた。次に現れたのは AndOrNot の3つのクラスだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package ClothCraft::Expression::And;
use v5.36;
use parent 'ClothCraft::Expression';

sub new ($class, $left, $right) {
    return $class->SUPER::new(left => $left, right => $right);
}

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

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package ClothCraft::Expression::Or;
use v5.36;
use parent 'ClothCraft::Expression';

sub new ($class, $left, $right) {
    return $class->SUPER::new(left => $left, right => $right);
}

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

1;

「……構文木」

先生がまた短く呟いた。

ナナコが頷いた。「カードを木のように組み合わせるんです。 A AND B なら、AND の下に A と B がぶら下がる。 A AND (B OR C) なら、AND の下に A と B OR C のサブツリーがぶら下がる——CTスキャンの画像みたいに、ルールの構造が見えるようになりますよ」

「CTスキャン……」

俺の eval エンジンは、文字列を丸ごと飲み込んで胃液で溶かすような仕組みだった。中身は見えない。何が起きているかも分からない。だから $@ を握りつぶしても気づかなかった。

でもこの構文木なら、どのノードがどう評価されたか、全部追える。

先生はさらにパーサーを書き始めた。ルール文字列をトークンに分解し、Expression オブジェクトのツリーに変換する RuleParser クラス。トークナイザの正規表現と再帰下降パーサーが、驚くほど整然と組み上がっていく。

打鍵が止まった。先生が立ち上がった。俺の方に近づいてくる。

——え、何だ。俺のノートPC見てくれるのか? 俺のコードを評価してくれるってことか?

先生の手が俺のほうに伸びた。俺は思わず姿勢を正した。

先生の手は俺のノートPCの脇に置いてあった缶コーヒーをつかみ、そのまま自分の席に戻った。プシュ、とプルタブを開け、一口飲んで、またキーボードに向かう。

「……え、俺のコーヒー?」

ナナコが申し訳なさそうに小声で言った。「すみません。先生は手の届くところにあるものは自分のものだと思うフシがありまして……。新しいのお持ちしますね」

先生は満足げな——かどうかは読めないが——無表情のまま、缶コーヒーを啜りながらコーディングを再開した。

……まあ、いいか。

排毒

先生が最後に書いたのは、新しい CouponEngine だった。eval は完全に消え、代わりに RuleParser と 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
package ClothCraft::CouponEngine;
use v5.36;
use Carp qw(croak);
use ClothCraft::RuleParser;

sub new ($class) {
    my $parser = ClothCraft::RuleParser->new;
    return bless {
        rules  => [],
        parser => $parser,
    }, $class;
}

sub add_rule ($self, %rule) {
    my $expr = $self->{parser}->parse($rule{condition});
    push $self->{rules}->@*, {
        name     => $rule{name},
        expr     => $expr,
        discount => $rule{discount},
    };
    return $self;
}

sub evaluate ($self, $context) {
    my @applicable;
    for my $rule ($self->{rules}->@*) {
        if ($rule->{expr}->interpret($context)) {
            push @applicable, {
                name     => $rule->{name},
                discount => $rule->{discount},
            };
        }
    }
    return \@applicable;
}

1;

「……排毒完了」

先生がそう呟いた瞬間、俺は画面を見つめた。add_rule の中にある $self->{parser}->parse(...) が目に入る。ルール文字列は登録時にパースされ、Expression ツリーに変換される。もし文字列がおかしければ、この時点でエラーになる。evaluate は純粋にツリーを interpret するだけだ。

「消えた……」俺は呟いた。「eval が、消えた」

「大丈夫ですよ」ナナコが微笑んだ。「もっと安全で、構造が分かる治療法に変わったんです。試しに不正な式を入れてみましょうか」

先生が端末に壊れたルール文字列を入力した。パーサーが即座にエラーを投げた。

1
Unexpected token at position 3: '>==='

「前は黙って飲み込んでたのに……ちゃんとエラーになる」

先生が小さく頷いた——ように見えた。


術後経過

先生がテストを走らせた。俺が管理画面から登録していた46件のルール——同等の条件式をすべて新しいエンジンに移行し、同じコンテキストで評価する。

画面にテスト結果が流れた。

1
2
3
4
5
6
7
ok 1 - ゴールド会員8000円で1ルール適用
ok 2 - 正しいルールが適用
ok 3 - 割引額500円
...
ok 24 - 不正なルールは登録時にエラーになる
...
1..27

「全部……通ってる」

27件のテスト、全パス。eval 時代と同じ結果が、eval なしで実現されている。

「新しい演算子を追加してみましょうか」ナナコが俺に向かって言った。「先生、 CONTAINS はどうですか?」

先生は無言で新しいクラスを書き始めた。ファイルを一つ追加し、パーサーのトークナイザに一語追加しただけ。10行にも満たない変更。既存の Expression クラスには一切手を入れていない。

「……追加完了」

「え、これだけ? 既存のコード触ってないっすよね」

「新しいお薬を棚に追加しただけですよ。他のお薬には影響しません」ナナコが穏やかに言った。

俺は画面を見つめた。eval 時代なら、正規表現に1行追加するだけで済んだ。確かにそっちのほうが短い。でも——

「eval のほうが短かったっすけど……こっちのほうが何やってるか分かる」

先生が席を立った。奥の部屋に向かって歩き始めた。

「コミットメント」

「先生の治療費はテストコードです」ナナコが振り返って言った。「新しいルールを追加するたびに、テストを書いてくださいね」

「テスト……ああ、eval 時代はそもそもテスト書けなかったっすもんね。文字列をそのまま eval に突っ込んでたから、ユニットテストの書きようがなくて」

先生は奥の部屋のドアに手をかけた。振り返らない。

「感謝は、このコードに」

ドアが閉まった。乾いた打鍵音が、ドアの向こうから微かに聞こえた。もう次の仕事に取りかかっているのだろう。

ナナコが俺の方に向き直り、にっこり笑った。

「おだいじに。監査の再試験、きっとうまくいきますよ」

「……ありがとうございます」

俺はノートPCを閉じ、鞄に入れて立ち上がった。O’Reilly本の山を避けながら入口に向かい、鉄の扉を押して廊下に出た。

リノリウムの床が蛍光灯の光を反射している。来た時と同じ景色のはずなのに、少しだけ明るく見えた。

——悔しいけど、俺の「最強の武器」は eval じゃなかった。

エレベーターのボタンを押しながら、俺は頭の中で Expression クラスのツリー構造を描いていた。AND の下に Comparison がぶら下がり、その末端に VariableLiteral がある。CTスキャンの画像だ。自分のシステムの内臓が、初めて見えた気がした。

明日、まずテストを書こう。46件のルール全部に。


処方箋まとめ

症状適用すべき経過観察
文字列で構成された条件式を動的に評価したい
eval / exec で文字列を直接実行している
ルール追加にコード変更が必要になっている
条件式のネスト(括弧)に対応できていない
固定的な if/else 分岐が5個以下で済んでいる
DSLの文法が今後拡張される見込みがない

治療のステップ

  1. Expression 基底クラスの定義interpret($context) インターフェースを持つ共通の親クラスを作成する
  2. 終端式(Terminal Expression)の実装Literal(リテラル値)、Variable(変数参照)、Comparison(比較演算)など、これ以上分解できない最小単位の式を実装する
  3. 非終端式(NonTerminal Expression)の実装AndOrNot など、他の Expression を子に持つ複合式を実装する
  4. パーサーの実装 — ルール文字列をトークンに分解し、Expression オブジェクトのツリー(構文木)に変換するパーサーを作成する
  5. eval の除去と置換 — 既存の eval 呼び出しを、パーサー + interpret() の呼び出しに置き換える
  6. テストの記述 — 各 Expression クラスの単体テスト、パーサーのテスト、統合テストを記述する

助手より

eval は確かに万能薬のように見えますよね。「何でも評価できる」という力は魅力的です。でも、その力がそのままリスクでもあるんです。

Interpreter パターンは、eval の「柔軟さ」を捨てずに「安全性」を取り戻す治療法です。式をオブジェクトのツリーとして表現することで、何が評価されているかが見えるようになります。CTスキャンのように。

桐山さんの2年間は無駄ではありません。あのルール設計があったからこそ、新しいエンジンにスムーズに移行できたんですよ。これからはテストという心強い味方と一緒に、もっと安全なシステムを育ててくださいね。

——ナナコ

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