본문으로 건너뛰기

Node.js C++ 확장 입문 가이드

무료2020-05-03#Node#Nodejs Addon#Nodejs Addon use case#Node插件#Nodejs动态链接库#Nodejs扩展教程

C++ 확장이란 무엇인가, 어떻게 작성하는가, 어떤 용도가 있는가?

一.개념

본질적으로, Node.js 확장은 C++ 동적 ���크 라이브러리입니다:

Addons are dynamically-linked shared objects written in C++.

JS 에서 C/C++ 세계로의 문에 해당합니다:

Addons provide an interface between JavaScript and C/C++ libraries.

이러한 C++ 확장 (xxx.node 파일) 도 JS 모듈과 마찬가지로 직접 require 하여 사용할 수 있습니다. [Node 모듈 로드 메커니즘](/articles/node 모듈 로드 메커니즘/) 이 네이티브 지원을 제공하기 때문입니다

P.S.소위 동적 링크 라이브러리란, 실행 시에 동적으로 로드할 수 있는 라이브러리 (.so 파일, 또는 Windows 하의 .dll 파일) 를 말합니다:

A shared library(.so) is a library that is linked but not embedded in the final executable, so will be loaded when the executable is launched and need to be present in the system where the executable is deployed.

이와 상대되는 것은 정적 라이브러리 (.a 파일) 로, 컴파일 시에 실행 파일에 링크되며, 외부에서 로드할 필요가 없습니다:

A static library(.a) is a library that can be linked directly into the final executable produced by the linker,it is contained in it and there is no need to have the library into the system where the executable will be deployed.

二.구현 방식

Node.js 에서, C++ 확장을 작성하는 데는 3 가지 방식이 있습니다:

  • 직접 손으로 작성: Node, V8, libuv 가 제공하는 C++ API 에 기반하여 직접 작성하지만, 이러한 API 의 다른 Node 버전 하에서의 호환성 문제를 수동으로 처리해야 합니다 (특히 V8 API 는 빈번하게 변화합니다)

  • nan 에 기반: Native Abstractions for Node.js 의 약자로, 다른 Node/V8 버전 간의 C++ API 차이를 차폐하기 위한 추상 레이어를 추가하며, 하층 API 호환성 문제의 처리를 이 레이어에 집약하는 것을 기대합니다

  • N-API 에 기반 (권장 방식): Node.js 가 제공하는 네이티브 확장 지원 API 로, 하층의 JS 런타임 (V8) 과 완전히 독립적이며, ABI 가 Node 버전을 걸쳐 일정하게 유지되는 것을 보장합니다. 따라서다른 Node 버전 위에서 재컴파일 없이 실행할 수 있습니다

P.S.실제로는, N-API 라는 독립 추상 레이어가 있으면, C++ 확장은 JavaScript 엔진, Electron 등의 런타임도 걸쳐갈 수 있습니다. 상세는 The Future of Native Modules in Node.js 참조

그 중에서, N-API 가 선호 방식입니다. N-API 로 해결할 수 없는 경우에만其它 방식을 검토합니다:

Unless there is a need for direct access to functionality which is not exposed by N-API, use N-API.

Node 버전을 걸쳐 (재컴파일 불필요) 직접 실행할 수 있는 것은 의심할 여지 없이 결정적인 우위이지만, 전문 제공된 N-API 만이 ABI 안정을 보장합니다. 즉, N-API 만을 사용하고 (동시에 하층의 Node, V8, libuv API 를 혼용하지 않음) 으로써, C++ 확장이 다른 Node 버전 하에서 직접 실행할 수 있는 것을 보장할 수 있습니다. 상세는 Implications of ABI Stability 참조

N-API 를 사용하지 않는 경우, 손으로 작성하는 것은 조금 복잡하며, 몇 층의 지식과 관련됩니다:

  • V8: Node.js 가 의존하는 JavaScript 엔진으로, 오브젝트 생성, 함수 호출 등의 메커니즘은 모두 V8 이 제공합니다. 구체적인 C++ API 는 헤더 파일 node/deps/v8/include/v8.h 참조

  • libuv: 이벤트 루프, Worker 스레드 및 모든 플랫폼 관련의비동기 동작은 libuv 가 제공하며, 파일 시스템, socket, 타이머, 시스템 이벤트 등에 크로스 플랫폼 추상을 제공합니다. C++ 확장에서는 libuv 를 통해 논블로킹 방식으로 각종 조작을 실현할 수 있으며, I/O 또는其它의 시간이 걸리는 태스크가 이벤트 루프를 블록하는 것을 회피할 수 있습니다

  • Node 내부 라이브러리: Node.js 자신도 몇 가지 C++ API 를 공개하고 있습니다. 예를 들어 node::ObjectWrap 클래스

  • Node 의존 라이브러리: Node.js 가 의존하는 몇 가지 정적 링크 라이브러리도 C++ 확장에서 사용할 수 있습니다. 예를 들어 OpenSSL(더 많은 의존 라이브러리는 node/deps/ 참조)

P.S.Node.js 소스코드 의존, 실행 메커니즘에 대한 상세 정보는, Node.js 아키텍처剖析 참조

三.Hello World

명확히 하기 위해, 여기서는 가장 원시적인 방식을 채택하여, 가장 심플한 C++ 확장을 손으로 작성합니다:

// hoho.cc
// 見 https://github.com/nodejs/node/blob/master/src/node.h
#include <node.h>
// 見 https://github.com/nodejs/node/blob/master/deps/v8/include/v8.h
using namespace v8;

namespace demo {
  void Method(const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    args.GetReturnValue().Set(String::NewFromUtf8(
        isolate, "hoho, there.", NewStringType::kNormal).ToLocalChecked());
  }

  void Initialize(Local<Object> exports) {
    NODE_SET_METHOD(exports, "hoho", Method);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}

그 중에서 중요한 2 행에 주목:

// 초기화 메소드를 실현
void Initialize(Local<Object> exports) { /* ... */ }
// 모듈명에 대응하는 초기화 로직을 등록
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)

C++ 확장은 Node.js 가 제공하는 NODE_MODULE 매크로를 통해 초기화 메소드 (Initialize) 를 공개합니다. 그 중에서 NODE_GYP_MODULE_NAME 은 매크로 (macro) 로, 컴파일 전의 전처리 단계에서 node-gyp 커맨드가传入하는 모듈명으로 전개됩니다

P.S.매크로 전개는 문자열 치환으로 이해할 수 있습니다. 상세는 Macros 참조

컴파일 실행

C++ 소스코드를 컴파일하기 전에, 먼저 컴파일 설정이 필요합니다:

{
  "targets": [
    {
      "target_name": "hoho",
      "sources": [ "hoho.cc" ]
    }
  ]
}

설정 파일명은 binding.gyp 로, 프로젝트 루트 디렉토리에 배치합니다 (package.json 과 유사). node-gyp 컴파일용으로 사용합니다

P.S.binding.gyp 구체적인 포맷 및 각 필드의 의미는 Input Format Reference 참조

먼저 node-gyp 커맨드를 설치합니다:

npm install -g node-gyp

P.S.물론, npm install node-gyp 로 현재 프로젝트에 설치하고, npx node-gyp 로 호출할 수도 있습니다

다음으로 node-gyp configure 커맨드를 통해, 현재 플랫폼의 구축 프로세스에 필요한 설정 파일을 생성합니다 (Unix 시스템 하에서는 Makefile 을 생성, Windows 하에서는 vcxproj 파일). 예를 들어 (Mac OSX):

$ node-gyp configure
gyp info it worked if it ends with ok
...
gyp info ok
# 생성된 파일은 build 디렉토리 하에 위치
$ tree build/
build/
├── Makefile
├── binding.Makefile
├── config.gypi
├── gyp-mac-tool
└── hoho.target.mk

컴파일하여 .node 바이너리 파일을 취득:

$ node-gyp build
gyp info it worked if it ends with ok
gyp info using node-gyp @6.1.0
gyp info using node @10.18.0 | darwin | x64
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
  CXX(target) Release/obj.target/hoho/hoho.o
  SOLINK_MODULE(target) Release/hoho.node
gyp info ok

컴파일 산출물은 Release/hoho.node 에 위치. 시연:

// index.js
// 後綴名을 생략, 자동으로 hoho.node 를 찾아서 로드, 초기화
const hoho = require('./build/Release/hoho');

console.log(hoho.hoho());

실행 결과:

$ node index.js
hoho, there.

위 예는 직접 Node, V8 이 제공하는 C++ API 를 사용했습니다. 버전 걸친 호환성 문제가 존재할 수 있습니다 (몇 버전 후에는 컴파일 오류가 될 수 있습니다). 또한, 다른 버전의 Node 환경 하에서는 모두 재컴파일이 필요합니다.否则 실행 시 오류가 발생합니다:

$ node -v
v10.18.0
# 8.17.0 로 전환
$ n 8.17.0
# 재컴파일 없이 직접 실행
$ node index.js
module.js:682
  return process.dlopen(module, path._makeLong(filename));
                ^

Error: The module '/path/to/hoho/build/Release/hoho.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

재컴파일, 실행해야 합니다:

$ node-gyp rebuild
gyp info it worked if it ends with ok
...
gyp info ok
$ node index.js
hoho, there.

그렇다면, 일노영일한 방식은 있는가?

있습니다. N-API 입니다

四.N-API

직접 Node, V8 등의 하층 C/C++ 모듈이 공개하는 API 를 사용하지 않고, 모두 N-API 로 교체합니다:

// hoho-anywhere.cc
#include <node_api.h>

namespace demo {
  napi_value Method(napi_env env, napi_callback_info args) {
    napi_value greeting;
    napi_status status;

    status = napi_create_string_utf8(env, "hoho, anywhere.", NAPI_AUTO_LENGTH, &greeting);
    if (status != napi_ok) return nullptr;
    return greeting;
  }

  napi_value init(napi_env env, napi_value exports) {
    napi_status status;
    napi_value fn;

    status = napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
    if (status != napi_ok) return nullptr;

    status = napi_set_named_property(env, exports, "hoho", fn);
    if (status != napi_ok) return nullptr;
    return exports;
  }

  NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
}

헤더 파일 node_api.h 만을 임포트하며, 값 타입 등도 직접 v8::String 를 사용하지 않습니다

컴파일 설정 binding.gyp 를 수정:

{
  "targets": [
    {
      "target_name": "hoho",
      "sources": [ "hoho-anywhere.cc" ]
    }
  ]
}

컴파일 실행:

$ node-gyp rebuild
$ node index.js
hoho, anywhere.
# Node 버전을 전환
$ n 8.17.0
# 컴파일 불필요, 직접 실행 가능!
$ node index.js
hoho, anywhere.

P.S.더 복잡한 용법, 및 N-API 에 대한 상세 정보는, N-API 참조

P.S.또한, N-API 가 제공하는 것은 모두 C 인터페이스입니다. C++ 환경에 대해서는, node-addon-api 를 채택할 수 있습니다

五.응용 장면

몇 가지 장면에서는, C++ 확장으로 실현하는 것이 특히 적합합니다:

  • 계산 집약형 모듈.C++ 의 실행 퍼포먼스는 일반적으로 JS 보다 높음

  • 기존의 C++ 라이브러리를 저비용으로 Node.js 확장에 캡슐화하여, Node 에코시스템에 제공

  • Node.js 가 제공하는 네이티브 능력이 니즈를 충족시킬 수 없는 경우.예를 들어 fsevents

  • JS 언어는 몇 가지 방면에서 선천적인 부족이 존재 (예를 들어 수치 정밀도, 비트 연산 등).C++ 로 보충 가능

P.S.주의, 실행 시에 C++ 템플릿을 초기화하는 것 자체에 몇 가지 오버헤드가 존재합니다.성능을 요구하는 장면에서는 이 요인을 고려에 넣을 필요가 있습니다.또한, C++ 이 항상 JS 보다 빠른 것은 아닙니다 (예를 들어 정규 표현식 매치의某些의 장면)

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성