【実務・中級編】VB.NETで作る独自の簡易数式計算エンジン:DataTable.Computeのセキュリティリスクとパフォーマンス限界を突破する安全な構文解析パーサーの自社開発 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク
スポンサーリンク

「DataTable.Compute」という甘い罠を捨てろ:堅牢な数式エンジンを自作するアーキテクチャ設計論

業務アプリケーションの開発において、我々は常に「手軽さ」という誘惑に直面する。

例えば、ユーザーが入力した「(Price * Tax) + Shipping」といった動的な計算式を処理したいとき、多くの開発者は `DataTable.Compute` メソッドを呼び出す。これは一見、非常に効率的で賢明な選択に見えるだろう。なぜなら、追加のコードを書く必要がなく、標準機能だけで完結するからだ。

しかし、プロフェッショナルなアーキテクトの視点から言わせてもらえば、それは設計の放棄である。

本稿では、なぜ `DataTable.Compute` がプロダクション環境において「技術的負債」となり得るのかを論理的に解明し、その限界を突破するための「独自の数式解析エンジン(パーサー)」をVB.NETで構築する極限の設計手法を伝授する。

1. なぜ `DataTable.Compute` では不十分なのか

まず、君たちが直面している問題の正体を明確にしよう。`DataTable.Compute` には、致命的な3つの弱点がある。

① セキュリティリスク:式インジェクションの脅威

`DataTable.Compute` は、渡された文字列をそのまま式として解釈する。もし、ユーザー入力が適切にバリデーションされずにこのメソッドに渡された場合、意図しない計算ロジックの実行や、アプリケーションのクラッシュを招く「式インジェクション」の脆弱性となり得る。ブラックボックス化された評価エンジンは、制御不能な挙動の温床だ。

② パフォーマンスの限界:オーバーヘッドの蓄積

`DataTable.Compute` は、内部的に `DataRow` のコンテキストを介して動的に型を解決し、評価を行う。数千、数万件のレコードに対してループ内でこれを呼び出す設計は、CPUサイクルを無駄に浪費し、UIスレッドを凍結させる原因となる。

③ 拡張性の欠如:独自の関数が書けない

「社内の特殊な計算ロジック(例:営業日計算、特定の税率ロジック)」を組み込みたいと思った瞬間、`DataTable.Compute` は行き詰まる。標準的な算術演算以外のロジックを注入するには、結局、外側で複雑な条件分岐を書くことになり、コードの可読性は著しく低下する。

2. 目指すべきアーキテクチャ:再帰下降構文解析 (Recursive Descent Parsing)

我々が構築すべきは、単なる「文字列置換」ではない。字句解析 (Lexer) と 構文解析 (Parser) を分離した、堅牢な計算エンジンである。

設計のステップは以下の通りだ:
1. Tokenization (字句解析): 文字列を「数値」「演算子」「変数」「括弧」といった意味のある最小単位(Token)に分解する。
2. Parsing & Evaluation (構文解析と評価): 演算子の優先順位(乗算は加算より先など)を考慮しながら、トークン列を解析し、計算結果を導き出す。

この手法を採用することで、計算式の「どこが間違っているか」を正確に特定でき、かつ独自の関数を容易に追加できる構造が手に入る。

3. 実装:プロダクション・グレードの数式エンジン

以下に、外部ライブラリを一切使わず、変数(パラメータ)の注入にも対応した、堅牢な数式パーサーの完全な実装を示す。これをそのままプロジェクトのコアモジュールとして組み込めるレベルまで仕上げている。

''' <summary>
''' 堅牢な数式計算エンジン
''' </summary>
Public Class FormulaEngine
    ' トークンの種類を定義
    Private Enum TokenType
        Number
        Plus
        Minus
        Multiply
        Divide
        LeftParenthesis
        RightParenthesis
        Variable
        EndOfFile
    End Enum

    ' トークン構造体
    Private Structure Token
        Public Property Type As TokenType
        Public Property Value As String
        Public Property NumberValue As Double
    End Structure

    Private _tokens As New List(Of Token)
    Private _position As Integer
    Private _variables As Dictionary(Of String, Double)

    ''' <summary>
    ''' 式を評価する
    ''' </summary>
    ''' <param name="expression">計算式 (例: "(price * 1.1) + tax")</param>
    ''' <param name="variables">使用する変数辞書</param>
    ''' <returns>計算結果</returns>
    Public Function Evaluate(expression As String, variables As Dictionary(Of String, Double)) As Double
        _variables = variables
        _tokens.Clear()
        _position = 0

        ' 1. 字句解析 (Lexer)
        Tokenize(expression)

        ' 2. 構文解析と評価 (Parser)
        Return ParseExpression()
    End Function

    ''' <summary>
    ''' 文字列をトークンに分解する
    ''' </summary>
    Private Sub Tokenize(input As String)
        Dim i As Integer = 0
        While i < input.Length
            Dim c As Char = input(i)

            If Char.IsWhiteSpace(c) Then
                i += 1
            ElseIf Char.IsDigit(c) OrElse c = "." Then
                ' 数値の抽出
                Dim start As Integer = i
                While i < input.Length AndAlso (Char.IsDigit(input(i)) OrElse input(i) = ".")
                    i += 1
                End While
                Dim val As String = input.Substring(start, i - start)
                _tokens.Add(New Token With {.Type = TokenType.Number, .Value = val, .NumberValue = Double.Parse(val)})
            ElseIf Char.IsLetter(c) Then
                ' 変数の抽出
                Dim start As Integer = i
                While i < input.Length AndAlso Char.IsLetterOrDigit(input(i))
                    i += 1
                End While
                Dim val As String = input.Substring(start, i - start)
                _tokens.Add(New Token With {.Type = TokenType.Variable, .Value = val})
            Else
                ' 演算子の抽出
                Select Case c
                    Case "+" : _tokens.Add(New Token With {.Type = TokenType.Plus, .Value = "+"})
                    Case "-" : _tokens.Add(New Token With {.Type = TokenType.Minus, .Value = "-"})
                    Case "*" : _tokens.Add(New Token With {.Type = TokenType.Multiply, .Value = "*"})
                    Case "/" : _tokens.Add(New Token With {.Type = TokenType.Divide, .Value = "/"})
                    Case "(" : _tokens.Add(New Token With {.Type = TokenType.LeftParenthesis, .Value = "("})
                    Case ")" : _tokens.Add(New Token With {.Type = TokenType.RightParenthesis, .Value = ")"})
                    Case Else
                        Throw New Exception($"不正な文字を検出しました: {c}")
                End Select
                i += 1
            End If
        End While
        _tokens.Add(New Token With {.Type = TokenType.EndOfFile})
    End Sub

    ' --- 再帰下降パーサーのコアロジック ---

    ' 式 (Expression): 加減算
    Private Function ParseExpression() As Double
        Dim result As Double = ParseTerm()

        While _tokens(_position).Type = TokenType.Plus OrElse _tokens(_position).Type = TokenType.Minus
            Dim op = _tokens(_position).Type
            _position += 1
            Dim right = ParseTerm()
            If op = TokenType.Plus Then result += right Else result -= right
        End While

        Return result
    End Function

    ' 項 (Term): 乗除算
    Private Function ParseTerm() As Double
        Dim result As Double = ParseFactor()

        While _tokens(_position).Type = TokenType.Multiply OrElse _tokens(_position).Type = TokenType.Divide
            Dim op = _tokens(_position).Type
            _position += 1
            Dim right = ParseFactor()
            If op = TokenType.Multiply Then
                result *= right
            Else
                If right = 0 Then Throw New DivideByZeroException("ゼロ除算が発生しました。")
                result /= right
            End If
        End While

        Return result
    End Function

    ' 因子 (Factor): 数値、変数、括弧
    Private Function ParseFactor() As Double
        Dim token = _tokens(_position)

        Select Case token.Type
            Case TokenType.Number
                _position += 1
                Return token.NumberValue

            Case TokenType.Variable
                _position += 1
                If _variables.ContainsKey(token.Value) Then
                    Return _variables(token.Value)
                Else
                    Throw New Exception($"未定義の変数です: {token.Value}")
                End If

            Case TokenType.LeftParenthesis
                _position += 1
                Dim result = ParseExpression()
                If _tokens(_position).Type <> TokenType.RightParenthesis Then
                    Throw New Exception("括弧の閉じ忘れを検出しました。")
                End If
                _position += 1
                Return result

            Case Else
                Throw New Exception($"構文エラー: 期待されていないトークン '{token.Value}' が見つかりました。")
        End Select
    End Function
End Class

実装のポイント解説:

1. 再帰下降構文解析の採用: `ParseExpression` $\rightarrow$ `ParseTerm` $\rightarrow$ `ParseFactor` という階層構造にすることで、数学的な演算優先順位(括弧 $>$ 乗除 $>$ 加減)を自然に、かつ完璧に処理している。
2. 変数注入の安全性: `Dictionary(Of String, Double)` を通じて外部から値を注入する設計にしている。これにより、式自体はテンプレートとして固定し、値だけを動的に変えることが可能。これはDBのパラメータとして扱う際に極めて安全だ。
3. 厳格なエラーハンドリング: 「未知の変数」「ゼロ除算」「括弧の不整合」など、業務アプリで発生しうる異常系を、明確な例外として投げている。これにより、ユーザーへのエラー表示も容易になる。

4. 実務への統合:DB・ファイル連携時の注意点

このエンジンを実際の業務アプリに組み込む際、設計者は以下の3点に留意しなければならない。

① パラメータの型管理

DBから取得した値が `Decimal` や `Integer` の場合、必ず `Double`(あるいは精度を極限まで求めるなら `Decimal`)にキャストして辞書に格納すること。型変換の不備は、計算誤差という目に見えないバグを生む。

② 大量データ処理の最適化

もし、数万件のデータに対して同じ数式を適用する場合、毎回 `Evaluate` を呼ぶのではなく、「式の解析結果(AST)」をキャッシュする設計を検討せよ。今回のコードはシンプルにするため毎回解析しているが、究極のパフォーマンスを求めるなら、解析済みのツリー構造を保持し、値の注入のみを行うフェーズを分けるべきだ。

③ バリデーションの二重化

ユーザーがUI上で式を入力する場合、エンジンに投げる前に「使用可能な変数リスト」に基づいた簡易的な正規表現チェックを行うこと。これにより、エンジンに到達する前に不正な入力を弾くことができ、システムの堅牢性が一層高まる。

結論

`DataTable.Compute` は「動けばいい」という段階のプロトタイプには適している。しかし、業務の根幹を支える「計算ロジック」を委ねるには、あまりにも不透明で、リスクが高い。

自前でパーサーを実装することは、一見遠回りに見えるかもしれない。しかし、「制御権をコードの手に取り戻す」ことこそが、バグを最小限に抑え、将来の拡張性に耐えうる、真にプロフェッショナルなエンジニアリングの姿である。

この知識を武器に、単なる「ツール作成者」から、信頼される「システムアーキテクト」へと昇華してほしい。

タイトルとURLをコピーしました