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

スポンサーリンク

VB.NETで作る独自の簡易数式計算エンジン:DataTable.Computeの限界を突破する安全な構文解析パーサーの自社開発

業務システムを開発していると、ユーザーがマスタ画面や設定ファイルから「自由な数式(例: `[単価] [数量] 1.1` や `IF([在庫] > 10, [単価] 0.9, [単価])`)」を入力し、それを動的に評価して計算したいという要件に必ず直面する。

この時、多くの開発者が飛びつくのが `System.Data.DataTable.Compute` メソッドだ。
「たった1行で数式評価ができるから便利だ」と。

だが、それはプロのアーキテクトとしては悪手と言わざるを得ない。

`DataTable.Compute` は、内部で .NET の旧態依然とした式評価エンジンを呼び出しているため、以下のような致命的な実務上の問題がある。

1. パフォーマンスの深刻な劣化: ループ内で大量に呼び出すと、リフレクションや内部コンパイルのオーバーヘッドでアプリがフリーズ寸前になる。
2. SQLインジェクションならぬ「Computeインジェクション」: 特殊文字や予期せぬ文字列が混入した際、不意の例外(`SyntaxErrorException`)で容赦なくプロセスがクラッシュする。
3. 機能の圧倒的な不足: 独自関数の定義、三項演算子、安全なNull伝播などが一切使えず、業務要件を満たせない。

今回は、この `DataTable.Compute` の呪縛を断ち切り、自社システムに組み込んで高速かつ安全に動作する「簡易数式計算エンジン(構文解析パーサー)」の設計と実装を、チーフアーキテクトの私から直々に授けよう。

—

1. 堅牢な計算エンジンを支えるアーキテクチャ

自社製パーサーを作るというと、コンパイラ理論の難解な「抽象構文木 (AST)」や「レキサー(字句解析)」の教科書を引っ張り出したくなるかもしれない。だが、業務アプリに必要なのは「軽快で、保守しやすく、落ちない」ことだ。

今回は、実務で最も手堅く、かつ拡張性が高い「再帰下降パーサー(Recursive Descent Parser)」を採用する。

処理の流れ

1. 字句解析 (Lexer / Tokenizer): 入力された数式文字列を、「数値」「演算子」「変数」「括弧」などのトークンに分解する。
2. 構文解析 (Parser): トークンの並びを監視し、演算子の優先順位(乗除算が加減算より先など)を考慮しながら評価ツリーを構築する。
3. 評価 (Evaluator): 実行時の変数コンテキスト(キーバリューのディクショナリ)を流し込み、最終的な計算結果を得る。

—

2. プロダクションコード:セキュア簡易数式パーサー

以下のコードは、Visual Basic .NET (VB.NET) でそのままプロダクション環境に投入できる、依存関係なしの完全な自社製計算エンジンである。

Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.Text

Namespace Enterprise.Calculations

‘ =================================================================
‘ トークンの種類
‘ =================================================================
Friend Enum TokenType
Number
Identifier ‘ 変数名 ([単価] など)
Plus
Minus
Multiply
Divide
LeftParen
RightParen
[End]
End Enum

‘ =================================================================
‘ トークン構造体
‘ =================================================================
Friend Structure Token
Public Property Type As TokenType
Public Property Value As String

Public Sub New(type As TokenType, value As String)
Me.Type = type
Me.Value = value
End Sub
End Structure

‘ =================================================================
‘ 簡易数式計算エンジン (メインクラス)
‘ =================================================================
Public Class ExpressionEngine

”’

”’ 数式を安全に評価します。
”’

”’ 数式文字列 (例: “[単価] [数量]”) ”’ 変数コンテキスト ”’ 計算結果
Public Shared Function Evaluate(expression As String, variables As Dictionary(Of String, Decimal)) As Decimal
If String.IsNullOrWhiteSpace(expression) Then Return 0D

Dim tokens = Tokenize(expression)
Dim parser As New Parser(tokens, variables)
Return parser.Parse()
End Function

‘ ————————————————————-
‘ 1. 字句解析 (Lexer)
‘ ————————————————————-
Private Shared Function Tokenize(input As String) As List(Of Token)
Dim tokens As New List(Of Token)()
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 = "."c Then ' 数値の切り出し Dim sb As New StringBuilder() While i < input.Length AndAlso (Char.IsDigit(input(i)) OrElse input(i) = "."c) sb.Append(input(i)) i += 1 End While tokens.Add(New Token(TokenType.Number, sb.ToString())) ElseIf c = "["c Then ' 変数名の切り出し ([変数名]) i += 1 Dim sb As New StringBuilder() While i < input.Length AndAlso input(i) <> “]”c
sb.Append(input(i))
i += 1
End While
If i < input.Length Then i += "]"にヒットしたのでスキップ i += 1 tokens.Add(New Token(TokenType.Identifier, sb.ToString())) Else ' 演算子と括弧 Select Case c Case "+"c : tokens.Add(New Token(TokenType.Plus, "+")) Case "-"c : tokens.Add(New Token(TokenType.Minus, "-")) Case ""c : tokens.Add(New Token(TokenType.Multiply, "")) Case "/"c : tokens.Add(New Token(TokenType.Divide, "/")) Case "("c : tokens.Add(New Token(TokenType.LeftParen, "(")) Case ")"c : tokens.Add(New Token(TokenType.RightParen, ")")) Case Else Throw New ArgumentException($"未定義の文字が含まれています: {c} (位置: {i})") End Select i += 1 End If End While tokens.Add(New Token(TokenType.[End], "")) Return tokens End Function End Class ' ================================================================= ' 2. 構文解析・評価エンジン (Parser / Evaluator) ' 演算子優先順位: 括弧 -> 乗除 -> 加減
‘ =================================================================
Friend Class Parser
Private ReadOnly _tokens As List(Of Token)
Private ReadOnly _variables As Dictionary(Of String, Decimal)
Private _position As Integer

Public Sub New(tokens As List(Of Token), variables As Dictionary(Of String, Decimal))
_tokens = tokens
_variables = variables
_position = 0
End Sub

Private Function CurrentToken() As Token
If _position >= _tokens.Count Then
Return _tokens(_tokens.Count – 1)
End If
Return _tokens(_position)
End Function

Private Function Consume(type As TokenType) As Token
Dim token = CurrentToken()
If token.Type <> type Then
Throw New InvalidOperationException($”構文エラー: 期待されたトークン {type} ですが、{token.Type} が検出されました。”)
End If
_position += 1
Return token
End Function

Public Function Parse() As Decimal
Dim result = ParseExpression()
If CurrentToken().Type <> TokenType.[End] Then
Throw New InvalidOperationException(“数式の構文が正しくありません。”)
End If
Return result
End Function

‘ 加算・減算 (優先順位 低)
Private Function ParseExpression() As Decimal
Dim value = ParseTerm()

While CurrentToken().Type = TokenType.Plus OrElse CurrentToken().Type = TokenType.Minus
Dim op = CurrentToken()
If op.Type = TokenType.Plus Then
Consume(TokenType.Plus)
value += ParseTerm()
Else
Consume(TokenType.Minus)
value -= ParseTerm()
End If
End While

Return value
End Function

‘ 乗算・除算 (優先順位 高)
Private Function ParseTerm() As Decimal
Dim value = ParseFactor()

While CurrentToken().Type = TokenType.Multiply OrElse CurrentToken().Type = TokenType.Divide
Dim op = CurrentToken()
If op.Type = TokenType.Multiply Then
Consume(TokenType.Multiply)
value = ParseFactor()
Else
Consume(TokenType.Divide)
Dim divisor = ParseFactor()
If divisor = 0D Then
Throw New DivideByZeroException(“計算式内でゼロ除算が発生しました。”)
End If
value /= divisor
End If
End While

Return value
End Function

‘ 因子 (数値、変数、括弧付き式)
Private Function ParseFactor() As Decimal
Dim token = CurrentToken()

If token.Type = TokenType.Number Then
Consume(TokenType.Number)
Return Decimal.Parse(token.Value, CultureInfo.InvariantCulture)

ElseIf token.Type = TokenType.Identifier Then
Consume(TokenType.Identifier)
If _variables IsNot Nothing AndAlso _variables.ContainsKey(token.Value) Then
Return _variables(token.Value)
Else
‘ 変数が未定義の場合は安全のため 0 または例外
Throw New KeyNotFoundException($”計算式内の変数 ‘[{token.Value}]’ がコンテキストに見つかりません。”)
End If

ElseIf token.Type = TokenType.LeftParen Then
Consume(TokenType.LeftParen)
Dim value = ParseExpression()
Consume(TokenType.RightParen)
Return value

Else
Throw New InvalidOperationException($”予期しないトークンです: {token.Value}”)
End If
End Function
End Class

End Namespace

—

3. 実務でこのエンジンを使いこなすための設計指針

この自社製エンジンを実際のWindows Formsアプリケーションやバックエンドのバッチ処理に組み込む際、以下のポイントを遵守してほしい。

A. データベース・ファイル連携時の注意点

マスタデータ(例: `ProductMaster` テーブル)に数式を保存する場合、カラム型は必ず `NVARCHAR(MAX)` や `VARCHAR(MAX)` など十分な長さを確保すること。
また、DBから取得した数式文字列は、アプリケーション起動時やマスタ画面のキャッシュ時に一度バリデーション(構文チェック)を通しておくこと。 ユーザーがフォームに入力した瞬間、あるいは画面の「保存」ボタンを押した瞬間に `ExpressionEngine.Evaluate` のテスト実行(ダミー変数を渡す)を行い、構文エラーを早期検知させるのがプロの作法だ。

B. パフォーマンスの最適化

`DataTable.Compute` と違い、リフレクションを一切使わず純粋なメモリ上のトークン走査と算術演算(`Decimal` 型による高精度演算)を行っているため、数千件のレコードを一括計算するようなバッチ処理であっても、CPUを無駄にスパイクさせない。
さらに速度が必要な場合は、パース結果(`List(Of Token)`)をメモリ上のキャッシュ(`Dictionary(Of String, List(Of Token))` など)に保持し、字句解析のコストすらスキップする「プリコンパイル機構」に拡張することも容易だ。

C. 堅牢性(セキュリティと例外ハンドリング)

ユーザーが自由に数式を書けるシステムで最も恐ろしいのは、無限ループやメモリ枯渇、そしてゼロ除算によるアプリ異常終了だ。
上記のコードでは `DivideByZeroException` や `KeyNotFoundException` を明示的にキャッチできるよう設計しているため、UI層では以下のようにスマートにハンドリングできる。

‘ UI層やビジネスロジック層での呼び出し例
Private Sub CalculateOrder()
Try
Dim vars As New Dictionary(Of String, Decimal) From {
{“単価”, 1500D},
{“数量”, 10D}
}

Dim formula As String = “[単価] [数量] 1.1” ‘ DBから取得した数式
Dim result As Decimal = ExpressionEngine.Evaluate(formula, vars)

lblTotal.Text = result.ToString(“N0″)

Catch ex As Exception
‘ ユーザーフレンドリーなエラーメッセージに変換して通知
MessageBox.Show($”数式の計算に失敗しました。設定を確認してください。{Environment.NewLine}{ex.Message}”,
“計算エラー”, MessageBoxButtons.OK, MessageBoxIcon.Warning)
End Try
End Sub

—

最後に:安易な外部ライブラリや標準機能への依存からの脱却

「たかが計算式」と侮り、脆弱な標準機能に頼ったり、巨大で複雑なサードパーティ製スクリプトエンジンを導入してアプリのフットプリントを肥大化させたりする時代は終わった。

今回紹介した自社製パーサーは、コード量こそわずかだが、「オブジェクトのライフサイクル」「例外の制御」「予期せぬ入力への耐性」という、プロの業務アプリケーション開発者が最も気をつべき要素が凝縮されている。

このエンジンをベースに、必要に応じて `IF` 関数や `ROUND` 関数などの独自関数ハンドラを追加していけば、どんな巨大な基幹システム要求をも軽々とクリアする強力な武器となるはずだ。
あなたのプロジェクトの生産性と堅牢性を、ぜひこのコードで次のステージへと引き上げてほしい。

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