Python與Javascript相互呼叫超詳細講解(2022年1月最新)(三)基本原理Part 3 - 通過C/C++聯通

milliele發表於2022-01-16


首先要明白的是,javascript和python都是解釋型語言,它們的執行是需要具體的runtime的。

  • Python: 我們最常安裝的Python其實是cpython,它有一個基於C的直譯器。除此之外還有像pypy這種直譯器,等等。基本上,不使用cpython作為python的runtime的最大問題就是通過pypi安裝的那些外來包,甚至有一些cpython自己的原生包(像collections這種)都用不了。
  • JavaScript: 常見的執行引擎有google的V8,Mozilla的SpiderMonkey等等,這些引擎可以把JavaScript程式碼轉換成機器碼執行。基於這些基礎的執行引擎,我們可以開發支援JS的瀏覽器(比如Chrome的JS執行引擎就是V8);也可以開發功能更多的JS執行環境,比如Node.js,相當於我們不需要一個瀏覽器,也可以跑JS程式碼。有了Node.js,JS包管理也變得方便許多,如果我們想把開發好的Node.js包再給瀏覽器用,就需要把基於Node.js的原始碼編譯成瀏覽器支援的JS程式碼。

在本文敘述中,假定:

  • 主語言: 最終的主程式所用的語言
  • 副語言: 不是主語言的另一種語言

例如,python呼叫js,python就是主語言,js是副語言

TL; DR

個人覺得這是最佳方案了,但是老一點的技術文章幾乎沒有人說。也可能是前幾年javascript的C語言相關的技術還沒發展得很全面?

只要選好合適的庫,幾乎適用於絕大部分場景:

  1. python的各種複雜的包不能割捨,Node.js的各種包也不想割捨。
  2. python和javascript的互動巨頻繁,傳遞的物件也大
  3. 對執行效率有要求:“把javascript變成python之後跑得也太慢了吧!”
  4. 甚至想在主語言裡搞多程式併發程式設計

有庫!有庫!有庫!

python調javascript

  • pyv8pip install -v pyv8
    • 缺點:太!老!了!甚至需要用python 2來構建V8……直接就是一個不建議使用。
  • stpyv8:後人做的pyv8升級,把Python API改成了python3的。
    • 缺點
      • 但V8還是要用python 2構建,雖然你不用再額外裝javascript的runtime,但你同時需要python2 和python3的runtime。
      • 由於js runtime是V8,有一些Node特性可能用不了,以及有的Node模組可能需要轉換成V8可以識別的js檔案(可以參考這裡
  • PyMiniRacer:基本跟上面一個專案做的差不多的事情。基於python 3嵌入了V8,但構建V8還是要用python 2。

javascript調python

  • PyNode:也是我最後用的包,安裝上會有點小問題,但總體是蠻不錯的
    python方要先安裝gyp-nextpip install gyp-next,然後npm install @fridgerator/pynode
    • 優點
      1. 上面說的所有優點都可以做到!就算NPM直接安裝的包做不到,稍微改改就可以做到。
      2. 和下面的boa相比,非常輕量,只有幾十MB,boa有幾面MB。
    • 缺點:列得比較多隻是因為我最後真的用了它,所以踩過一些坑,有的解決了,有的就還是將就用了。後面我會寫個踩坑經驗。
      1. 對python特性只有簡單的支援。比如:python的關鍵字引數(kwargs)是不支援的;傳一些複雜的object的時候解析得不是很靠譜,例如:有的簡單的dict套dict傳給javascript明明可以是個object,但它會變成它自己的python物件包裝器(PyObjectWrapper),希望以後可以繼續維護改進吧。
      2. 由於使用的python C API一直在變化,最好是用python 3.6以上。Node沒有太新的版本要求,只要支援N-API就可以了。
      3. 依賴python動態庫,在不同OS和Architecture的安裝時可能會出現一些找不到庫的問題(但應該可以解決!)。
      4. 呼叫startInterpreter()起2次python直譯器的話會有segmentation fault(可以解決!)
  • boanpm install @pipcook/boa。阿里的開源庫,跟pynode基本也是同一個原理。如果沒有缺點裡所說的情況,更推薦用這個
    • 優點
      1. 安裝它的時候,會通過miniconda直接安裝一個python環境,不需要自己裝python且找python的動態庫。
      2. 支援更多的python特性,比如關鍵字引數(kwargs),with語句之類的。
      3. 由於是阿里維護的,可能我們問問題會更方便吧(不是x)
    • 缺點:但由於它裝了一個conda的python環境,且只能使用這個python環境,導致損失了一些靈活性。這在大部分情況下應該都不是問題,其實這個庫會比pynode更魯棒一些。主要還是在我的use case裡,我還是必需要在我本地的python runtime裡執行一些程式的,而boa只能使用conda的python runtime,那麼結果就是python部分的依賴的包要麼同時在這兩個runtime裡都裝一遍,要麼只在一個裡裝,把site-packages新增到另一個runtime的sys.path下。如果包裝在本地runtime裡,那麼conda的runtime可能要把本地runtime的庫目錄,以及本地虛擬環境的庫目錄都加進入,我覺得挺麻煩的。
  • pyodide:沒用過,大致看了看介紹。直接把cpython直譯器編譯成了WebAssembly。
    • 優點:完全不用安裝python啦!而且比起js直譯器,支援很多python包,包括numpy之類的。感覺非常適合瀏覽器使用python進行科學計算!
    • 缺點
      • 支援的python包還是有限
      • 跟你本地的cpython不是一個runtime,可能你在本地python除錯好的程式碼在pyodide裡會有問題
      • 你的python部分暫時跟併發程式設計無緣啦!

原理

眾所周知(不是x

事就這麼成了,讓這兩種語言通過C語言來聯通!這種方式的好處是:

  1. python和javascript實際上都還是在各自的runtime裡執行,因此只要你單獨在python環境,或者javascript環境開發的程式碼沒問題,連起來也沒問題,所以支援各種擴充套件包。
  2. 而且它們在同一個程式裡,我們的開銷幾乎只有資料型別轉換,真正做到了高效率的兩種語言的互動

我根據目前見過的專案,總結了一下目前已有的方式:

基於Node.js的javascript呼叫python

參考:《NodeJS and Python interoperability!》(題外話:才發現就是PyNode專案的作者寫的!TQL!)

這篇文章把這條路的基本原理寫得很清楚了,我稍微翻譯一下大意,儘量達,不信不雅(x)。

從Node呼叫python函式

NodeJS與Python之間的互操作還是相對簡單 可行的。我不是指基於子程式呼叫CLI以及程式間通訊的方式,或者一些其它古怪的方式。這兩種語言都是用C/C++編寫的,所以通過呼叫副語言的本機庫,互操作是可能的。看看我使用這兩種語言的底層API的過程中都經歷了什麼吧,我可真的很不喜歡這個!??(就是這麼......直白)。

那麼,本著本部落格的精神,啤酒⇓
[Drumroll APA.jpg]

V8

Node是基於V8編寫的。你可以在C++中建立javascript類和函式,並在javascript資料型別和V8資料型別之間輕鬆轉換,來傳遞引數和返回值。我發現這非常有利用在C++中進入資料處理——javascript(相比C)實在太慢了。然後,我可以將(處理好的)大型陣列返回給javascript來繪製圖形,並且不需要先對這個大陣列進行序列化/反序列化。使用N-API的話,這種資料型別轉換甚至變得更加容易。我不打算討論使用V8和N-API的具體細節,但是有很多關於這個主題的博文,以及大量的基於此原理的Node.js模組可以作為樣例學習。

譯註:
1)Node-API,或N-API:Node.js更高階一層的應用程式二進位制介面 (ABI),對開發者隱藏了底層引擎(指V8)。
2)大意就是:轉換C和javascript資料型別很方便,不需要像程式間通訊(IPC)那像做複雜的字串解析。

嵌入Python

在Python裡也有個差不多的概念——嵌入Python (Embedding Python)。你可以用它執行Python程式碼片段,或開啟已有的檔案(模組)並直接呼叫函式。同樣,它也需要在C++和python的資料型別間進行轉換,來傳遞引數和返回值。此外,要做到這個你還需要一個Python直譯器(但依然可以讓你的專案保有可移植性,我會在後面詳細講講這個)。awasu.com上有一篇非常好的博文,關於如何用C++編寫Python包裝器給出了非常詳細的解釋和例子。

譯註:大意就是,參考了一個博文,可以做到基於python的C-API,給Python物件寫一個包裝器,把那些型別轉換都處理好,使得C程式碼呼叫python物件就像在呼叫C物件一樣。

程式碼

完整的程式碼在這裡

首先,在Initialize函式裡,我們設定了一些搜尋路徑,使得Python能夠找到直譯器和所需的庫。然後我們將它們傳遞給Py_SetPath。接下來,我們初始化Python直譯器,並把當前目錄加入python的sys.path裡,這樣這個直譯器就可以找到這個目錄裡的Python模組。最後,我們讓Python直譯器解碼我們的tools.py檔案,並把它作為模組匯入,這樣後面就可以呼叫它。

void Initialize(v8::Local<v8::Object> exports)
{
  // Initialize Python
  std::wstring path(L"/usr/lib/python3.7:/usr/local/lib/python3.7/lib-dynload:/usr/local/lib/python3.7/site-packages");
  const wchar_t *stdlib = path.c_str();
  Py_SetPath(stdlib);

  exports->Set(
      Nan::New("multiply").ToLocalChecked(),
      Nan::New<v8::FunctionTemplate>(Multiply)->GetFunction());

  Py_Initialize();
  PyRun_SimpleString("import sys");
  PyRun_SimpleString("sys.path.append(\".\")");

  PyObject *pName;
  pName = PyUnicode_DecodeFSDefault("tools");
  pModule = PyImport_Import(pName);
  Py_DECREF(pName);

  if (pModule == NULL)
  {
    PyErr_Print();
    fprintf(stderr, "Failed to load \%s\"\n", "tools");
    Nan::ThrowError("Failed to load python module");
    return;
  }
}

我們已經在我們的Node模組的exports裡新增了一個函式multiply,它會呼叫我們C++裡的Multiply函式。

譯註:
1)這個函式已經通過NODE_MODULE(addon, Initialize);被我們指定為Node.js模組的入口,所以這個函式的引數才是exports,往它裡面新增東西就相當於執行module.exports = {...}
2) exports->Set的那個語句相當於在javscript裡寫:

module.exports = {
    multiply: Multiply
}
void Multiply(const Nan::FunctionCallbackInfo<v8::Value> &args)
{
  if (!args[0]->IsNumber() || !args[1]->IsNumber())
  {
    Nan::ThrowError("Arguments must be a number");
    return;
  }

  PyObject *pFunc, *pArgs, *pValue;

  double a = Nan::To<double>(args[0]).FromJust();
  double b = Nan::To<double>(args[1]).FromJust();

  pFunc = PyObject_GetAttrString(pModule, "multiply");
  if (pFunc && PyCallable_Check(pFunc))
  {
    pArgs = PyTuple_New(2);
    PyTuple_SetItem(pArgs, 0, PyLong_FromLong(a));
    PyTuple_SetItem(pArgs, 1, PyLong_FromLong(b));

    pValue = PyObject_CallObject(pFunc, pArgs);
    Py_DECREF(pArgs);

    if (pValue != NULL)
    {
      long result = PyLong_AsLong(pValue);
      Py_DECREF(pValue);

      args.GetReturnValue().Set(Nan::New((double)result));
    }
    else
    {
      Py_DECREF(pFunc);
      PyErr_Print();
      fprintf(stderr, "Call failed\n");
      Nan::ThrowError("Function call failed");
    }
  }
}

(在Multiply函式裡),我們在檢查了引數後,使用方便的Nan::To把傳來的(基於Node資料型別的)引數轉換成(C++)的double型別。然後,我們使用PyObject_GetAttrString載入Python函式,並使用PyCallable_Check確保我們已經找到了一個可呼叫的函式。

假設我們有兩個從javascript傳來的有效引數,並且我們已經(在python裡)找到了一個可呼叫的multiply函式,下一步就是把這兩個double變數轉換成Python函式的引數。我們建立一個大小為2的新的Python元組(tuple),並把這兩個double變數新增到該元組中。現在是見證奇蹟的時刻:pValue = PyObject_CallObject(pFunc, pArgs);。如果pValue不是NULL,那我們就已經成功地在Node裡呼叫了Python函式且得到了返回值。最後,我們把pValue轉換為一個長整型long,並把它設定成我們的Node函式的返回值。

我覺得,這真特麼的酷斃了。

譯註:這個Demo可能做得比較早,現在Node.js這一系列API已經改名叫Node-API/N-API了,在C++裡的namespace也變了。N-API的設計可能也有所區別,但是聯通python和javascript的原理還是一樣的,體會精神hhh

可移植性

在這個Demo裡,我在本地下載並構建(build)了Python 3.7.3。如果你檢視binding.gyp檔案,你會注意到它包括了(Python的)本地資料夾。你也可以建立一個可移植的Python發行版,與Node App一起分發。這對Electron App來說可能很有用。另一篇博文描述瞭如何在OSX中這樣做。

譯註:
1)binding.gyp裡連到了他本地構建的python的標頭檔案,註釋掉的部分是連線本地編譯好的動態庫的,現在沒註釋掉的部分連的是本機python的動態庫。
2)沒太Get到,可能大意是編譯Node.js可執行檔案的時候直接把python一起編譯進去?不過現在pynode不是這麼做的,回頭再寫篇實踐經驗分享吧。

總結

這當然比使用child_process.spawn來執行python要費勁得多。我也不確定這些額外的工作值不值得(譯註:超值得!)。
它是(這兩種語言間)更直接的呼叫(相比與程式間通訊),並且好處是隻需要轉換引數和返回值。此外,我們甚至可以把Python檔案通過xxd -i tools.py進行hexdump(轉成十六進位制資料),然後作為一個C的char變數,在編譯的時候把它包括進去。
我會繼續基於這個想法做更多深度,探索出更多的可能性(譯註:然後就有了pynode!感恩!)。

最後,補充一下,這種方案說是python和javascript的互操作,但最後執行App的主語言需要是javascript,並且需要找到python的動態/靜態庫來build你的Node.js包,之後才能利用這個包隨意呼叫python程式碼。我的感受是,我們裝python的時候,大概率會裝好python的動態庫(大概是因為用Python的C API寫python包的需求實在很常見),找到它也比較容易。

基於V8的python呼叫javascript

事件起源於Google有個古老的python專案pyv8,大概的功能就是可以通過python API來呼叫V8引擎,執行javascript的程式碼。
原理跟前面所述的也差不多,都是利用python和V8各自的C API來聯通,進行一些資料型別轉換。只不過區別是,上面是javascript呼叫python,所以最後需要python方的動態或靜態庫,這邊是python呼叫javascript,所以最後需要javascript方(也就是V8)的動態/靜態庫。

但這種方式有兩個問題:

一是,我們裝Node的時候,大概率裝好了標頭檔案和二進位制可執行檔案,可能沒帶共享庫,想要庫檔案只能通過原始碼編譯。而V8,安裝它甚至需要從原始碼構建(……)。總之,通過嵌入V8來執行javascript會導致你的python app安裝起來非常累。Javascript方就像是,你每到一個地方,都要現場招聘一個可心的翻譯。而Python方就像是,只要我集團有業務的地方,都安排好翻譯了,你只要把他叫來就行(……破爛比喻)。

二是,嵌入的不是Node.js而是V8的話,有很多Node.js特有的特性你用不了。比如require包之類的,如果你的JS程式碼本來就是基於Node.js開發的話,這就會很痛苦。你需要把你的基於Node的JS程式碼轉換成V8可以懂的單檔案JS程式碼(倒是有包可以做到,見這裡)。

那麼可不可以嵌入Node.js呢?倒是也可以,Node有一篇簡單的文件,需要做的大概就是把上一種方法反過來。但目前沒人這麼幹過,不如說,別說是python嵌Node.js了,C++嵌Node.js的專案也不多,遇到問題可能很難解決。而且Node.js安裝的時候不帶共享庫,只能從原始碼構建,也挺煩人的。如果想簡單嵌入一些node.js的功能,可能libnode也能一定程度上解決?

編譯Python直譯器為WebAssembly

這種方法的底層邏輯(x)雖然跟C語言有關,但和前面兩種完全不同。我也沒實踐過,只把自己查資料得到的一些粗淺理解列出來了。

參考:
https://github.com/chenshenhai/blog/issues/38
如何看待 WebAssembly 這門技術?

首先,我們要知道,雖然python和javascript之類的底層引擎是用C/C++語言寫的,但歸根到底C/C++也是一種高階語言。它也需要被編譯成彙編的機器指令,並連結相應的庫之後生成二進位制可執行檔案來執行。

萬惡之源 起因是WebAssembly,它是一種組合語言,可以作為高階語言的可移植編譯目標(就是類似於.o的檔案),把高階語言編譯成一堆二進位制機器指令。編譯成WebAssembly的話好處大概是可以跨平臺?現在有很多瀏覽器,以及Node.js都支援WebAssembly了。簡而言之,它是一種在javascript的大部分可能的執行環境下都能被友好支援的彙編。

有一個工具Emscripten,可以專門把C/C++編譯成WebAssembly機器指令。

而眾所周知(不是x)cpython的直譯器是C寫的,誒嘿,它是不是也可以被編譯成WebAssembly呢?這樣一來,在javascript的大部分可能的執行環境下(例如瀏覽器,Node.js等),我們就有了一個python直譯器!還不需要再額外裝cpython了!換句話說,這部分WebAssembly碼就是python的runtime了。而且,何止是javascript,如果有其他語言的runtime也支援了WebAssembly,那其他語言也可以在沒有cpython的情況下跑python了!

做這件事的就是這個專案:Pyodide。可以把它理解為一個基於WebAssembly的python直譯器。我們在cpython下可以用python a.py執行python程式,是因為指令python在作業系統上連線到了cpython的python直譯器的可執行檔案上。而安裝pyodide之後,你也會有個類似的可執行程式,是pyodide的python直譯器。

與前面兩種方法相比,最大的優勢在於完全脫離了cpython的runtime(不用裝python了),這對於讓瀏覽器支援跑python是很有意義的,其實基本原理Part 2:“用js寫python直譯器”的目標之一也是這個。

但與“用js寫python直譯器”相比,這種方式最大的區別在於:可以使用C編寫的python包了!js直譯器不支援的python包,通常原因是人家是用C寫的,比如numpy之類的,所以js直譯器執行不了。但用C寫又怎麼樣,照樣可以編譯成WebAssembly,從而被使用。只不過,需要pyodide的開發者每次都把這些包手動從C原始碼編譯成WebAssembly庫。目前他們已經編譯了一些常見的基於C的python庫(比如numpy)並內建了,安裝pyodide就能用。

優點

個人感覺前兩種原理做不到的事情它都可以,只要能成功構建(build)好環境……如果用的是docker,能解決作業系統(OS)和體系架構(Architecture)帶來的煩惱的話,這種方式無敵了。

  1. 隨便用各種擴充套件包! python的包和javascript的包,只要原來在各自的runtime下可以用,現在也還可以用!(使用pyodide的情況除外,但pyodide的究級形態理應也是可以做到這一點的)
  2. 執行效率很高! 不管是python還是javascript,本質上其實還是在各自的引擎下執行,除了一些資料型別轉換開銷,幾乎不會增加執行時間。並且它們其實還是在同一個程式下執行的,不需要程式間通訊開銷。當你的python runtime不是cpython而是pyodide的時候,甚至可能會更快。
  3. 無縫互動! 因為通過C聯通了,兩種語言的互動最後歸根結底只會是一些基礎資料型別的通訊:在javascript裡是字串的,用C API告訴python它是個字串就行了;在python裡是個函式的,在javascript裡只要把javascript資料型別的引數轉換成python資料型別傳過去,然後在python runtime下執行函式,把python返回值轉成javascript資料型別,幾乎不需要序列化和程式間通訊。
  4. 如果只想安裝一個runtime,可以直接嵌入一個副語言的runtime。(當然也有相應的缺點)
  5. 如果兩個runtime都裝好了,我們要做的只是找到副語言的動態庫而已,不怎麼用花太多時間編譯和安裝環境,當你的專案需要作為一個輕量包分發的話,是不錯的選擇。

缺點

  1. 上輩子作惡多端,這輩子寫C++ 主要來自編譯和連結C庫,配環境能氣死人,雖然寫好的包裡都有一定的自動化build和link的手段,但每臺機器都有自己的脾氣(怎麼跟烤箱似的),誰也不知道自己安裝的時候會出什麼問題,庫作者也沒法測試所有的環境。
  2. 相信要python和javascript選手寫或者修改C/C++也不是一件容易的事。
  3. 優點4裡說的嵌入副語言runtime的方式,會讓你的主語言專案很heavy。因為副語言的runtime的動態/靜態庫不能普適於所有的作業系統(OS)和體系架構(Architecture),所以要在安裝的時候再編譯出相應的庫。如果只在一個機器上執行,編譯一次就夠了;如果要在不同的機器上執行,每次部署你的專案,都需要針對該機器編譯一次,時間通常很長(原始碼安裝一次Python、Node.js或者V8應該就懂了)。不過WebAssembly法js呼叫python應該沒有這種煩惱?畢竟它對機器的依賴程度不大,只是支援的包少了些。

總結

這一期內容比較多,簡單做個總結吧。通過C/C++聯通python和javascript有兩種思路:

  1. 通過runtime 引擎提供的C API進行
    • javascript呼叫python的話需要cpython的動態/靜態庫;python呼叫javascript需要Node.js或者V8的動態/靜態庫。
    • 幾乎能完美做到python和javascript的互呼叫,缺點在於配環境很煩人。
  2. 把cpython的python直譯器編譯成js runtime可用的WebAssembly語言
    • 好處是甚至可能比用cpython跑python程式更快,不需要依賴cpython環境
    • 缺點大概是支援的包還是比較有限吧

最後讓我用之前外語學習的破爛比喻收尾吧(沒有冒犯的意味):

  • 你是javascript runtime,你是一個聽不懂英語的中文母語者,但能聽說日語。
  • 你有一個朋友cpython runtime,他是一個聽不懂中文的英語母語者,但能聽說日語。
  • 你的python-javascript程式就像是一堆由英語和中文混合而成的指令,有的一個人做就行,有的需要二人協作完成。
  • 方式一: 你們找了個日語母語者工具人,他只會聽說日語。聽到英語指令,如果一個人可以做,你的朋友做,如果需要二個人,你的朋友就用日語告訴工具人你需要做什麼,工具人把日語原樣告訴你;同理,聽到中文指令,一個人完成的你自己執行,需要配合的時候你也用日語告訴工具人你朋友需要做什麼。
  • 方式二: 你總是需要處理這些混合指令,你的朋友和工具人都不可能總是像連體嬰一樣打包跟你走。你的朋友總是可以把英語轉換成日語,而日本人會做機器人呀!所以工具人在你朋友的指導下做出了一個能聽懂英語並執行指令的機器人,你打包不了你朋友,你還不能打包機器人嘛!從此你與機器人過上了相依為命的生活,聽到英語指令,如果一個人可以做,機器人自己就做了;如果需要你幫忙,你們也能通過簡單的手勢(資料型別轉換)配合完成,你覺得這樣的生活也不錯,機器人大部分時候很好,只是有時候實在不靈光,它竟然聽不懂印度口音的英語,啊,你有點想念你的朋友了,他在哪兒呢?

相關文章