Python与Javascript相互调用超详细讲解(三)基本原理Part 3 - 通过C/C++联通


首先要明白的是,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接口……直接就是一个不建议使用。
  • 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 runtime的动态/静态库就像是,你每到一个地方,都要现场招聘一个可心的翻译。而找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程序就像是一堆由英语和中文混合而成的指令,有的一个人做就行,有的需要二人协作完成。
  • 方式一: 你们找了个日语母语者工具人,他只会听说日语。听到英语指令,如果一个人可以做,你的朋友做,如果需要二个人,你的朋友就用日语告诉工具人你需要做什么,工具人把日语原样告诉你;同理,听到中文指令,一个人完成的你自己执行,需要配合的时候你也用日语告诉工具人你朋友需要做什么。
  • 方式二: 你总是需要处理这些混合指令,你的朋友和工具人都不可能总是像连体婴一样打包跟你走。你的朋友总是可以把英语转换成日语,而日本人会做机器人呀!所以工具人在你朋友的指导下做出了一个能听懂英语并执行指令的机器人,你打包不了你朋友,你还不能打包机器人嘛!从此你与机器人过上了相依为命的生活,听到英语指令,如果一个人可以做,机器人自己就做了;如果需要你帮忙,你们也能通过简单的手势(数据类型转换)配合完成,你觉得这样的生活也不错,机器人大部分时候很好,只是有时候实在不灵光,它竟然听不懂印度口音的英语,啊,你有点想念你的朋友了,他在哪儿呢?
posted @ 2022-01-16 00:45  milliele  阅读(9491)  评论(0编辑  收藏  举报