作者:泽阳 Pipcook contributor
Pipcook 是淘系技术部 D2C 团队研发的一款面向前端开发者的机器学习应用框架,我们希望 Pipcook 能成为前端人员学习和实践机器学习的一个平台,从而推进前端智能化的进程。
Boa 是 Pipcook 背后的技术,通过它,开发者可以调用到任何 Python 的函数,当然就包括:numpy、scikit-learn、jieba 等大家熟知的 Python 库,如果想直接使用 tensorflow、pytorch、tvm 等也不在话下。
在未实现此功能之前,Boa 加载 Python 库是下面这样子的:
const boa = require('@pipcook/boa');
const { range, len } = boa.builtins();
const { getpid } = boa.import('os');
const numpy = boa.import('numpy');
复制代码
通过 boa.import 函数传入 Python 库的名称来进行导入,如果需要加载的包很多的话可能 import() 会显得冗余。为此我们开发了自定义的导入语义声明,使用 ES Module 实现更简洁的导入语句:
import { getpid } from 'py:os';
import { range, len } from 'py:builtins';
import {
array as NumpyArray,
int32 as NumpyInt32,
} from 'py:numpy';
复制代码
上面的功能实现依赖于 Node 的实验性功能 --experimental-loader, 它还有个未公开的别名 --loader, 一开始这个功能刚推出来的时候其实是 --loader , 因为是考虑到是实验性的所以后面的版本加上了 experimental.
--experimental-loader
启动程序的时候通过指定此 flag , 接受指定后缀为 mjs 的文件来实现自定义加载器,mjs 文件提供几种钩子来拦截默认的加载器:
- resolve
- getFormat
- getSource
- transformSource
- getGlobalPreloadCode
- dynamicInstantiate
执行的顺序为:
( Each `import` runs the process once )
|- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -|
( run once ) | ---> dynamicInstantiate |
getGlobalPreloadCode -> | resolve -> getFormat -> | |
| ---> getSource -> transformSource |
|_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _|
复制代码
getGlobalPreloadCode Hook
只运行一次,用于在应用程序启动时在全局范围内运行一些代码,可以通过 globalThis 对象将变量挂在到全局范围内,目前只提供一个 getBuiltin(类似 require) 函数加载内置模块:
/**
* @returns {string} Code to run before application startup
*/
export function getGlobalPreloadCode() {
return `
globalThis.someInjectedProperty = 42;
console.log('I just set some globals!');
const { createRequire } = getBuiltin('module');
const require = createRequire(process.cwd() + '/<preload>');
`;
}
复制代码
那么在所有的模块中都可以使用 someInjectedProperty 这个变量了。
resolve Hook
是加载器的入口,拦截 import 语句和 import() 函数,可以对导入的字符串进行判断返回特定逻辑的 url:
const protocol = 'py:';
/**
* @param {string} specifier
* @param {object} context
* @param {string} context.parentURL
* @param {function} defaultResolve
* @returns {object} response
* @returns {string} response.url
*/
export function resolve(specifier, context, defaultResolve) {
if (specifier.startsWith(protocol)) {
return {
url: specifier
};
}
return defaultResolve(specifier, context, defaultResolve);
}
复制代码
参数定义:
- specifier - import 语句 或 import() 表达式中的字符串
// eg:
import os from 'py:os'; // 'py:os'
async function load() {
const sys = await import('py:sys'); // 'py:sys'
}
复制代码
- parentURL - 导入该模块的父模块 url
// eg:
// app.mjs
import os from 'py:os'; // file:///.../app.mjs
复制代码
这里有个细节点,defaultResolve 函数的第一个参数只接受三种协议的字符串:
- data: - javascript wasm 字符串
- nodejs: - built-in 模块
- file: - 第三方或用户自定义模块
Boa 自定义的 py: 协议并不能通过默认 resolve Hook 的参数检查,所以上面做了判断匹配后直接返回 url 传递给 getFormat Hook.
getFormat Hook
提供多种方式定义如何解析 resolve Hook 传递下来的 url:
- builtin - Node.js 内置模块
- commonjs - CommonJS 模块
- dynamic - 动态实例化, 触发 dynamicInstantiate Hook
- json - JSON 文件
- module - ECMAScript 模块
- wasm - WebAssembly 模块
从功能描述来看,只有 dynamic 符合我们的需求:
export function getFormat(url, context, defaultGetFormat) {
// DynamicInstantiate hook triggered if boa protocol is matched
if (url.startsWith(protocol)) {
return {
format: 'dynamic'
}
}
// Other protocol are assigned to nodejs for internal judgment loading
return defaultGetFormat(url, context, defaultGetFormat);
}
复制代码
当 url 匹配上 boa 协议则触发 dynamicInstantiate Hook, 其他情况下都交给默认的解析器去判断加载。
dynamicInstantiate Hook
提供不同于 getFormat 几种解析格式的动态加载模块方式:
/**
* @param {string} url
* @returns {object} response
* @returns {array} response.exports
* @returns {function} response.execute
*/
export function dynamicInstantiate(url) {
const moduleInstance = boa.import(url.replace(protocol, ''));
// Get all the properties of the Python Object to construct named export
// const { dir } = boa.builtins();
const moduleExports = dir(moduleInstance);
return {
exports: ['default', ...moduleExports],
execute: exports => {
for (let name of moduleExports) {
exports[name].set(moduleInstance[name]);
}
exports.default.set(moduleInstance);
}
};
}
复制代码
使用 boa.import() 加载 Python 模块,并使用 Python builtins 内置的 dir 函数获取模块的全部属性。钩子需要预先提供导出列表传递给 exports 参数,用于支持 Named exports,加上 default 是为了支持 Default exports。execute 函数在初始化动态钩子时设置指定命名对应的属性。
getSource Hook
用于传递源码字符串,提供不同于默认加载器从磁盘读取文件的方式获取源码,比如网络、内存和硬编码等:
export async function getSource(url, context, defaultGetSource) {
const { format } = context;
if (someCondition) {
// For some or all URLs, do some custom logic for retrieving the source.
// Always return an object of the form {source: <string|buffer>}.
return {
source: `export const message = 'Woohoo!'.toUpperCase();`
};
}
// Defer to Node.js for all other URLs.
return defaultGetSource(url, context, defaultGetSource);
}
复制代码
这里比较有意思的是可以从不同渠道获取源码,比如实现和 Deno 一样的方式从网络获取源码。
transformSource Hook
等待 getSource Hook 执行完毕加载完源码后,该钩子可以对已加载的源码进行修改操作:
export async function transformSource(source,
context,
defaultTransformSource) {
const { url, format } = context;
if (source && source.replace) {
// For some or all URLs, do some custom logic for modifying the source.
// Always return an object of the form {source: <string|buffer>}.
return {
source: source.replace(`'A message';`, `'A message'.toUpperCase();`)
};
}
// Defer to Node.js for all other sources.
return defaultTransformSource(
source, context, defaultTransformSource);
}
复制代码
上面提供的例子将源码中的特定字符串替换成新字符串, 还可以实现即时编译:
export function transformSource(source, context, defaultTransformSource) {
const { url, format } = context;
if (extensionsRegex.test(url)) {
return {
source: CoffeeScript.compile(source, { bare: true })
};
}
// Let Node.js handle all other sources.
return defaultTransformSource(source, context, defaultTransformSource);
}
复制代码
最后
相比之前 Node.js 只能用内置的几种方式加载模块,如今开放出来的几种钩子可以组合出很多有趣的功能,大家可以去挖掘更多有意思的场景。
参考链接
- Experimental Loaders
- ECMAScript Modules Export