前言
從語法特性來看,Dart 作為一門通用語言已足夠成熟,但在人工智慧與自然語言處理(NLP)領域,Dart 顯然不在第一梯隊。
為了補足生態系的缺口,我們原本希望透過 Python FFI(Foreign Function Interface)借力使力,直接調用 Python 豐富的庫。然而,在實際整合過程中,我們發現目前的 python_ffi_dart 函式庫在 API 設計上存在嚴重的耦合問題,導致其難以成為一個良好的通用 Interop(跨語言互操作)方案。
展示問題的範例程式碼
以下是一個試圖啟動 Python 實例並載入模組的 Dart 程式。在理想的 Interop 方案中,這應該是開箱即用的,但下方的範例揭露了實作上的斷層:
import 'dart:io';
import 'package:python_ffi_dart/python_ffi_dart.dart';
void main() async {
final String soPath =
"/home/user/.pyenv/versions/3.14.4/lib/libpython3.14.so";
try {
// 初始化 Python 環境
await PythonFfiDart.instance.initialize(
libPath: soPath,
pythonModules: null,
);
print('✅ Python instance initialized');
final python = PythonFfiDart.instance;
/* 下方程式碼在當前 API 設計下無法運作,因為 importModule 強制要求轉換參數 */
/*
final sys = python.importModule("sys");
final path = sys.get("path");
path.callMethod("append", [pythonHome]);
path.callMethod("append", [sitePackages]);
print('✅ Python module loaded');
print('Python 版本: ${sys.get("version")}');
*/
} catch (e, st) {
print('\n❌ Error:');
print(e);
print(st);
} finally {
exit(0);
}
}
問題的核心在於 importModule 的參數設計,它強制暴露了函式庫內部的封裝邏輯,詳見下文分析。
API 設計問題
首先看啟動 Python 實例的 initialize 函式定義:
/// Initializes the native platform Python runtime.
///
/// Pass the generated String `kPythonModules` from
/// `lib/python_modules/src/python_modules.g.dart` as [pythonModules] to load
/// all bundled Python modules.
/// Leave [pythonModules] `null` to load only builtin modules.
FutureOr<void> initialize({
String? pythonModules,
String? libPath,
bool? verboseLogging,
}) {
/* Omit some code. */
}
從官方註解可以發現,該套件預期使用者必須先透過其專屬工具將 Python 模組「序列化」為 Dart 字串(python_modules.g.dart)。經實測,pythonModules 參數期待的是一段 Base64 編碼的打包數據,且無法傳入 dummy string 繞過。
接著是關鍵的 importModule 函式:
/// Imports a Python module.
///
/// The module must be builtin or bundled with the app via Flutter assets or
/// embedded in Dart.
T importModule<T extends PythonModule>(
String name,
PythonModuleFrom<T> from,
) {
_ensureInitialized();
return from(delegate.importModule(name));
}
在這裡,第二個參數 from 被設計為一個必須執行的轉換函式(Callback)。這意味著開發者不可能傳入 null 或簡單的模組名稱。這種設計傳達了一個強烈的訊號:python_ffi_dart 根本不信任宿主環境(Host Environment)。它強迫使用者必須將 Python 模組打包進其封閉的參數體系中,否則無法進行基本的模組載入。
實務解決:Sidecar 模式
經過一番折騰,我認為與其在不完善的 FFI 封裝上打補丁,不如回歸通用的架構方案。在後端開發中,我們通常會將缺乏原生 Binding 的外部工具包裝成 CLI 程式或 Microservice,即所謂的 Sidecar(邊車模式)。
撰寫專屬 Sidecar (Python CLI)
以日文 NLP 函式庫 fugashi 為例,我們將其封裝為一個具備基礎錯誤檢查的 CLI 工具:
#!/usr/bin/env python3
import sys
import argparse
def main():
# 1. Use argparse to handle CLI arguments
parser = argparse.ArgumentParser(description="Fugashi (Unidic) Tokenizer Tool")
parser.add_argument("text", nargs="?", help="Japanese text to process")
args = parser.parse_args()
# Check if input text is provided
if not args.text:
print("Error: Please provide text to tokenize.", file=sys.stderr)
print("Usage: python script.py 'Japanese sentence'", file=sys.stderr)
sys.exit(1)
try:
# 2. Lazy import to provide friendly error messages
from fugashi import Tagger
except ImportError:
print("Error: 'fugashi' module not found. Please run 'pip install fugashi'.", file=sys.stderr)
sys.exit(1)
try:
# 3. Initialize Tagger
tagger = Tagger('-Owakati')
except Exception as e:
print(f"Error: Tagger initialization failed. Ensure unidic dictionary is installed.\nDetails: {e}", file=sys.stderr)
sys.exit(1)
# 4. Execute tokenization and parsing
try:
for word in tagger(args.text):
lemma = word.feature.lemma if hasattr(word.feature, 'lemma') else "N/A"
pos = word.pos if word.pos else "N/A"
print(f"{word.surface}\t{lemma}\t{pos}")
except Exception as e:
print(f"Runtime Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
在 Dart 程式呼叫 Sidecar
透過 Dart 原生的 Process 類別,我們可以輕鬆地與 Python Sidecar 通訊,既保證了環境隔離,也避免了複雜的 C-FFI 連結問題:
import 'dart:io';
import 'dart:convert';
class FugashiResult {
final String surface;
final String lemma;
final String pos;
FugashiResult(this.surface, this.lemma, this.pos);
@override
String toString() => 'Surface: $surface, Lemma: $lemma, POS: $pos';
}
class FugashiTokenizer {
final String pythonPath;
final String scriptPath;
FugashiTokenizer({
this.pythonPath = 'python3',
required this.scriptPath,
});
Future<List<FugashiResult>> tokenize(String text) async {
final ProcessResult result = await Process.run(
pythonPath,
[scriptPath, text],
stdoutEncoding: utf8,
stderrEncoding: utf8,
);
if (result.exitCode != 0) {
throw Exception('Python CLI Error:\n${result.stderr}');
}
final List<FugashiResult> tokens = [];
final List<String> lines = LineSplitter.split(result.stdout).toList();
for (var line in lines) {
if (line.trim().isEmpty) continue;
final parts = line.split('\t');
if (parts.length >= 3) {
tokens.add(FugashiResult(parts[0], parts[1], parts[2]));
}
}
return tokens;
}
}
void main() async {
final tokenizer = FugashiTokenizer(
pythonPath: '/home/user/.pyenv/versions/3.14.4/bin/python',
scriptPath: 'fugashi_cli.py',
);
try {
final text = "麩菓子は、麩を主材料とした日本の菓子。";
final results = await tokenizer.tokenize(text);
for (var token in results) {
print(token);
}
} catch (e) {
print('Failed to process: $e');
}
}
結語與評語
平心而論,python_ffi_dart 的設計並非完全錯誤,而是其出發點被過度限制在 Flutter 生態系中。在行動裝置 App 裡,將 Python 模組預先打包成資源檔並進行重新封裝是必要的「隔離機制」。
然而,當場景轉換到 Backend 或通用伺服器環境時,這種強制打包的設計就顯得極其冗餘且僵化。後端環境本身就具備完整的依賴管理系統,我們需要的是「對接現有環境」的橋樑,而非一個「強迫重新打包」的黑盒子。
很遺憾,在 python_ffi_dart 修正其過度耦合的 API 設計之前,它還無法成為一個合格且通用的 Python Interop 方案。對於伺服端開發者,Sidecar 模式依然是目前更穩定、更靈活的首選。