聊聊内嵌脚本引擎

1,101 阅读18分钟

这篇文章来自我们团队黄琦同学的分享,他介绍了广泛应用的内嵌脚本引擎,以及它的立足之本——“功能注入”这一细节知识点

前言

看到本文标题也许大家会有点慌,这篇文章是不是要讲一些很硬核很令人头秃的东西呢?

哈哈,实际上不用担心,这篇文章只是科普性质。

我会从日常工作生活中使用到的一些软件出发,跟大家探讨一下他们的共同点,然后聊聊我从中得到的一些启发。

那么就从看案例开始吧!

案例

案例 1:Microsoft Office

Office 大家都知道,这个就不用解释了,我想提到的是其中的一个功能。一个程序员不常用,而财会文秘人员却很常用的东西——VBA。

Office 全家桶里面最常用的 Word、Excel、Powerpoint 都支持使用 VBA 脚本。使用 VBA 脚本可以自动化地去完成一些重复机械的任务,完成 UI 界面点不出来的功能。甚至还可以整出一些花活儿,例如在 excel 里面制作一个小游戏或者播放一段鬼畜视频~

案例 2:键鼠模拟软件

一个非常典型的例子就是 按键精灵。这是一个已经很久不更新的软件,可能 90 后的同学们会有一些印象。

这款软件最主要的功能就是用来模拟鼠标键盘的操作,用来解放双手。一些机械重复的事情就可以用它来完成,比如最常见的用法就是用来玩游戏。

那么如何控制什么时候按下鼠标、什么时候按下键盘呢?鼠标要点哪个位置,键盘要按下哪个按键呢?

按键精灵提供了一个脚本机制,我们可以在脚本编辑器里面编排这些行为,把脚本保存起来重复使用。

详情可以自行登录 官网 查看文档、截图视频。

虽然现在这个软件不更新了,但至今仍有很多这样的同类软件出现。无论 PC 端还是移动端,各种各样的 “**精灵”、“**助手”、“自动**”,普遍都是这么玩的。

案例 3:nginx

从这个案例开始,就进入到我们程序员的专业领域了。

nginx 这个软件相信大家都不陌生吧?即使是前端同学也应该是有所耳闻的,毕竟大家工作的项目很大概率会涉及到这个东西。

nginx 在生产上的使用,主要是用来做反向代理和负载均衡。

初级的用法就是使用配置文件,它有一个丰富的配置文件规则,我们可以在里面进行监听端口、静态文件路径、转发规则之类的配置。

然而在实际生产中,有些行为是动态变化的,有些行为是有逻辑的,并不适合写死在配置文件里。

为了符合这一诉求,nginx 就引入了脚本引擎,我们可以使用一些脚本语言编写代码去做一些配置文件做不到的事情。

1_nginx_1678800144104.svg

虽然支持众多脚本语言,但由于 OpenResty 的广泛流行,lua 成为了 nginx 最常见的搭配。

例如 B 站发表的技术文章就有提到了他们在 nginx 上使用 lua:2021.07.13 我们是这样崩的

下面我们用一段代码举例,感受一下如何使用脚本代码去调度 nginx 的行为。

为了让前端同学更容易阅读,我这里从 官方示例 中摘抄一段 js 的代码进行说明。

首先,在 nginx.conf 配置文件里面这么配置

 load_module modules/ngx_http_js_module.so;

 events {}

 http {
   js_path "/etc/nginx/njs/";

   js_import utils.js;
   js_import main from http/hello.js;

   server {
     listen 80;

     location = /version {
        js_content utils.version;
     }

     location / {
       js_content main.hello;
     }
  }
}

实际上都不需要介绍 api,光是看关键字也看得出来功能了:js_pathjs_importjs_content 这些关键字都是跟 js 相关。作用分别是指定 js 路径、导入 js 文件、执行 js 函数。

我们导入了一个 hello.js 文件,那它内容是什么呢?

function hello(r) {
  r.return(200, "Hello world!\n");
}

export default { hello };

返回一个状态码 200 的 HTTP 请求,报文体内容是“Hello world!”。

用 curl 验证一下功能:

curl http://localhost/
Hello world!

curl http://localhost/version
0.4.1

在这个示例中,我们用 js 代码去拦截了 HTTP 请求,并修改了其返回内容。

案例 4:游戏引擎

在网络上,存在感比较高的游戏引擎,有 Unity虚幻Cocos Creator 等。

在使用上,有很多共性。例如提供了很多图形界面操作让你去调整模型、特效、素材,提供了脚本机制让你去对游戏行为进行编程等等。

这些游戏引擎支持各种各样的脚本编程(从官方文档上查到的):

  • Unity:C#、JavaScript、Boo(新版本不再支持)
  • 虚幻:C++、蓝图(称为脚本但实际是可视化编程)
  • Cocos Creator:TypeScript、JavaScript(使用场景受限)

这是一段 Cocos Creator 的脚本示例,使用 TypeScript 编写。

import { _decorator, Component, Node } from "cc";
const { ccclass, property } = _decorator;

@ccclass("say_hello")
export class say_hello extends Component {
  start() {}

  update(deltaTime: number) {}
}

乍一眼看上去很像一个前端组件(类比 vue、react)吧?有生命周期钩子,有事件钩子。初始化的时候做什么事情、每帧画面刷新的时候做什么事情、被鼠标点到了做什么事情......程序员只需把这些逻辑填充到相关的类、函数里面,这就算是开始进行游戏开发了。

当然了,只是“开始进行游戏开发”。游戏领域的技术还是很复杂的,个人认为游戏开发比前端开发难多了,因为我学不会~

案例 5:跨端开发工具

这里以 React Native 为例吧,各位前端同学大概率也是听说过这个东西的。

摘抄一段来自 官网 的描述:

React Native is an open source framework for building Android and iOS applications using React and the app platform’s native capabilities. With React Native, you use JavaScript to access your platform’s APIs as well as to describe the appearance and behavior of your UI using React components: bundles of reusable, nestable code.

简单来说,我们用这一个框架开发出来的 app,可以分别打包出 Android 和 iOS 两种格式的安装包安装使用。而开发 app 的过程,无论是视图还是交互,都是使用 js 语言进行开发。

有涉及 jsx,但 jsx 最终都会编译成 js,所以就不单独讨论了。

并且 React Native 里面的 js 代码还可以调用 Android 和 iOS 的原生库,以实现对一些底层功能的调用,比如蓝牙、TCP 连接。

案例 6:华为云 AppCube

摘抄一段来自 官网 的描述:

应用魔方 AppCube 是华为云为行业客户、合作伙伴、开发者量身打造的低代码/零代码应用开发平台,提供全场景可视化开发能力和端到端部署能力,可快速搭建行业和大型企业级应用并沉淀复用行业资产,加速行业数字化。

总之,它是一款低代码开发平台。

低码平台往往会有一个功能:可视化编程。AppCube 也不例外。

有可视化编程是方便了,非程序员也可以搭建应用了,是好事没错。然而......

如果让你用托拉拽的形式写一段快速排序算法,低码平台的能力足以支撑你做这样的一件事情吗?如果能支撑,性能又会如何呢?

在我们实际业务中肯定会遇到这样的一些问题。光是可视化编程不足以支撑这样子的业务需求。所以低码平台们往往也会想一些办法去做编程语言的支持。

例如,AppCube 就推出了一个脚本能力。

摘自 官网

针对业务逻辑比较复杂的场景,低代码平台提供了脚本(Script)能力,支持用户在线开发 TypeScript 脚本,完成灵活复杂的业务逻辑。

有了这个脚本能力,可视化编程做不了或者做起来效率低的事情,我们就可以通过代码去进行开发了。

思考

上面列举到的软件,各自属于不同领域,软件之间也没有什么关联,我为什么要拿出来放在一起讲呢?

聪明的你已经想到了:都可以写脚本。

不过,我们大费周章列了这么多内容,可不是为了得到上面这几个字。

正经一点儿讨论,我们可以整理出他们有这些共同点:

  1. 宿主 app 提供了非常强大的特性

    • 游戏引擎的图像渲染、碰撞检测……
    • nginx 的 web 服务器、负载均衡……
    • 低代码平台的可视化编程、动态表模型……
    • 键鼠模拟软件的键鼠操作、屏幕捕获……
    • 跨端开发工具的封装层……
    • Office 的办公文档处理……
  2. 这些强大的特性,仅通过常规的使用方式难以发挥出全部能力。需要提供一些“编程式”的调用手段,让用户得以灵活组装自己的业务逻辑。

  3. 宿主 app 一般基于比较“重”的编程语言开发。若强制使用宿主 app 本身的技术栈去调用这些功能,会遇到若干问题。例如开发效率低、灵活性差、平台兼容性不足等。

用一个示意图来表示一下,大概就是这样子:

2_actor_1678800119504.svg

这里就可以回归主题了,这些厚重强大的应用软件,作为宿主 app,为了更好的服务用户,他们都使用了内嵌的脚本引擎

这些内嵌的脚本引擎,由于与宿主 app 是跑在同一个进程中的,使用相同语言编写,甚至代码都可能在同一个工程里面。

而脚本引擎普遍都会提供一些注入功能,宿主 app 可以在代码中向脚本引擎里面注入一些函数、变量、结构体等等。

所以,我们的脚本代码跑在脚本引擎里面的时候,便可以很轻易调用到宿主 app 的功能。

注:我这里只是笼统地讲。实际生产上,视技术栈不同,底层实现也不同,可能会有一些特殊情况。这篇文章作为科普暂不深究技术细节。还有,“宿主 app”不是什么严谨的学术概念,只是在本文中为了描述方便而使用的词汇。

从这个角度来看,网页浏览器也是这样的,甚至 nodejs、deno、Cpython、Ipython 这类不带任何业务功能的 runtime 也符合这一特征。例如,chrome 浏览器向 V8 引擎中注入了 DOM API、BOM API,而 nodejs 向 V8 引擎中注入了操作系统 API。

做个实验

我这里用两个不同的 js 引擎(js 解释器)来举例。大家可以根据各自需求查看相关内容。

goja 是用 go 语言实现的,它功能没有 V8 那么复杂,API 也比较简单。

V8 用在生产上更多,但因为是用 C++ 编写,又经过了数不清的迭代演进,会显得复杂一些。

下面将会向这两个解释器中分别注入一个 print 函数,在 js 代码中调用这个函数,把字符串打印到控制台上。

如果你在浏览器或者 nodejs 里面 print 函数,是无法达到这个效果的。因为浏览器注入的 print 函数是调用打印机的,而 nodejs 直接没有注入这个函数,所以会报错未定义。而经过我们配置过的解释器,表现就不一样。

goja 示例

我们写这么一段 go 语言代码

我们首先定义一个 print 函数

func print(str string) {
    fmt.Println(str)
}

创建一个 goja 实例, 然后把 print 注入到 goja 解释器实例中。解释执行 js 代码:print("你好")

func main() {
    vm := goja.New()
    vm.Set("print", print)
    vm.RunString("print('你好')")
}

这就是一个非常简单的宿主 app 了,它里面裹着一个 goja 解释器。

只需要把它运行,就可以在控制台得到输出

你好

这里为有兴趣的同学提供一份完整的复现教程:

点击展开复现教程

操作系统:windows 终端环境:git bash

首先去到官网下载 go 语言编译器并安装。我这里用的是 1.20.1 版本,下载链接是 这个

创建一个文件夹,随便取个名字

mkdir goja_example
cd goja_example

文件夹中创建一个文本文件,命名为 example.go

touch example.go

使用一个文本编辑器打开 example.go,把如下内容粘贴进去

package main

import (
    "fmt"
    "github.com/dop251/goja"
)

func print(str string) {
    fmt.Println(str)
}

func main() {
    vm := goja.New()
    vm.Set("print", print)
    vm.RunString("print('你好')")
}

编译前的一些准备工作

export GO111MODULE=on
export GOPROXY=https://repo.huaweicloud.com/repository/goproxy/
export GONOSUMDB=*
go mod init example
go mod tidy

编译执行

go build example.go  && ./example.exe

V8 示例

首先,我们准备一个 Print 函数。不需要我们自己写,从 V8 开源代码仓里面的 示例文件 复制过来就行。

void Print(const v8::FunctionCallbackInfo<v8::Value>& args) {
  // 减少篇幅占用,省略内容。函数的功能是把字符串打印在控制台上。
}

然后,基于另一个 示例文件 进行修改。

在我们执行 js 代码之前,先把 Print 函数注入进去

v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
global->Set(isolate, "print", v8::FunctionTemplate::New(isolate, Print));
v8::Local<v8::Context> context = v8::Context::New(isolate, NULL, global);

解释执行 print('你好') 这句 js 代码

v8::Local<v8::String> source = v8::String::NewFromUtf8Literal(isolate, "print('你好')");
v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked();
v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();
v8::String::Utf8Value utf8(isolate, result);

我这里笼统地说成是“解释执行”了。但 V8 经过多年迭代,现在的底层运作十分复杂,引入了 JIT 等机制,还存在一些优化和反优化措施,对于一些步骤可能称其为“编译”更合理。由于这篇文章不深入讨论 V8 原理,所以这里就暂且忽略这些细节了。

我们将会得到一个由 C++ 编写的宿主 app,里面包裹着一个 V8 解释器。

将宿主 app 运行起来,控制台可以得到输出。

你好

这里为有兴趣的同学提供一份完整的复现教程:

点击展开复现教程

基本上就是照着 官方教程 来就行,实验过程涉及一些文件改动,对环境也有一些要求,所以我这里还是把我的操作列出来方便复现

编译机器:unbuntu 2022 64 位 用户:我这里用的是 root,但建议使用非 root 用户

如果由于公司内网原因或网络过慢的原因,需要使用代理的,可以配置一下环境变量

export http_proxy=<你的代理服务器>
export https_proxy=$http_proxy
export ftp_proxy=$http_proxy

如果是新机器,会缺少一些依赖,编译不动 V8,事先装上

sudo apt update
sudo apt install python-is-python3 pkg-config

下载 depot_tools

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=`pwd`/depot_tools:$PATH

如果你正在使用 root 用户进行编译,需要使用 vim 编辑 depot_tools/update_depot_tools 这个文件,把文件开头的这几行注释掉:

# if [ "$USER" == "root" ];
# then
#   echo Running depot tools as root is sad.
#   exit
# fi

依次执行以下命令:

# 更新依赖,这一步花时间比较久
gclient

# 下载 V8 工程代码,切换到官方验证过的可通过编译的版本
# V8 发版本非常频繁,但不是什么版本都能轻易编译通过的,只有用官方验证过的这个版本最稳
fetch v8
cd v8
git checkout refs/tags/10.5.1 -b sample -t

# 生成配置文件
tools/dev/v8gen.py x64.release.sample

# 修改一个配置参数
# 这里官方教程没讲,代码里面用了一些过时的 API,如果不加这个参数的话,会编译不通过
echo "treat_warnings_as_errors = false" >> out.gn/x64.release.sample/args.gn

# 把 V8 代码编译成静态链接库
ninja -C out.gn/x64.release.sample v8_monolith

这里编译出来的 V8 是一个静态链接库。

静态链接库本身不能直接运行使用。我们需要写一个宿主软件去调用 V8,将宿主软件编译成可执行文件,才能在机器上运行起来。

这里比较省事,因为 V8 的工程的 samples 目录里面已经自带示例了,我们只需要按照官方教程去把示例编译起来就行。

g++ -I. -Iinclude samples/hello-world.cc -o hello_world -fno-rtti -lv8_monolith -lv8_libbase -lv8_libplatform -ldl -Lout.gn/x64.release.sample/obj/ -pthread -std=c++17 -DV8_COMPRESS_POINTERS

验证一下示例是不是好的

./hello_world

能打印出如下的结果则说明编译出来的程序没问题

Hello, World!
3 + 4 = 7

samples/hello-world.cc 复制一份,在上面进行少量修改即可完成实验目标

cp samples/hello-world.cc samples/hello-world2.cc

samples/shell.cc 里面把 Print 函数搬过来。进行少量修改,并去掉一些不影响编译的注释和代码以维持版面整洁。最终改完的 hello-world2.cc 长这样子:

点击展开代码内容
// Copyright 2015 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "include/libplatform/libplatform.h"
#include "include/v8-context.h"
#include "include/v8-initialization.h"
#include "include/v8-isolate.h"
#include "include/v8-local-handle.h"
#include "include/v8-primitive.h"
#include "include/v8-script.h"
#include "include/v8-template.h"

// 需要注入的全局函数。这个函数是从 samples/shell.cc 文件里面复制过来的
void Print(const v8::FunctionCallbackInfo<v8::Value>& args) {
  bool first = true;
  for (int i = 0; i < args.Length(); i++) {
    v8::HandleScope handle_scope(args.GetIsolate());
    if (first) {
      first = false;
    } else {
      printf(" ");
    }
    v8::String::Utf8Value str(args.GetIsolate(), args[i]);
    const char* cstr = *str;
    printf("%s", cstr);
  }
  printf("\n");
  fflush(stdout);
}


int main(int argc, char* argv[]) {
  // 初始化 V8
  v8::V8::InitializeICUDefaultLocation(argv[0]);
  v8::V8::InitializeExternalStartupData(argv[0]);
  std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform();
  v8::V8::InitializePlatform(platform.get());
  v8::V8::Initialize();

  // 创建 Isolate
  v8::Isolate::CreateParams create_params;
  create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator();
  v8::Isolate* isolate = v8::Isolate::New(create_params);

  {
    v8::Isolate::Scope isolate_scope(isolate);
    v8::HandleScope handle_scope(isolate);

    // 注入全局函数
    v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
    global->Set(isolate, "print", v8::FunctionTemplate::New(isolate, Print));

    // 创建上下文
    v8::Local<v8::Context> context = v8::Context::New(isolate, NULL, global);
    v8::Context::Scope context_scope(context);

    {
      // 解释执行
      v8::Local<v8::String> source = v8::String::NewFromUtf8Literal(isolate, "print('你好')");
      v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked();
      v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();
      v8::String::Utf8Value utf8(isolate, result);
    }

  }

  // 一些清理工作
  isolate->Dispose();
  v8::V8::Dispose();
  v8::V8::DisposePlatform();
  delete create_params.array_buffer_allocator;
  return 0;
}

同样的编译命令,换个文件名

g++ -I. -Iinclude samples/hello-world2.cc -o hello_world2 -fno-rtti -lv8_monolith -lv8_libbase -lv8_libplatform -ldl -Lout.gn/x64.release.sample/obj/ -pthread -std=c++17 -DV8_COMPRESS_POINTERS

运行

./hello_world2

输出结果:

你好

扩展一下

关于多语言混合编程

除了内嵌脚本引擎以外,还有一些场景也存在多语言混合编程的用法,但原理各不相同。

举例:

  • 脚本语言调用 c/c++ 原生库

    nodejs、Cpython 等 runtime 本身就是用 c/c++ 编写,天然具备调用动态链接库的能力。把这个能力封装后注入相关接口给解释器即可。注入的方式跟我们刚才讲的一样。大多情况下是在同一进程中完成,无需 IPC

  • 使用多种语言编写的微服务应用

    不同进程(服务)各自拉起运行,主要通过套接字这一途径进行 IPC。

  • OJ 系统

    web 服务进程 fork 出子进程,在子进程中运行其他的可执行文件。包括编译器、runtime 程序、沙盒封装程序等。父子进程主要通过管道这一途径进行 IPC(用于传递测试用例)。

  • js 调用其他语言编写的构建工具

    例如 esbuild 就提供了 js 封装的 npm 包,其调用方式也是拉起子进程。以 windows 为例,我们通过 js 代码调用 esbuild 的时候,nodejs 会在子进程中把 esbuild.exe 运行起来。

  • java 技术栈的 GraalVM

    这个主要是在编译器上面做文章。它在 jvm 上面套了一个封装层,这个封装层可以将 C++、js、python 等不同语言都编译/解释成 jvm 能认识的形式,统一放在 jvm 里面执行。这样 GraalVM 就成为了一个跨语言的通用虚拟机,可以很轻松地完成多语言混合编程。

关于内嵌 nodejs

上面的场景有提到,chorme 和 nodejs 是内嵌了 V8 解释器的,各种 API 都由宿主 app 注入。

那假如我开发了一款 C++ 应用程序,也想做脚本支持,我自然可以在自己的代码中调用 V8 解释器。可如果我也想要在脚本编程的时候使用事件循环和操作系统 API,那怎么办?需要我自己去实现这些功能注入到 V8 中吗?

这种情况下,我们直接使用 nodejs 的 C++ embedder API 即可。nodejs 不仅可以编译为一个独立的软件,它也可以作为一个库被其他软件调用。

总结

在我们的生产和生活中,内嵌脚本引擎的应用非常广泛。而使用的时候,开发者们往往会向脚本引擎中注入一些东西,包括函数、结构体、变量等,以实现一些需求。

如果脚本引擎本身仅仅是实现了相关语言的语法规范,而没有宿主 app 注入进去的业务功能和操作系统 API,那仅仅就只能用来进行一些简单的运算操作。不仅无法操作网络、文件系统、图形界面、多进程等功能,甚至连往控制台打印一行“hello world”都做不到(因为标准输入输出流也是操作系统的功能)。在现代社会中,这样子的软件基本不会有使用场景。

试想一下,如果网页浏览器里面的 js 无法调用 DOM API、BOM API,那 web 世界会变成什么样?如果 nodejs 里面操作不了文件和网络、没有事件循环机制,今天的大前端技术栈又会是什么样子的呢?

宿主 app 由于使用了脚本引擎,添加了脚本语言支持,而更好地为用户提供服务。相关的引擎和语言也因为能调用这些注入的功能才得以发光发热。它们是相辅相成的

参考链接