位元詩人 為什麼 python_ffi_dart 不是一個良好的通用 Interop 方案?談 API 設計中的過度耦合問題

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

從語法特性來看,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 模式依然是目前更穩定、更靈活的首選。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,專注於從原型到產品的開發過程,並以工具驅動的方式持續探索技術。喜歡以開源專案作為成果,回饋社群。

主要方向包括:自用工具的打磨 (dogfooding)、編譯器前端在工具開發中的應用,以及將研究與實驗轉化為可維護的開源成果。

除了技術之外,也喜歡日本料理和黑咖啡,偶爾自助旅行,將生活中的靈感融入技術隨筆。