使用 Buffer 共用Node.js 和 C++數據

2020-08-13 10:39:01

使用 Node.js 開發的一個好處是簡直能夠在 JavaScript 和 原生 C++ 程式碼之間無縫切換 - 這要得益於 V8 的擴充套件 API。從 JavaScript 進入 C++ 的能力有時由處理速度驅動,但更多的情況是我們已經有 C++ 程式碼,而我們想要直接用 JavaScript 呼叫。

我們可以用(至少)兩軸對不同用例的擴充套件進行分類 - (1)C++ 程式碼的執行時間,(2)C++ 和 JavaScript 之間數據流量。

大多數文件討論的 Node.js 的 C++ 擴充套件關注於左右象限的不同。如果你在左象限(短處理時間),你的擴充套件有可能是同步的 - 意思是當呼叫時 C++ 程式碼在 Node.js 的事件回圈中直接執行。

「#nodejs 允許我們在#javascript 和原生 C++ 程式碼之間無縫切換」 via @RisingStack

在這個場景中,擴充套件函數阻塞並等待返回值,意味着其他操作不能同時進行。在右側象限中,幾乎可以確定要用非同步模式來設計附加元件。在一個非同步擴充套件函數中,JavaScript 呼叫函數立即返回。呼叫程式碼向擴充套件函數傳入一個回撥,擴充套件函數工作於一個獨立工作執行緒中。由於擴充套件函數沒有阻塞,則避免了 Node.js 事件回圈的死鎖。

頂部和底部象限的不同時常容易被忽視,但是他們也同樣重要。

V8 vs. C++ 記憶體和數據
如果你不瞭解如何寫一個原生附件,那麼你首先要掌握的是屬於 V8 的數據(可以 通過 C++ 附件獲取的)和普通 C++ 記憶體分配的區別。

當我們提到 「屬於 V8 的」,指的是持有 JavaScript 數據的儲存單元。

這些儲存單元是可通過 V8 的 C++ API 存取的,但它們不是普通的 C++ 變數,因爲他們只能夠通過受限的方式存取。當你的擴充套件 可以 限製爲只使用 V8 數據,它就更有可能同樣會在普通 C++ 程式碼中建立自身的變數。這些變數可以是棧或堆變數,且完全獨立於 V8。

在 JavaScript 中,基本型別(數位,字串,布爾值等)是 不可變的,一個 C++ 擴充套件不能夠改變與基本型別相連的儲存單元。這些基本型別的 JavaScript 變數可以被重新分配到 C++ 建立的 新儲存單元 中 - 但是這意味着改變數據將會導致 新 記憶體的分配。

在上層象限(少量數據傳遞),這沒什麼大不了。如果你正在設計一個無需頻繁數據交換的附加元件,那麼所有新記憶體分配的開銷可能沒有那麼大。當擴充套件更靠近下層象限時,分配/拷貝的開銷會開始令人震驚。

一方面,這會增大最高的記憶體使用量,另一方面,也會 損耗效能。

在 JavaScript(V8 儲存單元) 和 C++(返回)之間複製所有數據花費的時間通常會犧牲首先執行 C++ 賺來的效能紅利!對於在左下象限(低處理,高數據利用場景)的擴充套件應用,數據拷貝的延遲會把你的擴充套件參照往右側象限引導 - 迫使你考慮非同步設計。

V8 記憶體與非同步附件
在非同步擴充套件中,我們在一個工作執行緒中執行大塊的 C++ 處理程式碼。如果你對非同步回撥並不熟悉,看看這些教學(這裏 和 這裏)。

非同步擴充套件的中心思想是 你不能在事件回圈執行緒外存取 V8 (JavaScript)記憶體。這導致了新的問題。大量數據必須在工作執行緒啓動前 從事件回圈中 複製到 V8 記憶體之外,即擴充套件的原生地址空間中去。同樣地,工作執行緒產生或修改的任何數據都必須通過執行事件回圈(回撥)中的程式碼拷貝回 V8 引擎。如果你致力於建立高吞吐量的 Node.js 應用,你應該避免花費過多的時間在事件回圈的數據拷貝上。

理想情況下,我們更傾向於這麼做:

Node.js Buffer 來救命
這裏有兩個相關的問題。

當使用同步擴充套件時,除非我們不改變/產生數據,那麼可能會需要花費大量時間在 V8 儲存單元和老的簡單 C++ 變數之間移動數據 - 十分費時。
當使用非同步擴充套件時,理想情況下我們應該儘可能減少事件輪詢的時間。這就是問題所在 - 由於 V8 的多執行緒限制,我們 必須 在事件輪詢執行緒中進行數據拷貝。
Node.js 裡有一個經常會被忽視的特性可以幫助我們進行擴充套件開發 - Buffer。Nodes.js 官方文件 在此。

Buffer 類的範例與整型陣列類似,但對應的是 V8 堆外大小固定,原始記憶體分配空間。

這不就是我們一直想要的嗎 - Buffer 裡的數據 並不儲存在 V8 儲存單元內,不受限於 V8 的多執行緒規則。這意味着可以通過非同步擴充套件啓動的 C++ 工作執行緒與 Buffer 進行互動。

Buffer 是如何工作的
Buffer 儲存原始的二進制數據,可以通過 Node.js 的讀檔案和其他 I/O 裝置 API 存取。

藉助 Node.js 文件裡的一些例子,可以初始化指定大小的 buffer,指定預設值的 buffer,由位元組陣列建立的 buffer 和 由字串建立的 buffer。

// 10 個位元組的 buffer:const buf1 = Buffer.alloc(10);

// 10 位元組並初始化爲 1 的 buffer:const buf2 = Buffer.alloc(10, 1);

//包含 [0x1, 0x2, 0x3] 的 buffer:const buf3 = Buffer.from([1, 2, 3]);

// 包含 ASCII 位元組 [0x74, 0x65, 0x73, 0x74] 的 buffer:const buf4 = Buffer.from(‘test’);

// 從檔案中讀取 buffer:const buf5 = fs.readFileSync(「some file」);
Buffer 能夠傳回傳統 JavaScript 數據(字串)或者寫回檔案,數據庫,或者其他 I/O 裝置中。

C++ 中如何存取 Buffer
構建 Node.js 的擴充套件時,最好是通過使用 NAN(Node.js 原生抽象)API 啓動,而不是直接用 V8 API 啓動 - 後者可能是一個移動目標。網上有許多用 NAN 擴充套件啓動的教學 - 包括 NAN 程式碼庫自己的 例子。我也寫過很多 教學,在我的 電子書 裡藏得比較深。

首先,來看看擴充套件程式如何存取 JavaScript 發送給它的 Buffer。我們會啓動一個簡單的 JS 程式並引入稍後建立的擴充套件。

'use strict';  

// 先引入稍後建立的擴充套件 
const addon = require('./build/Release/buffer_example');

// 在 V8 之外分配記憶體,預設值爲 ASCII 碼的 "ABC"
const buffer = Buffer.from("ABC");

// 同步,每個字元旋轉 +13
addon.rotate(buffer, buffer.length, 13);

console.log(buffer.toString('ascii'));

「ABC」 進行 ASCII 旋轉 13 後,期望輸出是 「NOP」。來看看擴充套件!它由三個檔案(方便起見,都在同一目錄下)組成。

// binding.gyp
{
「targets」: [
{
「target_name」: 「buffer_example」,
「sources」: [ 「buffer_example.cpp」 ],
「include_dirs」 : ["<!(node -e 「require(‘nan’)」)"]
}
]
}
//package.json
{
「name」: 「buffer_example」,
「version」: 「0.0.1」,
「private」: true,
「gypfile」: true,
「scripts」: {
「start」: 「node index.js」
},
「dependencies」: {
「nan」: 「*」
}
}
// buffer_example.cpp
#include <nan.h>
using namespace Nan;
using namespace v8;

NAN_METHOD(rotate) {
char* buffer = (char*) node::Buffer::Data(info[0]->ToObject());
unsigned int size = info[1]->Uint32Value();
unsigned int rot = info[2]->Uint32Value();

for(unsigned int i = 0; i < size; i++ ) {
    buffer[i] += rot;
}   

}

NAN_MODULE_INIT(Init) {
Nan::Set(target, New(「rotate」).ToLocalChecked(),
GetFunction(New(rotate)).ToLocalChecked());
}

NODE_MODULE(buffer_example, Init)
最有趣的檔案就是 buffer_example.cpp。注意我們用了 node:Buffer 的 Data 方法來把傳入擴充套件的第一個參數轉換爲字元陣列。現在我們能用任何覺得合適的方式來運算元組了。在本例中,我們僅僅執行了文字的 ASCII 碼旋轉。要注意這沒有返回值,Buffer 的關聯記憶體已經被修改了。

通過 npm install 構建擴充套件。package.json 會告知 npm 下載 NAN 並使用 binding.gyp 檔案構建擴充套件。執行 index.js 會返回期望的 「NOP」 輸出。

我們還可以在擴充套件裡建立 新 buffer。修改 rotate 函數增加輸入,並返回減小相應數值後生成的字串 buffer。

NAN_METHOD(rotate) {
char* buffer = (char*) node::Buffer::Data(info[0]->ToObject());
unsigned int size = info[1]->Uint32Value();
unsigned int rot = info[2]->Uint32Value();

char * retval = new char[size];
for(unsigned int i = 0; i < size; i++ ) {
    retval[i] = buffer[i] - rot;
    buffer[i] += rot;
}   

info.GetReturnValue().Set(Nan::NewBuffer(retval, size).ToLocalChecked());
}
var result = addon.rotate(buffer, buffer.length, 13);

console.log(buffer.toString(‘ascii’));
console.log(result.toString(‘ascii’));
現在結果 buffer 是 ‘456’。注意 NAN 的 NewBuffer 方法的使用,它包裝了 Node buffer 裡 retval 數據的動態分配。這麼做會 轉讓這塊記憶體的使用權 給 Node.js,所以當 buffer 越過 JavaScript 作用域時 retval 的關聯記憶體將會(通過呼叫 free)重新宣告。稍後會有更多關於這一點的解釋 - 畢竟我們不希望總是重新宣告。

你可以在 這裏 找到 NAN 如何處理 buffer 的更多資訊。

? :PNG 和 BMP 圖片處理
上面的例子非常基礎,沒什麼興奮點。來看個更具有實操性的例子 - C++ 圖片處理。如果你想要拿到上例和本例的全部原始碼,請到我的 GitHub 倉庫 https://github.com/freezer333/nodecpp-demo,程式碼在 ‘buffers’ 目錄下。

圖片處理用 C++ 擴充套件處理再合適不過,因爲它耗時,CPU 密集,許多處理方法並行,而這些正是 C++ 所擅長的。本例中我們會簡單地將圖片由 png 格式轉換爲 bmp 格式。

png 轉換 bmp 不是 特別耗時,使用擴充套件可能有點大材小用了,但能很好的實現示範目的。如果你在找純 JavaScript 進行圖片處理(包括不止 png 轉 bmp)的實現方式,可以看看 JIMP,https://www.npmjs.com/package/jimphttps://www.npmjs.com/package/jimp。

有許多開源 C++ 庫可以幫我們做這件事。我要使用的是 LodePNG,因爲它沒有依賴,使用方便。LodePNG 在 http://lodev.org/lodepng/,它的原始碼在 https://github.com/lvandeve/lodepng。多謝開發者 Lode Vandevenne 提供了這麼好用的庫!

設定擴充套件
我們要建立以下目錄結構,包括從 https://github.com/lvandeve/lodepng 下載的原始碼,也就是 lodepng.h 和 lodepng.cpp。

/png2bmp
 |
 |--- binding.gyp
 |--- package.json
 |--- png2bmp.cpp  # the add-on
 |--- index.js     # program to test the add-on
 |--- sample.png   # input (will be converted to bmp)
 |--- lodepng.h    # from lodepng distribution
 |--- lodepng.cpp  # From loadpng distribution

lodepng.cpp 包含所有進行圖片處理必要的程式碼,我不會就其工作細節進行討論。另外,lodepng 包囊括了允許你指定在 pnp 和 bmp 之間進行轉換的簡單程式碼。我對它進行了一些小改動並放入擴充套件原始檔 png2bmp.cpp 中,馬上我們就會看到。

在深入擴充套件之前來看看 JavaScript 程式:

'use strict';  
const fs = require('fs');  
const path = require('path');  
const png2bmp = require('./build/Release/png2bmp');

const png_file = process.argv[2];  
const bmp_file = path.basename(png_file, '.png') + ".bmp";  
const png_buffer = fs.readFileSync(png_file);

const bmp_buffer = png2bmp.getBMP(png_buffer, png_buffer.length);  
fs.writeFileSync(bmp_file, bmp_buffer);

這個程式把 png 圖片的檔名作爲命令列參數傳入。呼叫了 getBMP 擴充套件函數,該函數接受包含 png 檔案的 buffer 和它的長度。此擴充套件是 同步 的,在稍後我們也會看到非同步版本。

這是 package.json 檔案,設定了 npm start 命令來呼叫 index.js 程式並傳入 sample.png 命令列參數。這是一張普通的圖片。

{
  "name": "png2bmp",
  "version": "0.0.1",
  "private": true,
  "gypfile": true,
  "scripts": {
    "start": "node index.js sample.png"
  },
  "dependencies": {
      "nan": "*"
  }
}

這是 binding.gyp 檔案 - 在標準檔案的基礎上設定了一些編譯器標識用於編譯 lodepng。還包括了 NAN 必要的參照。
{
「targets」: [
{
「target_name」: 「png2bmp」,
「sources」: [ 「png2bmp.cpp」, 「lodepng.cpp」 ],
「cflags」: ["-Wall", 「-Wextra」, 「-pedantic」, 「-ansi」, 「-O3」],
「include_dirs」 : ["<!(node -e 「require(‘nan’)」)"]
}
]
}
png2bmp.cpp 主要包括了 V8/NAN 程式碼。不過,它也有一個圖片處理通用函數 - do_convert,從 lodepng 的 png 轉 bmp 例子裡採納過來的。

encodeBMP 函數接受 vector 參數用於輸入數據(png 格式)和 vector 參數來存放輸出數據(bmp 格式,直接參照 lodepng 的例子。

這是這兩個函數的全部程式碼。細節對於理解擴充套件的 Buffer 物件不重要,包含進來是爲了程式完整性。擴充套件程式入口會呼叫 do_convert。

~~~~~~~~<del>{#binding-hello .cpp}
/*
ALL LodePNG code in this file is adapted from lodepng's  
examples, found at the following URL:  
https://github.com/lvandeve/lodepng/blob/  
master/examples/example_bmp2png.cpp'  
*/void encodeBMP(std::vector<unsigned char>& bmp,  
  const unsigned char* image, int w, int h)
{
  //3bytes per pixel used for both input and output.
  int inputChannels = 3;
  int outputChannels = 3;

  //bytes 0-13bmp.push_back('B'); bmp.push_back('M'); //0: bfType
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); //6: bfReserved1
bmp.push_back(0); bmp.push_back(0); //8: bfReserved2
bmp.push_back(54 % 256); bmp.push_back(54 / 256); bmp.push_back(0); bmp.push_back(0);

  //bytes 14-53bmp.push_back(40); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //14: biSize
bmp.push_back(w % 256); bmp.push_back(w / 256); bmp.push_back(0); bmp.push_back(0); //18: biWidth
bmp.push_back(h % 256); bmp.push_back(h / 256); bmp.push_back(0); bmp.push_back(0); //22: biHeight
bmp.push_back(1); bmp.push_back(0); //26: biPlanes
bmp.push_back(outputChannels * 8); bmp.push_back(0); //28: biBitCount
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //30: biCompression
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //34: biSizeImage
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //38: biXPelsPerMeter
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //42: biYPelsPerMeter
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //46: biClrUsed
bmp.push_back(0); bmp.push_back(0); bmp.push_back(0); bmp.push_back(0);  //50: biClrImportant

  int imagerowbytes = outputChannels * w;
  //must be multiple of 4
  imagerowbytes = imagerowbytes % 4 == 0 ? imagerowbytes :
            imagerowbytes + (4 - imagerowbytes % 4);

  for(int y = h - 1; y >= 0; y--)
  {
    int c = 0;
    for(int x = 0; x < imagerowbytes; x++)
    {
      if(x < w * outputChannels)
      {
        int inc = c;
        //Convert RGB(A) into BGR(A)
if(c == 0) inc = 2;elseif(c == 2) inc = 0;bmp.push_back(image[inputChannels
            * (w * y + x / outputChannels) + inc]);
      }
      elsebmp.push_back(0);
      c++;if(c >= outputChannels) c = 0;
    }
  }

  // Fill in the size
  bmp[2] = bmp.size() % 256;bmp[3] = (bmp.size() / 256) % 256;bmp[4] = (bmp.size() / 65536) % 256;bmp[5] = bmp.size() / 16777216;
}

bool do_convert(  
  std::vector<unsigned char> & input_data,
  std::vector<unsigned char> & bmp)
{
  std::vector<unsigned char> image; //the raw pixels
  unsigned width, height;
  unsigned error = lodepng::decode(image, width,
    height, input_data, LCT_RGB, 8);if(error) {
    std::cout << "error " << error << ": "
              << lodepng_error_text(error)
              << std::endl;
    return false;
  }
  encodeBMP(bmp, &image[0], width, height);
  return true;
}
</del>~~~~~~~~

Sorry… 程式碼太長了,但對於理解執行機制 機製很重要!把這些程式碼在 JavaScript 裡執行一把看看。

同步 Buffer 處理
當我們在 JavaScript 裡,png 圖片數據會被真實讀取,所以會作爲 Node.js 的 Buffer 傳入。我們用 NAN 存取 buffer 自身。這裏是同步版本的完整程式碼:

NAN_METHOD(GetBMP) {  
    unsigned char*buffer = (unsigned char*) node::Buffer::Data(info[0]->ToObject());  
    unsigned int size = info[1]->Uint32Value();

    std::vector<unsigned char> png_data(buffer, buffer + size);
    std::vector<unsigned char> bmp;

    if ( do_convert(png_data, bmp)) {
        info.GetReturnValue().Set(
            NewBuffer((char *)bmp.data(), bmp.size()/*, buffer_delete_callback, bmp*/).ToLocalChecked());
    }
}  

NAN_MODULE_INIT(Init) {  
   Nan::Set(target, New<String>("getBMP").ToLocalChecked(),
        GetFunction(New<FunctionTemplate>(GetBMP)).ToLocalChecked());
}

NODE_MODULE(png2bmp, Init)

在 GetBMP 函數裡,我們用熟悉的 Data 方法開啓 buffer,所以我們能夠像普通字元陣列一樣處理它。接着,基於輸入構建一個 vector,才能 纔能夠傳入上面列出的 do_convert 函數。一旦 bmp 向量被 do_convert 函數填滿,我們會把它包裝進 Buffer 裡並返回 JavaScript。

這裏有個問題:返回的 buffer 裡的數據在 JavaScript 使用之前可能會被刪除。爲啥?因爲當 GetBMP 函數返回時,bmp 向量要傳出作用域。C++ 向量語意當向量傳出作用域時,向量解構函式會刪除向量裡所有的數據 - 在本例中,bmp 數據也會被刪掉!這是個大問題,因爲回傳到 JavaScript 的 Buffer 裡的數據會被刪掉。這最後會使程式崩潰。

幸運的是,NewBuffer 的第三和第四個可選參數可控制這種情況。

第三個參數是當 Buffer 被 V8 垃圾回收結束時呼叫的回撥函數。記住,Buffer 是 JavaScript 物件,數據儲存在 V8 之外,但是物件本身受到 V8 的控制。

從這個角度來看,就能解釋爲什麼回撥有用。當 V8 銷燬 buffer 時,我們需要一些方法來釋放建立的數據 - 這些數據可以通過第一個參數傳入回撥函數中。回撥的信號由 NAN 定義 - Nan::FreeCallback()。第四個參數則提示重新分配記憶體地址,接着我們就可以隨便使用。

因爲我們的問題是向量包含 bitmap 數據會傳出作用域,我們可以 動態 分配向量,並傳入回撥,當 Buffer 被垃圾回收時能夠被正確刪除。

以下是新的 delete_callback,與新的 NewBuffer 呼叫方法。 把真實的指針傳入向量作爲一個信號,這樣它就能夠被正確刪除。

void buffer_delete_callback(char* data, void* the_vector){  
  deletereinterpret_cast<vector<unsigned char> *> (the_vector);
}

NAN_METHOD(GetBMP) {

  unsigned char*buffer =  (unsigned char*) node::Buffer::Data(info[0]->ToObject());
  unsigned int size = info[1]->Uint32Value();

  std::vector<unsigned char> png_data(buffer, buffer + size);
  std::vector<unsigned char> * bmp = new vector<unsigned char>();

  if ( do_convert(png_data, *bmp)) {
      info.GetReturnValue().Set(
          NewBuffer(
            (char *)bmp->data(),
            bmp->size(),
            buffer_delete_callback,
            bmp)
            .ToLocalChecked());
  }
}

npm install 和 npm start 執行程式,目錄下會生成 sample.bmp 檔案,和 sample.png 非常相似 - 僅僅檔案大小變大了(因爲 bmp 壓縮遠沒有 png 高效)。

非同步 Buffer 處理
接着開發一個 png 轉 bitmap 轉換器的非同步版本。使用 Nan::AsyncWorker 在一個 C++ 執行緒中執行真正的轉換方法。通過使用 Buffer 物件,我們能夠避免複製 png 數據,這樣我們只需要拿到工作執行緒可存取的底層數據的指針。同樣的,工作執行緒產生的數據(bmp 向量),也能夠在不復制數據情況下用於建立新的 Buffer。

class PngToBmpWorker : public AsyncWorker {
    public:
    PngToBmpWorker(Callback * callback,
        v8::Local<v8::Object> &pngBuffer, int size)
        : AsyncWorker(callback) {
        unsigned char*buffer =
          (unsigned char*) node::Buffer::Data(pngBuffer);

        std::vector<unsigned char> tmp(
          buffer,
          buffer +  (unsigned int) size);

        png_data = tmp;
    }
    voidExecute(){
       bmp = new vector<unsigned char>();
       do_convert(png_data, *bmp);
    }
    voidHandleOKCallback(){
        Local<Object> bmpData =
               NewBuffer((char *)bmp->data(),
               bmp->size(), buffer_delete_callback,
               bmp).ToLocalChecked();
        Local<Value> argv[] = { bmpData };
        callback->Call(1, argv);
    }

    private:
        vector<unsigned char> png_data;
        std::vector<unsigned char> * bmp;
};

NAN_METHOD(GetBMPAsync) {  
    int size = To<int>(info[1]).FromJust();
    v8::Local<v8::Object> pngBuffer =
      info[0]->ToObject();

    Callback *callback =
      new Callback(info[2].As<Function>());

    AsyncQueueWorker(
      new PngToBmpWorker(callback, pngBuffer , size));
}

我們新的 GetBMPAsync 擴充套件函數首先解壓縮從 JavaScript 傳入的 buffer,接着初始化並用 NAN API 把新的 PngToBmpWorker 工作執行緒入隊。這個工作執行緒物件的 Execute 方法在轉換結束時被工作執行緒內的 libuv 呼叫。當 Execute 函數返回,libuv 呼叫 Node.js 事件輪詢執行緒的 HandleOKCallback 方法,建立一個 buffer 並呼叫 JavaScript 傳入的回撥函數。

現在我們能夠在 JavaScript 中使用這個擴充套件函數了:

png2bmp.getBMPAsync(png_buffer,  
  png_buffer.length,
  function(bmp_buffer) {
    fs.writeFileSync(bmp_file, bmp_buffer);
});

總結
本文有兩個核心賣點:

不能忽視 V8 儲存單元和 C++ 變數之間的數據拷貝消耗。如果你不注意,本來你認爲把工作丟進 C++ 裡執行可以提高的效能,就又被輕易消耗了。

Buffer 提供了一個在 JavaScript 和 C++ 共用數據的方法,這樣避免了數據拷貝。

我希望通過旋轉 ASCII 文字的簡單例子,和同步與非同步進行圖片轉換實戰使用 Buffer 很簡單。希望本文對你提升擴充套件應用的效能有所幫助!
想看更多關於node.js&c++內容請點選