用 Addon 增強(qiáng) Node.js 和 Electron 應(yīng)用的原生能力
前言
Node.js Addon 是 Node.js 中為 JavaScript 環(huán)境提供 C/C++ 交互能力的機(jī)制。其形態(tài)十分類似 Java 的 JNI,都是通過提供一套 C/C++ SDK,用于在 C/C++ 中創(chuàng)建函數(shù)方法、進(jìn)行數(shù)據(jù)轉(zhuǎn)換,以便 JavaScript / Java 等語言進(jìn)行調(diào)用。這樣編寫的代碼通常叫做 Bindings。
此外還有基于 C ABI Calling Convention (例如 stdcall / System-V 等標(biāo)準(zhǔn)) 直接進(jìn)行跨語言調(diào)用的方案,例如 Rust FFI、Python 的 ctypes、Node.js 的 ffi 包等。這兩者的差別在于 Rust 等原生語言是直接針對平臺來將函數(shù)調(diào)用編譯為機(jī)器碼,而 ctypes 和 ffi 包則是基于 libffi 動態(tài)生成機(jī)器碼來完成函數(shù)調(diào)用的。和 Node.js Addon 的差別則在于調(diào)用和類型轉(zhuǎn)換的開銷上。
本文將圍繞 Node.js Addon 進(jìn)行介紹,即創(chuàng)建一個 Bindings 來增強(qiáng) Node.js 或 Electron 應(yīng)用的原生能力,使其可以和系統(tǒng)進(jìn)行交互,或者使用一些基于 C/C++ 編寫的第三方庫。
Node.js 和 Electron 的關(guān)系
Electron 在主進(jìn)程和渲染進(jìn)程中都包含了完整的 Node.js 環(huán)境,因此本文既適用于 Node.js 程序,也適用于 Electron 程序。
Node.js Addon 的類型
在 Node.js 的 Addon,有三種類型:
本文主要介紹 Node-API 的原理,以及以 node-addon-api 作為例子。
Node-API 基本原理
Node.js 本質(zhì)上是一個動態(tài)鏈接庫(即 Windows 下的 .dll
文件、MacOS 下的 .dylib
文件、Linux 下的 .so
文件),只不過在分發(fā)時會將文件的擴(kuò)展名改為 .node
加載
Node.js Addon 通常通過 CommonJS 的 require 函數(shù)進(jìn)行導(dǎo)入和初始化。require 在被 .node 擴(kuò)展名路徑作為參數(shù)進(jìn)行調(diào)用的情況下,最終會利用 dlopen
(Windows 下是 LoadLibrary
)方法來動態(tài)加載這個以 .node 擴(kuò)展名的動態(tài)鏈接庫:
初始化
以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作為參考:
static napi_value Init(napi_env env, napi_value exports) {
napi_status status;
napi_property_descriptor desc = DECLARE_NAPI_METHOD("hello", Method);
status = napi_define_properties(env, exports, 1, &desc);
assert(status == napi_ok);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
NAPI_MODULE 宏用來綁定一個 C 函數(shù)作為初始化函數(shù)。這個函數(shù)中可以用來給模塊的 exports
對象添加所需要的功能。
例如上述的代碼中,給 exports
添加了一個叫做 hello
的函數(shù)。這樣一來,我們在 Node.js 中 require 這個模塊之后,就能獲得到一個包含 hello
函數(shù)的 exports 對象:
調(diào)用
以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作為參考:
static napi_value Method(napi_env env, napi_callback_info info) {
napi_status status;
napi_value world;
status = napi_create_string_utf8(env, "world", 5, &world);
assert(status == napi_ok);
return world;
}
Method 本身是一個 C 函數(shù),接受 napi_env
作為 JavaScript 的上下文信息。napi_callback_info
作為當(dāng)前函數(shù)調(diào)用的信息,例如函數(shù)參數(shù)等。返回一個 napi_value
作為函數(shù)的返回結(jié)果。
從這個函數(shù)的例子中可以看到,在 C 中是可以獲取到函數(shù)的調(diào)用參數(shù),并且產(chǎn)生一個值作為函數(shù)的返回結(jié)果。稍后我們會以 node-addon-api 作為例子來具體介紹其編寫方式。
模塊編寫指南
本節(jié)介紹使用 C++ 配合 node-addon-api 開發(fā)模塊時常見的一些模式和樣板代碼,僅供參考。
更多用法詳見官方文檔:https://github.com/nodejs/node-addon-api/blob/main/doc/hierarchy.md
模塊初始化
使用 NODE_API_MODULE 宏綁定一個 C++ 函數(shù)進(jìn)行模塊初始化:
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "hello"),
Napi::Function::New(env, Method));
return exports;
}
NODE_API_MODULE(hello, Init)
- 其中
Napi::Env
是對 napi_env 的封裝,代表一個 JavaScript 上下文,大部分和 JavaScript 交互的場景都需要這個上下文,可以保存起來以供下次使用(但是不要跨線程使用)。 Napi::Object exports
則是這個模塊的 exports 對象,可以把想要給 JavaScript 暴露的值和函數(shù)都設(shè)置到這個上面。
創(chuàng)建 JavaScript 函數(shù)
首先需要創(chuàng)建一個如下函數(shù)簽名的 C++ 函數(shù):
Napi::Value Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
double arg0 = info[0].As<Napi::Number>().DoubleValue();
double arg1 = info[1].As<Napi::Number>().DoubleValue();
Napi::Number num = Napi::Number::New(env, arg0 + arg1);
return num;
}
其中函數(shù)的返回值可以是任何派生自 Napi::Value
的類型,也可以是 Napi::Value
本身。
獲取函數(shù)參數(shù)
通過 Napi::CallbackInfo&
來獲取函數(shù)參數(shù),例如 info[0]
代表第一個參數(shù)。
info[n]
會獲取一個 Napi::Value
值,我們需要調(diào)用它的 As<T>
方法來轉(zhuǎn)換為具體的值,我們才能將它繼續(xù)轉(zhuǎn)換為 C/C++ 可用的數(shù)據(jù)類型。例如,我們希望將函數(shù)的第一個參數(shù)轉(zhuǎn)換為字符串,我們需要經(jīng)過兩個步驟:
- 將 Napi::Value 轉(zhuǎn)換為 Napi::String:
Napi::String js_str = info[0].As<Napi::String>();
- 將 Napi::String 轉(zhuǎn)換為
std::string
std::string cpp_str = js_str.Utf8Value();
其他數(shù)據(jù)類型例如 Napi::Number
、Napi::Buffer<T>
均有類似的方法。
返回函數(shù)結(jié)果
我們可以直接創(chuàng)建一個 JavaScript 值并在 C++ 函數(shù)中返回。具體創(chuàng)建值的方法詳見下一小節(jié)。
創(chuàng)建 JavaScript 值
我們可以利用各種實(shí)例化方法,來從 C/C++ 的數(shù)據(jù)類型中創(chuàng)建 JavaScript 的值,下面舉幾個常見的例子。
創(chuàng)建字符串
Napi::String::New(env, "字符串內(nèi)容")
創(chuàng)建數(shù)字
Napi::Number::New(env, 123)
創(chuàng)建 Buffer
創(chuàng)建 Buffer 是一個有風(fēng)險的操作。Node-API 提供了兩種創(chuàng)建方式:
- 提供一個指針和數(shù)據(jù)長度,創(chuàng)建一個數(shù)據(jù)的拷貝
- ? 安全,首選這種方法
- ? v8 會負(fù)責(zé)這個 Buffer 的垃圾回收
Napi::Buffer::Copy(napi_env env, const T* data, size_t length)
- 直接基于指針和數(shù)據(jù)長度創(chuàng)建一個 External Buffer
- ?? 同一個指針(相同的內(nèi)存地址)只能創(chuàng)建一個 Buffer,重復(fù)創(chuàng)建會引起錯誤
- ?? v8 / Node.js 不負(fù)責(zé)這個 Buffer 的內(nèi)存管理
Napi::Buffer::New(napi_env env, const T* data, size_t length)
異步代碼
異步函數(shù)
異步函數(shù)通常用于實(shí)現(xiàn)一些異步 IO 任務(wù)、事件,例如實(shí)現(xiàn)一個異步網(wǎng)絡(luò)請求庫的綁定。
異步函數(shù)通常有兩種實(shí)現(xiàn)方式:回調(diào) 和 Promise。
同線程回調(diào)
同線程回調(diào)的使用場景比較少:
- 使用了 libuv 來運(yùn)行了一些異步任務(wù),并且這個異步任務(wù)會在 libuv 主線程喚醒事件循環(huán)來返回結(jié)果,這時候可以比較安全地直接進(jìn)行同線程回調(diào)。但是要求事先把 Napi::Env 保存在一個地方。
- 實(shí)現(xiàn)一個函數(shù)的時候,在實(shí)現(xiàn)中直接同步調(diào)用一個 Napi::Function。
獲取函數(shù)
通常我們會從函數(shù)調(diào)用的參數(shù)中獲取到 Napi::Function
,一般來說我們需要在當(dāng)次調(diào)用就把這個函數(shù)給使用掉,避免后續(xù)被 v8 GC 回收。
持久化函數(shù)
如果我們確實(shí)需要在之后的其他時機(jī)去使用函數(shù),我們需要將它通過 Napi::Persistent
持久化:
Napi::FunctionReference func_persist = Napi::Persistent(func);
使用時,可以作為一個正常的函數(shù)去使用。
調(diào)用函數(shù)
無論是 Napi::Function
還是 Napi::FunctionReference
,我們都可以通過 Call
方法來調(diào)用:
Napi::Value ret_val = func_persist.Call({
Napi::String::New(env, "Arg0")
});
跨線程回調(diào)
跨線程回調(diào)是比較常見使用場景,因?yàn)槲覀兺ǔ朐诹硗庖粋€線程調(diào)用 JavaScript 函數(shù)。
使用線程安全函數(shù) (ThreadSafeFunction)
為了在其他線程中調(diào)用 JavaScript 函數(shù),我們需要基于 Napi::Function
去創(chuàng)建一個 Napi::ThreadSafeFunction
。
Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
env, // Napi::Env
info[0].As<Function>(), // JavaScript 函數(shù)
"handler", // 異步函數(shù)的名稱,用于調(diào)試的識別
0, // 隊列最大大小,通常指定為 0 代表沒有限制。如果隊列已滿則可能會導(dǎo)致調(diào)用時阻塞。
1 // 初始線程數(shù)量,通常指定為 1。實(shí)際上是作為內(nèi)存管理使用。可參考這篇文檔。
);
接著就可以把 tsfn
保存在任何位置,并且并不需要同時保存一份 Napi::Env
。
調(diào)用線程安全函數(shù)
調(diào)用線程函數(shù)有兩種形式,一種是同步調(diào)用,另一種是異步調(diào)用。
同步調(diào)用
同步調(diào)用指的是如果我們限制了 ThreadSafeFunction 的隊列大小,并對其進(jìn)行了多次調(diào)用,從而創(chuàng)建了許多調(diào)用任務(wù),則會導(dǎo)致隊列已滿,調(diào)用就會被阻塞,直到成功插入隊列后返回結(jié)果。
這是進(jìn)行一次同步調(diào)用的例子:
const char* value = "hello world";
napi_status status = tsfn.BlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
Napi::String arg0 = Napi::String::New(env, value);
callback.Call({ arg0 });
});
這樣一來就能順利地在任意線程去調(diào)用 JavaScript 函數(shù)。
但是我們發(fā)現(xiàn),實(shí)際上我們并不能同步地獲取函數(shù)調(diào)用的返回結(jié)果。并且 Node-API 或者 node-addon-api 都沒有提供這么一種機(jī)制。但是我們可以借助 libuv 的信號量來達(dá)到這個目的。
uv_sem_t sem;
uv_sem_init(&sem, 0);
const char* value = "hello world";
Napi::Value ret_val;
napi_status status = tsfn.BlockingCall(value, [&ret_val](Napi::Env env, Napi::Function callback, const char* value) {
Napi::String arg0 = Napi::String::New(env, value);
*ret_val = callback.Call({ arg0 });
uv_sem_post(&sem);
});
uv_sem_wait(&sem);
// 直至 JavaScript 運(yùn)行結(jié)束并返回結(jié)果,才會走到這里
// 這里就可以直接使用 ret_val 了
異步調(diào)用
異步調(diào)用則會在隊列已滿時直接返回錯誤狀態(tài)而不進(jìn)行函數(shù)調(diào)用。除此之外的使用方法同 “同步調(diào)用” 完全一致:
const char* value = "hello world";
napi_status status = tsfn.NonBlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
Napi::String arg0 = Napi::String::New(env, value);
callback.Call({ arg0 });
});
Promise
C++ 中創(chuàng)建 Promise 給 JavaScript 使用
我們通常會需要在 C++ 中實(shí)現(xiàn)異步函數(shù)。除了直接用上面已經(jīng)介紹的基于回調(diào)的方法之外,我們還可以直接在 C++ 中創(chuàng)建一個 Promise。
Promise 只支持同 線程 調(diào)用
由于 Promise 并未提供跨線程 Resolve 的方式,因此如果希望在其他線程對 Promise 進(jìn)行 Resolve 操作,則需要結(jié)合 libuv 來實(shí)現(xiàn)。此方法比較繁瑣,建議轉(zhuǎn)而使用跨線程回調(diào)函數(shù)。如果讀者感興趣,后續(xù)本文可以補(bǔ)充相關(guān)內(nèi)容。
我們可以直接創(chuàng)建一個 Promise,并在函數(shù)中返回:
Napi::Value YourFunction(const Napi::CallbackInfo& info) {
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(info.Env());
// 我們可以把 env 和 Napi::Promise::Deferred 保存在任何地方。
// deferred_ 會在 Resolve 或者 Reject 之后釋放。
env_ = info.Env();
deferred_ = deferred;
return deferred.Promise();
}
接著我們可以在其他地方調(diào)用 Napi::Promise::Deferred
來完成 Promise。注意,這里一定需要在主線程中調(diào)用:
// 返回成功結(jié)果
deferred_.Resolve(Napi::String::New(info.Env(), "OK"));
// 返回錯誤
deferred_.Reject(Napi::String::New(info.Env(), "Error"));
C++ 中使用來自 JavaScript 的 Promise
由于 Node-API 或者 node-addon-api 均沒有提供使用 Promise 的封裝,因此我們需要像在 JavaScript 中通過 .then
手動使用 Promise 的方式,在 C++ 中使用 Promise。
// 首先需要定義兩個函數(shù),用來接受 Promise 成功和失敗
Napi::Value ThenCallback(const Napi::CallbackInfo &info) {
Napi::Value result = info[0];
// result 是 Promise 的返回結(jié)果
return info.Env().Undefined();
}
Napi::Value CatchCallback(const Napi::CallbackInfo &info) {
Napi::Value error = info[0];
// error 是 Promise 的錯誤信息
return info.Env().Undefined();
}
Napi::Promise promise = async_function.Call({}).As<Napi::Promise>()
Napi::Value then_func = promise.Get("then").As<Napi::Function>();
then_func.Call(promise, { Napi::Function::New(env, ThenCallback, "then_callback") });
Napi::Value catch_func = promise.Get("catch").As<Napi::Function>();
catch_func.Call(promise, { Napi::Function::New(env, CatchCallback, "catch_callback") });
顯然這種使用方式是比較繁瑣的,我們也可以通過一些辦法使其可以將 C++ Lambda 作為回調(diào)函數(shù)來使用,但是本文暫時不涉及這部分內(nèi)容。
異步任務(wù)
異步任務(wù)通常是利用 libuv 提供的線程池來運(yùn)行一些 CPU 密集型的工作。而對于一些跨線程異步回調(diào)的 Bindings 實(shí)現(xiàn)則直接使用 ThreadSafeFunction 即可。
具體使用可以參考:https://github.com/nodejs/node-addon-api/blob/main/doc/async_worker.md
Node-API 的構(gòu)建
基本構(gòu)建配置
Node.js Addon 通常使用 node-gyp 構(gòu)建,這是一個基于 Google 的 gyp 構(gòu)建系統(tǒng)實(shí)現(xiàn)的構(gòu)建工具。至于為何是 gyp,因?yàn)?Node.js 是基于 gyp 構(gòu)建的。
我們來看一個 node-addon-api 項目的構(gòu)建配置,以 bindings.gyp
命名:
{
"targets": [
{
"target_name": "hello",
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"sources": [ "hello.cc" ],
"include_dirs": [
"<!@(node -p "require('node-addon-api').include")"
],
'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
}
]
}
具體配置可以參考官方使用文檔:https://gyp.gsrc.io/docs/UserDocumentation.md
一些常識:
"sources"
中需要包含所有 C/C++ 代碼文件,不需要包含頭文件"<!@(node -p "require('node-addon-api').include")"
在使用 Node-API 還是 node-addon-api 的情況下是不同的。"target_name"
通常需要修改為你希望使用的擴(kuò)展名稱,它會影響編譯產(chǎn)物的名稱。
常用構(gòu)建命令
node-gyp rebuild
重新構(gòu)建,會清理掉已有的構(gòu)建緩存,推薦每次都使用這個命令來構(gòu)建產(chǎn)物,避免出現(xiàn)奇怪的問題
- 可以添加
--arch <ARCH>
參數(shù)來指定構(gòu)建的目標(biāo)架構(gòu),例如希望構(gòu)建一個 32 位的產(chǎn)物,則可以使用--arch ia32
來構(gòu)建。
node-gyp clean
清理構(gòu)建緩存。如果希望使用node-gyp build
來進(jìn)行構(gòu)建的話,需要善用 clean 功能。
實(shí)用構(gòu)建配置
添加頭文件目錄
'include_dirs': [
'win32/x64/include'
]
在 Windows 下進(jìn)行動態(tài)鏈接 / 靜態(tài)鏈接
'libraries': [
'some_library.lib'
]
- 對于動態(tài)鏈接,需要指定 .dll 對應(yīng)的 .lib 文件,并在分發(fā)的時候?qū)?.dll 放在 .node 相同的目錄下。
- 對于靜態(tài)鏈接,則直接指定 .lib 文件即可。但是在 Node.js Addon 中進(jìn)行靜態(tài)鏈接是一個比較費(fèi)勁的事情,因?yàn)橥ǔI婕暗綄ζ渌o態(tài)依賴的管理,需要謹(jǐn)慎選擇此方案。
在 Windows 下設(shè)置 C++ 版本
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
'/std:c++20'
]
}
}
在 Windows MSVC 下構(gòu)建支持代碼文件中的 UTF-8 字符(中文注釋等)
本質(zhì)上是給 MSVC 的編譯器添加一個 /utf-8
參數(shù)
'msvs_settings': {
'VCCLCompilerTool': {
"AdditionalOptions": [
'/utf-8'
]
}
}
在 MacOS 下進(jìn)行動態(tài)鏈接 / 靜態(tài)鏈接
'link_settings': {
'libraries': [
'-L<動態(tài)庫或靜態(tài)庫所在的文件夾>',
'-l<動態(tài)庫名稱>'
]
}
在 MacOS 下引入系統(tǒng) Framework 依賴
'libraries': [
'-framework MediaPlayer',
'-framework Cocoa',
]
在 MacOS 下設(shè)置 C++ 版本
"cflags_cc": [
"-std=c++20"
]
在 MacOS Xcode 的 Release 構(gòu)建下生成 .dSYM 調(diào)試文件
'xcode_settings': {
'DEBUG_INFORMATION_FORMAT': 'dwarf-with-dsym'
}
使 MacOS 下的 addon 能夠使用同目錄下的動態(tài)庫 / Framework
'link_settings': {
'libraries': [
'-Wl,-rpath,@loader_path',
## 此外,還可以設(shè)置到任何相對于 .node 文件的其他目錄下
'-Wl,-rpath,@loader_path/../../darwin/arm64',
]
},
但是這也要求 .dylib 文件支持該功能,可以通過 otool -D <你的動態(tài)鏈接庫位置>.dylib
的返回結(jié)果來檢查:
<你的動態(tài)鏈接庫>.dylib:
@rpath/<鏈接庫名稱>.dylib
如果文件名往前的開頭是 @rpath
,則意味著支持該功能。如果不是,則可以使用 install_name_tool
來修改動態(tài)鏈接庫使其支持:
install_name_tool -id "@rpath/<鏈接庫名稱>.dylib" <你的動態(tài)鏈接庫位置>.dylib
在 MacOS 下支持 Objective-C 和 C++ 混編
'xcode_settings': {
'OTHER_CFLAGS': [
'-ObjC++'
]
}
開發(fā)&分發(fā)&使用
項目文件組織
通常來說,我們可以用下面的文件夾結(jié)構(gòu)來扁平地組織我們的 addon 文件:
.
├── node_modules ## npm 依賴
├── build ## 構(gòu)建路徑
│ ├── Release ## Release 產(chǎn)物路徑
│ ├── myaddon.node ## addon 產(chǎn)物
│ ├── myaddon.node.dSYM ## addon 的符號文件
├── binding.gyp ## 構(gòu)建配置
├── addon.cc ## Addon 的 C++ 源碼
├── index.js ## Addon 的 JavaScript 源碼
├── index.d.ts ## Addon 的 TypeScript 類型(下方會介紹)
└── package.json ## Addon 的 package.json 文件
當(dāng)然我們也可以把 JavaScript 源碼和 C++ 源碼分別放入不同的文件夾,只需要修改對應(yīng)的構(gòu)建配置和 package.json 即可。
編寫 index.js - 使用 bindings 包
一般來說我們會直接在 C++ 中實(shí)現(xiàn)大部分邏輯,JavaScript 文件只用來引入 .node 文件。由于 Node.js Addon 存在各種不同的方案、構(gòu)建配置,因此 .node 文件產(chǎn)物的位置可能也會因此不同,所以我們需要借助一個第三方 npm 包來自動為我們尋找 .node 文件的位置:
https://github.com/TooTallNate/node-bindings
通過 bindings,我們的 index.js 僅需一行代碼就能自動獲取并導(dǎo)出 .node 模塊:
module.exports = require('bindings')('binding.node')
同時保證 package.json 的 main
配置為我們的 index.js:
{
// ...
"main": "index.js"
// ...
}
為 Addon 添加 TypeScript 類型
添加 TypeScript 類型,最簡單的方式只需要創(chuàng)建一個 index.d.ts
文件,并在其中聲明在 C++ 代碼中創(chuàng)建的函數(shù)們即可:
export interface FooOptions {
bar: string
}
export function foo(options: FooOptions)
并在 package.json 添加一行參數(shù)用于指向類型文件:
{
// ...
"types": "index.d.ts"
// ...
}
大部分情況下,這個方法就可以給你的 Node.js Addon 聲明類型。
分發(fā)形式
安裝時編譯
一種方式是在使用者進(jìn)行 npm install
時,使用用戶設(shè)備進(jìn)行 Addon 的編譯。這時候我們可以使用 install
鉤子來實(shí)現(xiàn),我們僅需在 package.json
文件中添加如下內(nèi)容:
{
// ...
"scripts": {
// ...
"install": "prebuild-install || node-gyp rebuild --release"
// ...
}
// ...
}
保險起見,確保 node-gyp 在你的 devDependencies
之中,這樣就能在用戶通過 npm 安裝你的 Addon 時,自動編譯當(dāng)前系統(tǒng)架構(gòu)所對應(yīng)的產(chǎn)物。
預(yù)編譯
如果希望更近一步,節(jié)約用戶安裝 Addon 的時間,或者是為了讓用戶無需具備編譯環(huán)境即可安裝 Addon,可以使用預(yù)編譯方案。即在集成環(huán)境中提前編譯常見的操作系統(tǒng)、架構(gòu)對應(yīng)的 .node 文件,并隨著 npm 包進(jìn)行分發(fā),再通過 bindings 或者其他一些庫來自動匹配尋找系統(tǒng)所需要的對應(yīng) .node 文件。
由于預(yù)編譯方案涉及到更多的細(xì)節(jié),本文不再做介紹,大家可以參考該項目: