ちいさな Web ブラウザを作ってみよう
JavaScript エンジンを組み込む
JavaScript エンジンを組み込む
"JavaScript と Web" で言及した通り、V8 は Google による JavaScript / WebAssembly の実行エンジンです。まずはこの V8 エンジンについて簡単に学んだ上で、Part 1 で作った Web ブラウザ に JavaScript エンジンである V8 を組み込んでみましょう。
V8 の基礎的な概念
V8 には isolate, context, handle, handle scope といったコンセプトが存在します。 これらはおよそ以下のようなものです:
- isolate: V8 エンジンのインスタンス
- context: isolate 内に複数存在しうる独立した実行環境
- handle: V8 が持つオブジェクトへのポインタ
- handle scope: handle を取りまとめるもの
isolate は V8 エンジンのインスタンスです。V8 を利用する際には、適当な初期化処理の後、まずはこの isolate を作成してやることになります。
context は isolate 内にさらに作成できる独立した JavaScript 実行環境です。 同一 isolate 内の context は適切な security token というものを伴えば干渉しあうことができますが、さもなくば原則干渉はできないため、無関係な複数の JavaScript を実行する際などに context の切り分けによる隔離が利用できます。
handle は V8 が持つ JavaScript オブジェクトへのポインタです。 handle は大抵 handle scope という複数の handle を束ねるものを用いて管理されます。 これらは V8 を利用する側(C++ コードなど)が V8 内の JavaScript オブジェクトを操作したり、C++ コード側と連携しながら Garbage Collection を行ったりできるようにするために存在しています。
📝 Chromium での利用例
Chromium(Blink)では "Design of V8 bindings" で述べられているような形で isolate や context を利用しています。 JavaScript が実行されうる箇所が拡張機能や Worker 等多岐にわたること、かつそれらの間の隔離をシビアに行う必要があることなどに起因して、isolate や context の用法は少し複雑になっていますが、現代 Web ブラウザにおける用例としては興味深いですね。
📝 V8 のその他の利用例
V8 エンジンは Web ブラウザ外でもしばしば用いられています。その最たる例が Node.js です。 その他の面白い利用例としては、サーバレスプラットフォームである Cloudflare Workers でのサーバレスアプリケーションの実行環境としての利用例や(参考: "How Workers works")、クラウドネイティブな環境での用例が増えている Envoy での WebAssembly ランタイムとしての用例(参考)があります。
実装
V8 を使ったちいさなソフトウェアを書きたい人に向けて、V8 は公式で "Getting started with embedding V8" という、C++ で V8 エンジンを使ったプログラムを書くためのガイドを公開しています。また 実際の小さなプログラムの例(hello-world.cc) も公開されています。ここまでの Web ブラウザの実装では Rust を利用してきましたから、悲しいことに、このガイドはそのまま実践できません。
しかし、Node.js と同じく JavaScript の処理系である Deno が Rust から V8 を利用するためにメンテナンスしているクレート rusty_v8 を利用すると、およそ "Getting started with embedding V8" や hello-world.cc に示されている手順と似たような手順で V8 を Rust から利用できます。 ここからは実際にこのクレートを利用して、Part 1 で作成した Web ブラウザに V8 を組み込んでいきます。
📝 rusty_v8 の中身
rusty_v8 クレートのソースツリーの src/ ディレクトリを見ると分かる通り、rusty_v8 クレート自体は大量の Foreign Functions Interface (FFI) 定義を行いつつ、そのラッパーを作成することにより V8 の Rust インターフェイスを構成しています。
実装の準備
まずは以下のコマンドにより Part 1 で作成した Web ブラウザに src/runtime.rs
を足したものをダウンロードしてください:
git clone https://github.com/tiny-browserbook/exercise-js
実装の方針
上記コマンドにより生成されたディレクトリ exercise-js
の中で cargo test
コマンドを実行すると、テストが 1 つ失敗するのが分かるはずです。これを修正するのがこれから取り組むことです。
具体的には、以下のような JavaScriptRuntime
構造体を定義して、最終的にはその構造体の execute()
メソッドに文字列を与えることで JavaScript が V8 上で実行できるようにしていきます:
pub struct JavaScriptRuntime { /* ... */}
impl JavaScriptRuntime { pub fn new() -> Self { /* ... */ }
pub fn execute(&mut self, filename: &str, source: &str) -> Result</* ... */> { /* ... */ }}
V8 の初期化
hello-world.cc の冒頭では以下のようにして V8 を初期化しています:
// Initialize 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();
これは rusty_v8
クレートを用いると以下のように書くことが出来ます:
use rusty_v8 as v8;
/* ... */
static PUPPY_INIT: Once = Once::new();PUPPY_INIT.call_once(move || { let platform = v8::new_default_platform().unwrap(); v8::V8::initialize_platform(platform); v8::V8::initialize();});
まずは JavaScriptRuntime
に対する new()
の実装の先頭でこのような処理を行うようにしましょう。
Isolate の生成
hello-world.cc では、以下のようにして V8 の isolate を生成しています:
v8::Isolate::CreateParams create_params;create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator();v8::Isolate* isolate = v8::Isolate::New(create_params);
これは rusty_v8
クレートを用いると以下のように書けます:
use rusty_v8 as v8;
/* ... */
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
JavaScriptRuntime
に対する new()
の実装における、V8 の初期化処理の後に、このような isolate の生成処理を追加しておきましょう。
Context の生成
hello-world.cc では isolate の生成の後、以下のような形で context を生成しています:
v8::Isolate::Scope isolate_scope(isolate);// Create a stack-allocated handle scope.v8::HandleScope handle_scope(isolate);// Create a new context.v8::Local<v8::Context> context = v8::Context::New(isolate);// Enter the context for compiling and running the hello world script.v8::Context::Scope context_scope(context);
これは rusty_v8
クレートを用いると以下のように書けます:
use rusty_v8 as v8;
/* ... */
let context = { let isolate_scope = &mut v8::HandleScope::new(&mut isolate); let handle_scope = &mut v8::EscapableHandleScope::new(isolate_scope); let context = v8::Context::new(handle_scope); let context_scope = handle_scope.escape(context); v8::Global::new(handle_scope, context_scope)};
これに関しても先項と同じく、JavaScriptRuntime
に対する new()
の実装における isolate の生成処理の後に、このような context の生成処理を追加しておきましょう。
JavaScriptRuntime
の状態保持
ここで生成した context は JavaScriptRuntime
の execute()
関数の実装時に時折必要になるものです。
そこで、Deno の実装を参考 に、 JavaScriptRuntime
関数の状態を表す以下のようなデータ構造を定義してやることにします:
pub struct JavaScriptRuntimeState { pub context: v8::Global<v8::Context>,}
こうしたデータは V8 の slot という場所に保存することができます。例えば以下のようにです:
use rusty_v8 as v8;
/* ... */
isolate.set_slot(Rc::new(RefCell::new(JavaScriptRuntimeState { context })));
JavaScriptRuntimeState
を定義しつつ、JavaScriptRuntime
の new()
の実装における context の生成処理の後に、このような状態保存用の処理を追加しておきましょう。
JavaScriptRuntime
の定義
ここまでの手順により、以下のような JavaScriptRuntime
構造体や JavaScriptRuntimeState
構造体が定義されているはずです:
use rusty_v8 as v8;use std::{cell::RefCell, rc::Rc, sync::Once};
/// `JavaScriptRuntimeState` defines a state of JS runtime that will be stored per v8 isolate.pub struct JavaScriptRuntimeState { pub context: v8::Global<v8::Context>,}
/// `JavaScriptRuntime` defines a JS runtime with v8./// It has a link to a V8 isolate, and the isolate includes `JavaScriptRuntimeState` in its *slot*.#[derive(Debug)]pub struct JavaScriptRuntime { v8_isolate: v8::OwnedIsolate,}
impl JavaScriptRuntime { pub fn new() -> Self { // init v8 platform just once static PUPPY_INIT: Once = Once::new(); PUPPY_INIT.call_once(move || { let platform = v8::new_default_platform().unwrap(); v8::V8::initialize_platform(platform); v8::V8::initialize(); });
// create v8 isolate & context let mut isolate = v8::Isolate::new(v8::CreateParams::default()); let context = { let isolate_scope = &mut v8::HandleScope::new(&mut isolate); let handle_scope = &mut v8::EscapableHandleScope::new(isolate_scope); let context = v8::Context::new(handle_scope); let context_scope = handle_scope.escape(context); v8::Global::new(handle_scope, context_scope) };
// store state inside v8 isolate isolate.set_slot(Rc::new(RefCell::new(JavaScriptRuntimeState { context })));
JavaScriptRuntime { v8_isolate: isolate, } }
/* ... */}
これで V8 の準備は終了です。
JavaScript の実行処理の実装
hello-world.cc では以下のような形で V8 上で JavaScript を実行しています:
// Create a string containing the JavaScript source code.v8::Local<v8::String> source = v8::String::NewFromUtf8(isolate, "'Hello' + ', World!'", v8::NewStringType::kNormal) .ToLocalChecked();// Compile the source code.v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked();// Run the script to get the result.v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();// Convert the result to an UTF8 string and print it.v8::String::Utf8Value utf8(isolate, result);printf("%s\n", *utf8);
ここからも分かる通り、コードの実行のためには、先ほど作成した context の中で操作を行う必要があります。これを踏まえて、JavaScriptRuntime
の execute()
関数の方も概ね同じような形で実装してみると、例えば以下のようになります:
impl JavaScriptRuntime { /* ... */
/// `execute` runs a given source in the current context. pub fn execute(&mut self, filename: &str, source: &str) -> Result<String, String> { // `JavaScriptRuntimeState` から context handle scope を取り戻して開始 let scope = &mut self.get_handle_scope();
let source = v8::String::new(scope, source).unwrap(); let source_map = v8::undefined(scope); let name = v8::String::new(scope, filename).unwrap(); let origin = v8::ScriptOrigin::new( scope, name.into(), 0, 0, false, 0, source_map.into(), false, false, false, );
let mut tc_scope = v8::TryCatch::new(scope); let script = match v8::Script::compile(&mut tc_scope, source, Some(&origin)) { Some(script) => script, None => { assert!(tc_scope.has_caught()); return Err(to_pretty_string(tc_scope)); } };
match script.run(&mut tc_scope) { Some(result) => Ok(result .to_string(&mut tc_scope) .unwrap() .to_rust_string_lossy(&mut tc_scope)), None => { assert!(tc_scope.has_caught()); Err(to_pretty_string(tc_scope)) } } }}
/// `JavaScriptRuntimeState` から状態を取り戻すための実装群impl JavaScriptRuntime { /// `state` returns the runtime state stored in the given isolate. pub fn state(isolate: &v8::Isolate) -> Rc<RefCell<JavaScriptRuntimeState>> { let s = isolate .get_slot::<Rc<RefCell<JavaScriptRuntimeState>>>() .unwrap(); s.clone() }
/// `get_state` returns the runtime state for the runtime. pub fn get_state(&self) -> Rc<RefCell<JavaScriptRuntimeState>> { Self::state(&self.v8_isolate) }
/// `get_handle_scope` returns [a handle scope](https://v8docs.nodesource.com/node-0.8/d3/d95/classv8_1_1_handle_scope.html) for the runtime. pub fn get_handle_scope(&mut self) -> v8::HandleScope { let context = self.get_context(); v8::HandleScope::with_context(&mut self.v8_isolate, context) }
/// `get_context` returns [a handle scope](https://v8docs.nodesource.com/node-0.8/df/d69/classv8_1_1_context.html) for the runtime. pub fn get_context(&mut self) -> v8::Global<v8::Context> { let state = self.get_state(); let state = state.borrow(); state.context.clone() }}
/// `to_pretty_string` formats the `TryCatch` instance into the prettified error string for puppy.////// NOTE: See the following to get full error information./// https://github.com/denoland/rusty_v8/blob/0d093a02f658781d52e6d70d138768fc19a79d54/examples/shell.rs#L158fn to_pretty_string(mut try_catch: v8::TryCatch<v8::HandleScope>) -> String { // TODO (enhancement): better error handling needed! wanna remove uncareful unwrap(). let exception_string = try_catch .exception() .unwrap() .to_string(&mut try_catch) .unwrap() .to_rust_string_lossy(&mut try_catch); let message = try_catch.message().unwrap();
let filename = message .get_script_resource_name(&mut try_catch) .map_or_else( || "(unknown)".into(), |s| { s.to_string(&mut try_catch) .unwrap() .to_rust_string_lossy(&mut try_catch) }, ); let line_number = message.get_line_number(&mut try_catch).unwrap_or_default(); format!("{}:{}: {}", filename, line_number, exception_string)}
これで JavaScriptRuntime
構造体の準備ができました!
挙動をテストする
試しに JavaScriptRuntime
構造体を利用して JavaScript を実行するようなテストコードを書いてみましょう。例えば以下のようにです:
#[cfg(test)]mod tests { use super::*;
#[test] fn test_execute() { let mut runtime = JavaScriptRuntime::new(); { // a simple math let r = runtime.execute("", "1 + 1"); assert!(r.is_ok()); assert_eq!(r.unwrap(), "2"); } { // simple string operation let r = runtime.execute("", "'test' + \"func\" + `012${1+1+1}`"); assert!(r.is_ok()); assert_eq!(r.unwrap(), "testfunc0123"); } { // use of undefined variable let r = runtime.execute("", "test"); assert!(r.is_err()); } { // lambda definition let r = runtime.execute("", "let inc = (i) => { return i + 1 }; inc(1)"); assert!(r.is_ok()); assert_eq!(r.unwrap(), "2"); } { // variable reuse let r = runtime.execute("", "inc(4)"); assert!(r.is_ok()); assert_eq!(r.unwrap(), "5"); } }}
テストが用意できたら cargo test
コマンドを実行して、正しく JavaScript が実行されるかを確認してみてください。
無事全てのテストが成功すれば V8 を Web ブラウザに組み込むための最初のステップはクリアです。
ふりかえり
本章では V8 の概要を整理した後、実際に rusty_v8
クレートを用いて、V8 上で JavaScript を実行するための構造体 JavaScriptRuntime
を定義しました。これで HTML 中の JavaScript を処理するための準備は整ったことになります。