首先要明白的是,javascript和python都是解釋型語言,它們的執行是需要具體的runtime的。
- Python: 我們最常安裝的Python其實是cpython,就是基於C來執行的。除此之外還有像pypy這樣的自己寫了直譯器的,transcrypt這種轉成js之後再利用js的runtime的。基本上,不使用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
適用於:
- python和javascript的runtime(基本特指cpython[不是cython!]和Node.js)都裝好了
- 副語言用了一些複雜的包(例如python用了numpy、javascript用了一點Node.js的C++擴充套件等)
- 對執行效率有要求的話:
- python與javascript之間的互動不能太多,傳遞的物件不要太大、太複雜,最好都是可序列化的物件
- javascript佔的比重不過小。否則,python調js的話,啟動Node.js子程式比實際跑程式還慢;js調python的話,因為js跑得快,要花很多時間在等python上。
- 因為IPC大概率會用執行緒同步輸入輸出,主語言少整啥多程式、多執行緒之類的併發程式設計
有庫!有庫!有庫!
python調javascript
- JSPyBridge:
pip install javascript
- 優點:
- 作者還在維護,回issue和更新蠻快的。
- 支援比較新的python和node版本,安裝簡單
- 基本支援互呼叫,包括繫結或者傳回撥函式之類的。
- 缺點:沒有合理的銷燬機制,
import javascript
即視作連線JS端,會初始化所有要用的執行緒。如果python主程式想重啟對JS的連線,或者主程式用了多程式,想在每個程式都連線一次JS,都很難做到,會容易出錯。
- 優點:
- PyExecJS:
pip install PyExecJS
,比較老的技術文章都推的這個包- 優點: 支援除了Node.js以外的runtime,例如PhantomJS之類的
- 缺點: End of Life,作者停止維護了
javascript調python
(因為與我的專案需求不太符合,所以瞭解不太多)
- JSPyBridge:
npm i pythonia
- node-python-bridge:
npm install python-bridge
- python-shell:
npm install python-shell
原理
首先,該方法的前提是兩種語言都要有安裝好的runtime,且能通過命令列呼叫runtime執行檔案或一串字元指令碼。例如,裝好cpython後我們可以通過python a.py
來執行python程式,裝好Node.js之後我們可以通過node a.js
或者node -e "some script"
等來執行JS程式。
當然,最簡單的情況下,如果我們只需要呼叫一次副語言,也沒有啥互動(或者最多隻有一次互動),那直接找個方法呼叫CLI就OK了。把給副語言的輸入用stdin或者命令列引數傳遞,讀取命令的輸出當作副語言的輸出。
例如,python可以用subprocess.Popen
,subprocess.call
,subprocess.check_output
或者os.system
之類的,Node.js可以用child_process
裡的方法,exec
或者fork
之類的。需要注意的是,如果需要引用其他包,Node.js需要注意在node_modules
所在的目錄下執行指令,python需要注意設定好PYTHONPATH環境變數。
# Need to set the working directory to the directory where `node_modules` resides if necessary
>>> import subprocess
>>> a, b = 1, 2
>>> print(subprocess.check_output(["node", "-e", f"console.log({a}+{b})"]))
b'3\n'
>>> print(subprocess.check_output(["node", "-e", f"console.log({a}+{b})"]).decode('utf-8'))
3
// Need to set PYTHONPATH in advance if necessary
const a = 1;
const b = 2;
const { execSync } = require("child_process");
console.log(execSync(`python -c "print(${a}+${b})"`));
//<Buffer 33 0a>
console.log(execSync(`python -c "print(${a}+${b})"`).toString());
//3
//
如果有複雜的互動,要傳遞複雜的物件,有的倒還可以序列化,有的根本不能序列化,咋辦?
這基本要利用程式間通訊(IPC),通常情況下是用管道(Pipe)。在stdin
,stdout
和stderr
三者之中至少挑一個建立管道。
假設我用stdin
從python向js傳資料,用stderr
接收資料,模式大約會是這樣的:
(以下虛擬碼僅為示意,沒有嚴格測試過,實際使用建議直接用庫)
- 新建一個副語言(假設為JS)檔案
python-bridge.js
:該檔案不斷讀取stdin
並根據發來的資訊不同,進行不同處理;同時如果需要列印資訊或者傳遞object給主語言,將它們適當序列化後寫入stdout
或者stderr
。function sendObj(obj) { // deliver object, "$j2p" can be any prefix predefined and agreed upon with the Python side // just to tell python side that this is an object needs parsing process.stderr.write("$j2p sendObj "+JSON.stringify(obj)+"\n"); } process.stdin.on('data', data => { data.split('\n').forEach(line => { // Deal with each line if (line.startsWith('$p2j')) { const [prefix, cmd, ...words] = line.split(" "); if (cmd === 'call') { // call some function const [funcname, ...argStr] = words; const args = JSON.parse(argStr.join(' ')); sendObj(global[funcname](...args)); } } // write message process.stdout.write(message + "\n"); }); } process.on('exit', () => { console.debug('** Node exiting'); });
- 在python中,用Popen非同步開啟一個子程式,並將子程式的
stdin
,stdout
和stderr
三者之中的至少一個,用管道連線。大概類似於:cmd = ["node", "--trace-uncaught", f"{os.path.dirname(__file__)}/python-bridge.js"] kwargs = dict( stdin=subprocess.PIPE, stdout=sys.stdout, stderr=subprocess.PIPE, ) if os.name == 'nt': kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW subproc = subprocess.Popen(cmd, **kwargs)
- 在需要呼叫JS,或者需要給JS傳遞資料的時候,往
subproc
寫入序列化好的資訊,寫入後需要flush
,不然可能會先寫入緩衝區:subproc.stdin.write(f"$p2j call funcName {json.dumps([arg1, arg2])}".encode()) subproc.stdin.flush() # write immediately, not writing to the buffer of the stream
- 對管道化的
stdout
/stderr
,新建一個執行緒,專門負責讀取傳來的資料並進行處理。是物件的重新轉換成物件,是普通訊息的直接列印回主程式的stderr
或者stdout
。
這裡由於我們的def read_stderr(): while subproc.poll() is None: # when the subprocess is still alive, keep reading line = self.subproc.stderr.readline().decode('utf-8') if line.startswith('$j2p'): # receive special information _, cmd, line = line.split(' ', maxsplit=2) if cmd == 'sendObj': # For example, received an object obj = json.loads(line) else: # otherwise, write to stderr as it is sys.stderr.write(line) stderr_thread = threading.Thread(target=read_stderr, args=(), daemon=True) stderr_thread.start()
stdout
沒有建立管道,所以node那邊往stdout
裡列印的東西會直接列印到python的sys.stdout
裡,不用自己處理。 - 由於執行緒是非同步進行的,什麼時候知道一個函式返回的物件到了呢?答案是用執行緒同步手段,訊號量(Semaphore)、條件(Condition),事件(Event)等等,都可以。以python的條件為例:
同時,需要在讀stderr的執行緒func_name_cv = threading.Condition() # use a flag and a result object in case some function has no result func_name_result_returned = False func_name_result = None def func_name_wrapper(arg1, arg2): # send arguments subproc.stdin.write(f"$p2j call funcName {json.dumps([arg1, arg2])}".encode()) subproc.stdin.flush() # wait for the result with func_name_cv: if not func_name_result_returned: func_name_cv.wait(timeout=10000) # when result finally returned, reset the flag func_name_result_returned = False return func_name_result
read_stderr
裡解除對這個返回值的阻塞。需要注意的是,如果JS端因為意外而退出了,subproc
也會死掉,這時候也要記得取消主執行緒中的阻塞。
當然這是比較簡單的版本,由於對JS的呼叫基本都是線性的,所以可以知道只要得到一個object的返回,那就一定是def read_stderr(): while subproc.poll() is None: # when the subprocess is still alive, keep reading # Deal with a line line = self.subproc.stderr.readline().decode('utf-8') if line.startswith('$j2p'): # receive special information _, cmd, line = line.split(' ', maxsplit=2) if cmd == 'sendObj': # acquire lock here to ensure the editing of func_name_result is mutex with func_name_cv: # For example, received an object func_name_result = json.loads(line) func_name_result_returned = True # unblock func_name_wrapper when receiving the result func_name_cv.notify() else: # otherwise, write to stderr as it is sys.stderr.write(line) # If subproc is terminated (mainly due to error), still need to unblock func_name_wrapper func_name_cv.notify()
func_name_wrapper
對應的結果。如果函式多起來的話,情況會更復雜。 - 如果想取消對JS的連線,首先應該先關閉子程式,然後等待讀
stdout
/stderr
的執行緒自己自然退出,最後一定不要忘記關閉管道。並且這三步的順序不能換,如果先關了管道,讀執行緒會因為stdout
/stderr
已經關了而出錯。subproc.terminate() stderr_thread.join() subproc.stdin.close() subproc.stderr.close()
如果是通過這種原理javascript呼叫python,方法也差不多,javascript方是Node.js的話,用的是child_process
裡的指令。
優點
- 只需要正常裝好兩方的runtime就能實現互動,執行環境相對比較好配。
- 只要python方和javascript方在各自的runtime里正常執行沒問題,那麼連上之後執行也基本不會有問題。(除非涉及併發)
- 對兩種語言的所有可用的擴充套件包基本都能支援。
缺點
- 當python與JavaScript互動頻繁,且互動的資訊都很大的時候,可能會很影響程式效率。因為僅僅通過最多3個管道混合處理普通要列印的資訊、python與js互動的物件、函式呼叫等,通訊開銷很大。
- 要另起一個子程式執行副語言的runtime,會花一定時間和空間開銷。