0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tsurugi の UDF で GraalVM スクリプトを実行する(続編)

0
Last updated at Posted at 2026-02-23

Tsurugi の UDF で GraalVM スクリプトを実行する(続編)

前回の記事では、Tsurugi の UDF を Spring Boot と gRPC で実装し、日付・時刻のコンポーネント取得関数(年・月・日・曜日・週番号など)を SQL から利用できるようにしました。今回はその続編として、GraalVM 上で Python および JavaScript のスクリプトを実行し、その結果を Tsurugi の UDF として返す gRPC サーバを実装した事例を紹介します。SQL だけでは式が長くなりがちな計算や、正規表現・文字列操作をスクリプトに任せることで、柔軟な UDF を同じ Tsurugi UDF の仕組みの上に載せています。

背景

前回の UDF の位置づけ

前回実装した日付・時刻コンポーネント取得 UDF は、固定の RPC と戻り値型を持つものでした。必要な関数を .proto に列挙し、各 RPC ごとに Java でロジックを実装する形です。汎用的な「スクリプトを渡して結果を返す」というニーズには向いていません。

スクリプト実行 UDF の動機

Tsurugi の標準 SQL 関数だけでは、次のような処理を簡潔に書くのが難しい場合があります。

  • 単位付き文字列(例: "123.45cm")から数値部分だけを取り出す
  • カンマ区切り文字列の N 番目の要素を取得する
  • 正規表現でマッチした部分だけを返す

こうした処理を SQL の UDF として 使いたい場合、いちいち「専用 RPC」を .proto に追加して Java で実装するよりも、スクリプト本文と引数を渡し、実行結果を返す 汎用 UDF があると便利です。GraalVM の Polyglot API を使えば、同一 JVM 上で Python(GraalPython)と JavaScript(GraalJS)を安全に実行できるため、これを gRPC サーバの背後で動かす形にしました。

Python や node.js により直接 gRPC サーバを実装し、eval()などで与えられたスクリプトを実行する方式も検討しましたが、セキュリティ上の理由でスクリプト内からファイルIOやOSへのアクセスを禁止することが難しかったため、採用を見送りました。このツールでも allow-ioallow-native-access が設定可能ですが、完全に信用できる環境でのみ許可してください。

Tsurugi UDF の制約の再確認

前回と同様、Tsurugi UDF の制約に従います。

  • 各 RPC のレスポンスは「1 フィールドのみの message」にする
  • Unary RPC で、Tsurugi の UDF プラグインが gRPC クライアントとして本サーバに接続する

今回は日付・時刻型(tsurugi_types.proto)は使わず、script(文字列)と args(文字列) を受け取り、Int64 / Double / String のいずれか 1 つを返す 6 本の RPC を定義しています。

開発手順

全体の流れ

以下の順に開発を進めます。

  1. 要件と設計
  2. gRPC 定義(.proto)を作成
  3. Spring Boot で gRPC サーバを実装(GraalVM Polyglot でスクリプト実行)
  4. 実装した gRPC サーバを起動
  5. udf-plugin-builder で UDF プラグイン(.so / .ini)を生成
  6. Tsurugi にプラグインを配置して起動
  7. SQL から UDF を呼び出して動作確認

1. 要件と設計

要件は doc/user-requirements.md および doc/system-requirements.md に、設計は doc/system-design.md にまとめています。主なポイントは次のとおりです。

  • アーキテクチャ: Tsurugi の UDF プラグインが gRPC クライアントとなり、本アプリ(gRPC サーバ)に Unary RPC でリクエストを送る。サーバ側で GraalVM Polyglot により Python または JavaScript を実行し、戻り値を UDF の結果として返す。
  • Tsurugi UDF の制約: レスポンスは 1 フィールドのみの message(Int64Value / DoubleValue / StringValue)とする。
  • リクエスト: ExecuteScriptRequestscript(実行するコード)と args(JSON 配列の文字列)を受け取る。スクリプト内では args をパースした配列を変数 args としてバインドする。
  • セキュリティ: リクエストごとに Polyglot Context を新規作成し、デフォルトでは allowIO / allowNativeAccess は false とする。

構成

構成は前回の日付・時刻 UDF と同様、Tsurugi と gRPC サーバが分離された形です。今回のサーバは内部で GraalVM の Python/JavaScript エンジンを利用します。

構成図

モジュール 説明
Tsurugi DB データベース
libgraalservice.ini UDF 設定ファイル(エンドポイント等)
libgraalservice.so UDF 共有ライブラリ
tsurugi-udf-graal1 gRPC サーバ(Spring Boot + GraalVM)

2. gRPC 定義(.proto)を作成

.proto では、Python 用・JavaScript 用に Int64 / Double / String の 3 型 × 2 言語で、合計 6 個の RPC を定義しています。

syntax = "proto3";

package tsurugi_udf_graal1;

option java_package = "jp.septigram.tsurugi.udf.graal1";
option java_multiple_files = true;

// Tsurugi UDF 向け gRPC サービス定義
// 単一 service ブロック、Unary RPC のみ、レスポンスはフィールド 1 つの message
service GraalService {
  rpc ExecutePyInt64(ExecuteScriptRequest) returns (Int64Value) {}
  rpc ExecutePyDouble(ExecuteScriptRequest) returns (DoubleValue) {}
  rpc ExecutePyString(ExecuteScriptRequest) returns (StringValue) {}
  rpc ExecuteJsInt64(ExecuteScriptRequest) returns (Int64Value) {}
  rpc ExecuteJsDouble(ExecuteScriptRequest) returns (DoubleValue) {}
  rpc ExecuteJsString(ExecuteScriptRequest) returns (StringValue) {}
}

message ExecuteScriptRequest {
  string script = 1;
  string args = 2;
}

message Int64Value {
  int64 value = 1;
}

message DoubleValue {
  double value = 1;
}

message StringValue {
  string value = 1;
}

3. Spring Boot で gRPC サーバを実装(GraalVM Polyglot でスクリプト実行)

  • 技術スタック: Java 17、Spring Boot 3.x、Gradle 8.x、grpc-spring-boot-starter、GraalVM(GraalPython / GraalJS)、Protocol Buffers。
  • プロジェクト構成:
    • config: gRPC サーバのポート(デフォルト 50051)と Netty の起動・シャットダウン。
    • graal: GraalVM の Polyglot Context をリクエストごとに作成し、scriptargs を渡して Python または JavaScript を実行。戻り値を Int64 / Double / String に変換して返す。スクリプト構文エラー・実行時エラー・args の JSON パースエラーは gRPC の Status.INTERNAL で返却する。
    • grpc: 生成された gRPC の Service 基底クラスを継承し、各 RPC で graal パッケージのスクリプト実行を呼び出す。

4. 実装した gRPC サーバを起動

ビルドは ./gradlew clean build、起動は ./gradlew bootRun または java -jar build/libs/tsurugi-udf-graal1-*.jar で行います。GraalPython / GraalJS は GraalVM JDK・Oracle JDK・OpenJDK のいずれでも Maven 依存(org.graalvm.polyglot 等)により利用可能ですが、標準の OpenJDK では Graal コンパイラが使えずパフォーマンスが大きく劣るため、本格利用では GraalVM の利用を推奨します。

5. udf-plugin-builder で UDF プラグイン(.so / .ini)を生成

Tsurugi 側で UDF を利用するには、本プロジェクトの .proto を指定して udf-plugin-builder でプラグインを生成します。今回は tsurugi_types.proto を import していないため、本アプリ用の .proto のみでプラグイン化します。

udf-plugin-builder \
  --proto-file src/main/proto/graalservice.proto \
  --proto-path src/main/proto \
  --name graalservice \
  --grpc-endpoint "dns:///localhost:50052" \
  --output-dir ./build/udf-plugin

生成した libgraalservice.solibgraalservice.ini を Tsurugi のプラグイン配置ディレクトリ(${TSURUGI_HOME}/var/plugins/)に置き、Tsurugi を起動したうえで本アプリを起動すると、SQL から UDF を呼び出せます。手順の詳細は doc/graal-service-usage.md を参照してください。

6. Tsurugi にプラグインを配置して起動

生成したプラグイン(lib*.so と lib*.ini)を $TSURUGI_HOME/var/plugins/ (デフォルトの場合)に配置して、Tsurugi を起動します。

$ tgctl start

7. SQL から UDF を呼び出して動作確認

gRPC サーバの起動確認に加え、UDF プラグインを Tsurugi にデプロイし、tgsql から 6 種類の UDF を実行して期待値と一致することを確認しています。呼び出し例は doc/test-all.sql、結果サマリは doc/test-result.md にあります。

利用例

tgsql で UDF を呼び出す例です。FROM 句にテーブル(ここでは t1)が必要です。

整数の加算Pythonスクリプト

ExecutePyInt64: Python でスクリプトを実行し、整数を返す。args は JSON 配列文字列で、スクリプト内では args 変数としてバインドされる。

SELECT ExecutePyInt64('int(args[0]) + int(args[1])', '[10, 20]') FROM t1
...
[@#0: LONG]
[30]
(1 row)

文字列を浮動小数点数で演算するPythonスクリプト

ExecutePyDouble: Python で実行し、浮動小数点数を返す。

SELECT ExecutePyDouble('float(args[0]) * 2.0', '["25"]') FROM t1
...
[@#0: DOUBLE]
[50.0]
(1 row)

文字列を文字列として演算するJavaScriptスクリプト

ExecuteJsString: JavaScript で実行し、文字列を返す。

SELECT ExecuteJsString('args[0] + args[1]', '["Hello", " World"]') FROM t1
...
[@#0: VARCHAR]
[Hello World]
(1 row)

テーブルの列に script と args を格納しておき、SELECT ExecutePyInt64(script, args) FROM t のように列を渡すこともできます。

単位付き文字列から数値を取り出すJavaScriptスクリプト

ExecuteJsDouble: JavaScript で実行し、浮動小数点数を返す。

SELECT ExecuteJsDouble(
  'parseFloat(args[0].replace(/[^0-9.-]/g, ""))',
  '["123.45cm"]'
) FROM t1
...
[@#0: DOUBLE]
[123.45]
(1 row)

再帰関数を実行するJavaScriptスクリプト

returnで一つの値を返す必要があるので、関数表現を使う場合はコツがあります。

tgsql> SELECT ExecuteJsDouble('return (() => {
     |  const fib = (n) => { return n < 2 ? n : fib(n - 1) + fib(n - 2); };
     |  return fib(args[0]);
     | })();','[7]') FROM t1;
...
[@#0: DOUBLE]
[13.0]
(1 row)

類似した名前の法律を検索するJavaScriptスクリプト

長文のスクリプトを専用のテーブルに格納して利用する例です。UDFの結果はORDER BY句に指定できるので、文字列の類似度を計算して高い順に結果を取得します。

1. ジャロ・ウィンクラー類似度を計算する関数を作成して functions テーブルに登録する

ジャロ・ウィンクラー類似度は、人名・レコード照合・名寄せツールなど、ビジネスデータの重複検出系で利用される2つの文字列の類似度を測る指標

tgsql> CREATE TABLE functions (
  name VARCHAR(16),
  lang VARCHAR(16),
  script VARCHAR(10240),
  PRIMARY KEY (name, lang)
);
...
tgsql> INSERT OR REPLACE INTO functions VALUES
  ('jaro_winkler', 'js',
'return ((key1, key2) => {
  function jaroWinkler(s1, s2) {
    if (!s1 && !s2) return 1.0;
    if (!s1 || !s2) return 0.0;

    const p = 0.1
    const len1 = s1.length;
    const len2 = s2.length;

    const matchDistance = Math.floor(Math.max(len1, len2) / 2) - 1;

    const s1Matches = new Array(len1).fill(false);
    const s2Matches = new Array(len2).fill(false);

    let common = 0;

    // 1. 共通文字数を数える
    for (let i = 0; i < len1; i++) {
      const start = Math.max(0, i - matchDistance);
      const end = Math.min(i + matchDistance + 1, len2);

      for (let j = start; j < end; j++) {
        if (s2Matches[j]) continue;
        if (s1[i] !== s2[j]) continue;
        s1Matches[i] = true;
        s2Matches[j] = true;
        common++;
        break;
      }
    }

    if (common === 0) return 0.0;

    // 2. 転置数を数える
    let k = 0;
    let transpositions = 0;

    for (let i = 0; i < len1; i++) {
      if (!s1Matches[i]) continue;
      while (!s2Matches[k]) k++;
      if (s1[i] !== s2[k]) transpositions++;
      k++;
    }

    transpositions = Math.floor(transpositions / 2);

    // 3. Jaro 類似度
    const jaro =
      (common / len1 +
        common / len2 +
        (common - transpositions) / common) /
      3.0;

    // 4. 共通プレフィックス長(最大4)
    let prefix = 0;
    const maxPrefix = 4;
    for (let i = 0; i < Math.min(len1, len2, maxPrefix); i++) {
      if (s1[i] === s2[i]) {
        prefix++;
      } else {
        break;
      }
    }

    // 5. Winkler 補正
    return jaro + prefix * p * (1.0 - jaro);
  }
  return jaroWinkler(key1, key2);
})(args[0], args[1]);
');

2. 法律名一覧のテーブルを用意する

tgsql> CREATE TABLE IF NOT EXISTS japanese_laws (title VARCHAR(4096));
...
tgsql> SELECT title FROM japanese_laws LIMIT 10;
start transaction implicitly. option=[
  type: OCC
  label: "tgsql-implicit-transaction2026-02-27 08:16:16.341+09:00"
]
Time: 0.965 ms
[title: VARCHAR(4096)]
[ポツダム命令]
[請願法]
[旅券法]
[外国人登録法]
[国籍法]
[議院における証人の宣誓及び証言等に関する法律 (議院証言法)]
[国会職員法]
[議院法制局法]
[議院事務局法]
[政治倫理の確立のための国会議員の資産等の公開等に関する法律]
(10 rows)
Time: 6.249 ms
transaction commit(DEFAULT) finished implicitly.
Time: 4.313 ms

3. SQLから作成した関数を呼び出す

"労働者"と法律名のジャロ・ウィンクラー類似度が高いものから10個をORDER BYで指定してSELECTする。

tgsql> SELECT japanese_laws.title, ExecuteJsDouble(func.script, '["労働者","'||japanese_laws.title||'"]')
     |   FROM japanese_laws
     |     JOIN functions AS func
     |     ON func.name = 'jaro_winkler' AND func.lang = 'js'
     |   ORDER BY ExecuteJsDouble(func.script, '["労働者","'||japanese_laws.title||'"]') DESC
     |   LIMIT 10;
     |
start transaction implicitly. option=[
  type: OCC
  label: "tgsql-implicit-transaction2026-02-27 08:19:49.626+09:00"
]
Time: 0.556 ms
[title: VARCHAR(4096), @#1: DOUBLE]
[労働者派遣事業の適正な運営の確保及び派遣労働者の就業条件の整備等に関する法律 (労働者派遣法), 0.5627659574468085]
[労働審判法, 0.535]
[労働組合法, 0.535]
[労働基準法, 0.535]
[労働安全衛生法, 0.5178571428571428]
[労働関係調整法 (労調法), 0.4980769230769231]
[厚生労働省設置法, 0.4583333333333333]
[消費者契約法, 0.47222222222222215]
[障害者基本法, 0.47222222222222215]
[身体障害者福祉法, 0.4583333333333333]
(10 rows)
Time: 7,425.266 ms
transaction commit(DEFAULT) finished implicitly.
Time: 4.406 ms

パフォーマンス

結合テストの実行ログ(doc/test-result.md)から、UDF 1 呼び出しあたりの応答時間(tgsql の Time: 表示)を集計した結果を下表に示します。同一クエリの複数回実行は含めず、種別ごとの目安です。

UDF 種別 応答時間の目安 備考
ExecutePy*(Python) 約 150〜195 ms/回 GraalPython の初期化やコンテキスト作成のオーバーヘッドが主因と考えられる
ExecuteJs*(JavaScript) 約 12〜15 ms/回 Python に比べて 1 桁程度短い
構文エラー時(Python) 約 195 ms エラー検出までに要した時間
構文エラー時(JavaScript) 約 20 ms エラー検出までに要した時間

計測環境や負荷により変動するため参考値として扱ってください。大量行に対する UDF 呼び出しでは Python 系は遅くなりやすいため、軽量な計算や文字列処理のみでよい場合は JavaScript の利用を検討するとよいです。

複数の UDF サーバを実行する場合の注意点

  • Tsurugi は複数の UDF サーバを同時に利用することができますが、v1.8.0 では「tsurugidb.udf メッセージ型を含むUDF プラグインを複数デプロイすることができない」という制約があります。 Tsurugi UDF 既知の問題
  • UDF サーバを複数利用する場合、複数の .proto ファイルの定義に同じ名前は付けられません。 package 文を指定して名前空間を分離してください。
  • 同じノードで複数の UDF サーバを利用する場合は、別ポートで起動する必要があります。その場合、lib*.ini ファイルを編集して接続先を変更してください。

成果物

まとめ

前回は Tsurugi の UDF で「日付・時刻のコンポーネント取得」という固定の関数群を Spring Boot と gRPC で実装しました。今回は同じ Tsurugi UDF の仕組みの上に、GraalVM で Python/JavaScript を実行し、その結果を UDF として返す gRPC サーバを載せました。.proto ではレスポンス 1 フィールドの制約を守り、script と args を受け取る 6 本の RPC を用意することで、SQL から柔軟なスクリプト実行を呼び出せるようにしています。

結合テストでは Python 系が約 150〜195 ms/回、JavaScript 系が約 12〜15 ms/回という応答時間の目安が得られており、1 リクエストあたりのオーバーヘッドは無視できません。大量行に対する逐次呼び出しには向きませんが、正規表現や文字列操作など「SQL では書きづらいがスクリプトなら簡潔」な処理を UDF 化する用途には有効です。軽量な処理には JavaScript を選ぶと応答が速くなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?