이 블로그 검색

2012년 11월 28일 수요일

node.js 분석


Node.js source code analysis

How node executes user code ?

최근 국내에도 node.js 컨퍼런스가 개최되는등 관심이 높아지고 있다. 그런데 기존 javascript를 어떻게 웹브라우져에 독립적으로 만들었고, 서버 사이드에서도 활용가능한 여러 기능들을 어떤식으로 추가했는지, 그 내부 사항을 node.js의 소스 코드를 따라가면서 간략히 알아보자.
(2014.11.09 일 node.js github에 올려진 소스 코드를 기준으로 작성됨)

Node.js 맛보기

node.js 는 기존의 자바스크립트 문법을 사용해서, 웹 브라우져로부터 독립적인 환경에서, 비동기 처리의 장점을 활용하여 서버 사이드에서 요구되는 여러 기능을 구현할수 있게끔 구현 되었다.
먼저 간단한 예제를 보자.

var fs = require('fs');
fs.readFile('./bigFile.txt', 'utf8', function(err, data) {
  console.log(data);
});
console.log('exiting...');

파일을 비동기로 읽는 예제이다.
사용자가 작성한 javascript (이하 js) 에서는 file system 기능을 가지고 있는 fs 객체를 require ('fs') 함수로 요청하여 얻고, 그 객체의 readFile() 함수를 이용하고 있다.
이 파일을 fsTest.js 로 저장하고, 다음처럼 실행하면,
$ cp /usr/share/dict/words bigFile.txt --> 이것은 큰 사이즈의 파일을 만들기 위한 작업
$ node ./fsTest.js
exiting...
A
a
aa
......
'exiting' 문자열이 바로 출력되고 이후, 파일에 대한 비동기 읽기 작업이 완료될때 나머지 결과가 출력되는것을 볼수 있다.
이 예제에서 보이는 require 나 readFile 식별자들은 자바스크립트가 기본적으로 제공하는것이 아니라는 점을 유의하기 바란다. 위의 예제를 크롬 웹브라우져의 자바콘솔에서 돌려 본다면 다음과 같은 에러를 만나게 될것이다.
ReferenceError: require is not defined



Node.js 구성요소

이 예제를 실행한것을 살펴보면, 실행 파일 node의 인자로서 js를 전달했다.
사용자가 작성한 자바스크립트 코드를 node 라는 프로그램을 이용해서 구동시키고 있는것이다.
이처럼 node 실행 파일은 내부적으로 javascript를 분석하여 네이티브한 머신코드로 변환해준다.
이것이 바로 웹브라우져에 독립적인 javascript 실행을 가능하게 해주는 핵심이다.
node.js는 javascript engine으로 Google의 V8을 사용한다.

V8

V8 은 c++ API를 제공하는데, 이를 통해서 자신의 c++ 소스내에 V8 JavaScript engine을 내장시킬수가 있고, 자바스크립트 코드를 실행하고 결과를 얻을수 있다. 또한 자신이 작성한 C++소스를 js에서도 접근할수 있다.
node.js에서는 이것을 활용하여, c++로 node 의 core 기능들을 구현한 후
(이를 node.js내에서는 builtin module 이라고 표현하고 있다),
V8 c++ api를 이용해서 javascript에서도 인식할수 있게끔 하였다.
그리고 이 c++ 에서 작성된 기능을 사용하는 native javascript 들을 작성하였다(이를 node에서는 native module 이라고 부른다).
사용자의 자바스크립트에서 이 기능들을 불러와서 사용할수 있게끔 CommonJS 규격에 의한 모듈 시스템도 작성하였다 (위 예제에서 보이는 require 를 말한다).
즉, node.js의 소스코드는 c++로된 부분과 javascript로 구현된 부분으로 구분되고,
javascript소스에서는 c++ 소스의 기능을 가져다가 사용하는 구조로 되어있다.
최초에 V8를 활용해서 node.js같은 프레임워크를 만들려고 한 발상이, 독특하고 뛰어난 것이었다고 할수 있겠다.

native/builtin module

  • native module : node에서 제공하는 js 모듈을 말한다.
  • builtin module : javascript 만으로는 구현할수 없는 기능 위한 C++ 모듈을 의미.
native module 에서 c++ builtin module의 기능을 사용해서 원하는 기능을 수행한다.
예)
//lib/os.js
var binding = process.binding('os');
//c++ builtin module 'os'의 기능을 가져와서 js native module 의 기능을 구현한다.

CommonJS

CommonJS 는 브라우져 기반의 js를 탈피하고 좀더 광범위한 js 어플리케이션을 개발하는데 있어 필수적인 요소들, 즉 Python, Ruby, Java등에서 제공되는 수준의 풍부한 표준 라이브러리 제공을 위한 API 규격을 정의하고 있는데, 사이트에 가보면 require 함수, exports 객체에 대해 정의하고 다음처럼 사용되길 원한다(즉 specification 이다).
//math.js --> exports 객체(이 스크립트에 주어지는 객체로 구현은 알아서)에 원하는 기능을 추가.
exports.add = function() {
    var sum = 0, i = 0, args = arguments, l = args.length;
    while (i < l) {
        sum += args[i++];
    }
    return sum;
};
//increment.js
var add = require('math').add; // require를 통해서 원하는 기능을 가진 객체를 얻음.
exports.increment = function(val) {
    return add(val, 1);
};
//program.js
var inc = require('increment').increment; //마찬가지..
var a = 1;
inc(a); // 2
node.js에서도 이러한 commonJS를 따르고, 그 spec대로 구현했기에, 위에서처럼 사용할수 있다.
exports, require가 어떻게 구현되는지는 뒷부분에서 설명된다.

libuv

또 하나 node.js 의 중심을 이루는것은 비동기 처리를 실제로 담당하고 있는 libuv 라이브러리이다.
이것은 윈도우즈의 IOCP 와 Unix 의 epoll/kqueue/event ports/등의 기능을 추상화 시켜 플랫폼 독립적으로 비동기 작업 수행을 가능하게 한다.
내부적으로는 thread pool 을 구현하고 있다.
node.js의 비동기처리는 libuv 라이브로리 호출로서 구현된다.

js 와 c++

먼저 node.js 프로젝트의 디렉토리 구조를 간단히 살펴보자.
  • src 폴더에는 network, file system등, 기존 javascript 만으로는 구현 불가능한 영역에 위한 c++ 구현 소스가 존재한다.
  • lib 폴더에는 이러한 c++ 코드의 기능들을 가져와서 사용하는, javascript 로 구현된 소스가 존재한다(native modules).
  • deps 폴더안에는 node 를 구성하는데 필요한 의존 소스들, 즉, v8, uv등의 소스가 존재한다.

macro들과 node_module_struct

src 폴더의 c++ 소스 내부에는 c++의 함수를 js에서 사용하게 하기 위한 부분들이 포함되어 있다.
예를 들어서 node_os.cc 소스를 한번 살펴보자. 마지막 부분에 다음과 같은 부분을 발견할수 있다.
  //.... 각종 기능 구현 ....
  void Initialize(Handle<Object> target,
                Handle<Value> unused,
                Handle<Context> context) {
  NODE_SET_METHOD(target, "getEndianness", GetEndianness);
  NODE_SET_METHOD(target, "getHostname", GetHostname);
  NODE_SET_METHOD(target, "getLoadAvg", GetLoadAvg);
  NODE_SET_METHOD(target, "getUptime", GetUptime);
  NODE_SET_METHOD(target, "getTotalMem", GetTotalMemory);
  NODE_SET_METHOD(target, "getFreeMem", GetFreeMemory);
  NODE_SET_METHOD(target, "getCPUs", GetCPUInfo);
  NODE_SET_METHOD(target, "getOSType", GetOSType);
  NODE_SET_METHOD(target, "getOSRelease", GetOSRelease);
  NODE_SET_METHOD(target, "getInterfaceAddresses", GetInterfaceAddresses);
}

}  // namespace os
}  // namespace node

NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize)
먼저 NODE_SET_METHOD 의 정의 부분을 찾아보면,
#define NODE_SET_METHOD node::NODE_SET_METHOD
으로, 다음 함수를 호출하게 된다.
template <typename TypeName>
inline void NODE_SET_METHOD(const TypeName& recv,
                            const char* name,
                            v8::FunctionCallback callback) {
  v8::Isolate* isolate = v8::Isolate::GetCurrent();
  v8::HandleScope handle_scope(isolate);
  v8::Local<v8::FunctionTemplate> t = v8::FunctionTemplate::New(isolate,
                                                                callback);
  v8::Local<v8::Function> fn = t->GetFunction();
  v8::Local<v8::String> fn_name = v8::String::NewFromUtf8(isolate, name);
  fn->SetName(fn_name);
  recv->Set(fn_name, fn);
}
V8 가 제공하는 API를 호출하는것인데, C++ 함수를 javascript 에서 접근할수 있게 해주는 부분이다 (위 코드의 의미를 좀 더 자세히 알고 싶다면 V8문서를 참고하기 바란다).
그런데 여기서 recv는 무엇을 나타내는가?
위 코드를 보면 Handle target 을 전달한것이다.
그것은 v8 에서의 JavaScript object 를 의미한다.
즉 위 함수는 전달된 자바스크립트 객체에 fn_name 을 key로, 그 value는 함수를 저장한다.
실제 이 함수를 이용하려면 전달된 자바스크립트 객체를 통해서 호출해야 할것이다.
javascript 에서 이 자바스크립트 객체를 통해서 getCPUs() 함수를 호출하면,
c++ 함수인 GetCPUInfo() 가 불려지게 된다.
그럼 NODE_MODULE_CONTEXT_AWARE_BUILTIN 호출 부분을 살펴보자.
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize)
C++ 로 작성된 builtin module 소스의 마지막 부분에 이 매크로가 항상 호출된다.
그 정의 부분으로 가보면,
//node.h
#define NODE_MODULE_CONTEXT_AWARE_BUILTIN(modname, regfunc)           \
  NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, NULL, NM_F_BUILTIN)   \

#define NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, priv, flags)    \
  extern "C" {                                                        \
    static node::node_module _module =                                \
    {                                                                 \
      NODE_MODULE_VERSION,                                            \
      flags,                                                          \
      NULL,                                                           \
      __FILE__,                                                       \
      NULL,                                                           \
      (node::addon_context_register_func) (regfunc),                  \
      NODE_STRINGIFY(modname),                                        \
      priv,                                                           \
      NULL                                                            \
    };                                                                \
    NODE_C_CTOR(_register_ ## modname) {                              \
      node_module_register(&_module);                                 \
    }                                                                 \
  }
이렇게 node_module 구조체가 선언된다.
node_module 구조체는 다음과 같이 정의되어 있다.
//node.h
  struct node_module {
  int nm_version;
  unsigned int nm_flags;
  void* nm_dso_handle;
  const char* nm_filename;
  node::addon_register_func nm_register_func;
  node::addon_context_register_func nm_context_register_func;
  const char* nm_modname;
  void* nm_priv;
  struct node_module* nm_link;
};
nm_link 가 보이는데 이것을 보면 리스트를 구성하는것을 알수있다.
그러므로, 다음의 매크로 호출의 결과로
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize)
실제 선언되는 구조체 node_module 은 다음처럼 정의될 것이다.
  static node::node_module _module =
    {
      NODE_MODULE_VERSION,
      NM_F_BUILTIN,
      NULL,
      __FILE__,
      NULL,
      (node::addon_context_register_func) node::os::Initialize,
      "os",
      NULL,
      NULL
    };
즉, nm_context_register_func 함수 포인터에 node::os::Initialize 함수가 저장된다.
저장된 이함수가 언제 불려지는지는 뒷부분에 설명된다.
아무튼, 이 node::os::Initialize 함수가 C++ 함수를 javascript에서 인식가능하게 하는 매크로, NODE_SET_METHOD 호출을 포함하고 있다는 점이 중요하다.
이렇게 하나의 구조체만을 정의했는데, 어떻게 여러개의 builtin module들이 관리되는것일까?
다음의 코드를 보면
NODE_C_CTOR(_register_ ## modname) {                              \
      node_module_register(&_module);                                 \
    }
node_module_register 함수를 호출해서 지금 생성한 구조체를 리스트에 추가하게 된다.
즉, builtin module들은 리스트로 관리가 된다.
//node.cc
extern "C" void node_module_register(void* m) {
  struct node_module* mp = reinterpret_cast<struct node_module*>(m);

  if (mp->nm_flags & NM_F_BUILTIN) {
    mp->nm_link = modlist_builtin;
    //modlist_builtin는
    //static node_module* modlist_builtin; 로 정의된 연결리스트이다.
    modlist_builtin = mp;
  } else {
    assert(modpending == NULL);
    modpending = mp;
  }
}
NODE_C_CTOR 매크로의 역활은 node_module_register 함수가 가장 먼저 호출되게끔 해주는 일을 담당한다.
//node.js가 윈도우에서도 동작하므로 ifdef 처리되있음.
//윈도우인 경우처리
//...
//*nix 계열인 경우
#define NODE_C_CTOR(fn)                                               \
  static void fn(void) __attribute__((constructor));                  \
  static void fn(void)
#endif
이처럼 javascript에서 사용되길 원하는 모든 C++ builtin module들은 NODE_MODULE_CONTEXT_AWARE_BUILTIN 매크로를 호출하고있다.
즉, 모든 builtin module들에 대한 구조체들이 각각 정의되고, 이것들이 modlist_builtin 리스트에 관리된다.

Binding : modlist_builtin 리스트가 사용되는 곳

src/node.cc 파일을 보면, 다음과 같은 Binding 함수를 볼수 있을 것이다.
static void Binding(const FunctionCallbackInfo<Value>& args) {
  ...

  Local<Object> cache = env->binding_cache_object();
  Local<Object> exports;

  //먼저 cache 에서 검색하여 존재하면 그것을 리턴한다 ....
  if (cache->Has(module)) {
    exports = cache->Get(module)->ToObject();
    args.GetReturnValue().Set(exports);
    return;
  }
  ...
  node_module* mod = get_builtin_module(*module_v);
  if (mod != NULL) {
    exports = Object::New(env->isolate());
    ...
    Local<Value> unused = Undefined(env->isolate());
    mod->nm_context_register_func(exports, unused,
      env->context(), mod->nm_priv);
    cache->Set(module, exports);
  } else if (!strcmp(*module_v, "constants")) {
    exports = Object::New(env->isolate());
    DefineConstants(exports);
    cache->Set(module, exports);
  } else if (!strcmp(*module_v, "natives")) {
    exports = Object::New(env->isolate());
    DefineJavaScript(env, exports);
    cache->Set(module, exports);
  } else {
    char errmsg[1024];
    ...
  }

  args.GetReturnValue().Set(exports);
}
이 함수에서 get_builtin_module 함수를 호출하게 되는데, 그때 modlist_builtin 리스트에서 찾게된다.
//node.cc: modlist_builtin 리스트를 검색
struct node_module* get_builtin_module(const char* name) {
  struct node_module* mp;

  for (mp = modlist_builtin; mp != NULL; mp = mp->nm_link) {
    if (strcmp(mp->nm_modname, name) == 0)
      break;
  }

  assert(mp == NULL || (mp->nm_flags & NM_F_BUILTIN) != 0);
  return (mp);
}
그리고 다음처럼 exports객체를 정의하고, nm_context_register_func 함수 포인터를 이용,
각 모듈이 등록해놓은 함수(각 모듈의 초기화 기능) 호출을 하면서 exports 객체를 인자로 넘겨준다.
  if (mod != NULL) {
    exports = Object::New(env->isolate());
    ...
    Local<Value> unused = Undefined(env->isolate());
    mod->nm_context_register_func(exports, unused,
      env->context(), mod->nm_priv);
    cache->Set(module, exports);
  }
그리고 한번 생성된 객체는 캐쉬에 저장시켜서 다음 요청시에 는 좀 더 빠르게 리턴할수 있게 한다.
앞서 node_os 경우를 예로 들어보자.
Binding 함수 호출을 하면서 "os" 를 인자로 넘긴 경우,
  • 최초로 호출한 경우라면 캐쉬에 없으므로, get_builtin_module 함수호출
  • get_builtin_module 함수에서 modlist_builtin 리스트를 순회하면서 이름으로 검색
  • "os" 이름으로 저장된 구조체에는 다음 정보가 담겨있다.
     {
        NODE_MODULE_VERSION,
        NM_F_BUILTIN,
        NULL,
        __FILE__,
        NULL,
        (node::addon_context_register_func) node::os::Initialize,
        "os",
        NULL,
        NULL
     };
    
  • mod->nm_context_register_func(exports, unused, env->context(), mod->nm_priv); 호출로 실제 실행되는것은 node::os::Initialize 함수이다.
  • node::os::Initialize 함수 인자로는 exports, unused, env->context(), mod->nm_priv 가 전달된다.
  • node::os::Initialize 함수 호출로 인해 NODE_SET_METHOD(target, "getCPUs", GetCPUInfo); 등과 같은 NODE_SET_METHOD 들이 모두 호출되어, 전달된 exports 객체에 os 모듈의 각종 함수들이 Set된다.
  • Binding 함수는 exports 객체를 리턴하고 종료한다.
정리를 하자면 Binding 함수를 이용하면 C++로 구현된 builtin 모듈들이 가진 기능들이 담긴 exports 객체(즉 javascript 에서 사용가능한 객체)를 돌려받을 수 있다는것이다.
그런데 이 Binding함수가 역시 C++함수이므로 javascript 에서 이 함수를 호출하기 위해서는 V8 api 를 이용해서 처리해줘야 할것이다.
src/node.cc 파일내에 SetupProcessObject 함수를 살펴보자.
Handle<Object> SetupProcessObject(int argc, char *argv[])
이 함수의 역활은 나중에 설명될것이다. 일단 함수 종료 부분을 보면 다음 코드를 볼수 있다.
...
NODE_SET_METHOD(process, "binding", Binding);
...
process 객체에 "binding"이라는 이름으로 Binding c++ 함수를 맵핑시키고 있다.
이로서 javascript에서는 다음처럼 원하는 modlist_builtin 리스트에서 해당 모듈의 기능을 사용할수 있다.
var binding = process.binding('os');
var cpus = binding.getCPUs(); // CPU 갯수를 리턴 
지금까지 알아본것은, node.js 가 자바스크립트만으로 구현 불가한 기능을 어떻게 javascript 에서 사용가능하게 처리했는지에 대한 것이었다. 이제, process.binding(...)를 이용해서 C++ builtin module을 javascript 에서 사용할수 있게 되었다.

js code 가 node에 의해 실행되는 과정

//fsTest.js
var fs = require('fs');
fs.readFile('./bigFile.txt', 'utf8', function(err, data) {
  console.log(data);
});
console.log('exiting...');
그렇다면 사용자가 node ./fsTest.js 를 수행할때 내부적으로 어떤 일들이 발생되는지 알아보자.
node_main.cc 의 main 함수가 호출되면서 인자가 전달된다.
여기서 src/node.cc 의 Start함수를 호출한다.(*nix 계열 기준으로 설명)
Start 함수에서 호출되는 주요 함수들은 다음과 같다.
  //node.cc
  V8::Initialize();
  ...
  SetupProcessObject(env, argc, argv, exec_argc, exec_argv);
  Load(env);
SetupProcessObject 함수가 호출되고 있고, 다음과 같이 정의 되었다.
void SetupProcessObject(Environment* env,
                        int argc,
                        const char* const* argv,
                        int exec_argc,
                        const char* const* exec_argv) {
  HandleScope scope(env->isolate());

  Local<Object> process = env->process_object();

  //process 객체 속성들을 set 하기 시작한다.
  ...
  // process.versions
  Local<Object> versions = Object::New(env->isolate());
  READONLY_PROPERTY(process, "versions", versions);
  ...
  // process.argv
  Local<Array> arguments = Array::New(env->isolate(), argc);
  for (int i = 0; i < argc; ++i) {
    arguments->Set(i, String::NewFromUtf8(env->isolate(), argv[i]));
  }
  process->Set(env->argv_string(), arguments);// 모든 인자를 보관!
  ...

  //javascript에서 사용가능한 함수를 등록.
  // define various internal methods
  NODE_SET_METHOD(process,
                  "_startProfilerIdleNotifier",
                  StartProfilerIdleNotifier);
  NODE_SET_METHOD(process,
                  "_stopProfilerIdleNotifier",
                  StopProfilerIdleNotifier);
  NODE_SET_METHOD(process, "_getActiveRequests", GetActiveRequests);
  NODE_SET_METHOD(process, "_getActiveHandles", GetActiveHandles);
  NODE_SET_METHOD(process, "reallyExit", Exit);
  NODE_SET_METHOD(process, "abort", Abort);
  NODE_SET_METHOD(process, "chdir", Chdir);
  NODE_SET_METHOD(process, "cwd", Cwd);
  ...

  // binding 함수를 등록한다. 이제 이 process 객체만 있으면 binding함수를 호출해서
  // javascript에서 C++ builtin module들의 기능들을 사용할수 있다.

  NODE_SET_METHOD(process, "binding", Binding);
  ...
}
Load 함수가 호출된다.
void Load(Environment* env) {
  HandleScope handle_scope(env->isolate());
  ...
  Local<String> script_name = FIXED_ONE_BYTE_STRING(env->isolate(), "node.js");
  Local<Value> f_value = ExecuteString(env, MainSource(env), script_name);
  ...
  Local<Function> f = Local<Function>::Cast(f_value);
  ...
  Local<Object> global = env->context()->Global();
  ...
  Local<Value> arg = env->process_object();
  f->Call(global, 1, &arg);
}
먼저 다음 코드를 알아보자.
Local<Value> f_value = ExecuteString(env, MainSource(env), script_name);
node_javascript.cc 에 MainSource() 가 정의되어 있다.
Handle<String> MainSource(Environment* env) {
  return OneByteString(env->isolate(), node_native, sizeof(node_native) - 1);
}
OneByteString 정의부분은 다음과 같다.
//util-inl.h
inline v8::Local<v8::String> OneByteString(v8::Isolate* isolate,
                                           const char* data,
                                           int length) {
  return v8::String::NewFromOneByte(isolate,
                                    reinterpret_cast<const uint8_t*>(data),
                                    v8::String::kNormalString,
                                    length);
}
ExecuteString 함수정의는 다음과 같다.
이 함수는 자바스크립트 코드 문자열을 인자로 받아서 컴파일후 직접 실행하는 함수이다.
//node.cc
// Executes a str within the current v8 context.
static Local<Value> ExecuteString(Environment* env,
                                  Handle<String> source,
                                  Handle<String> filename) {
  EscapableHandleScope scope(env->isolate());
  ...

  Local<v8::Script> script = v8::Script::Compile(source, filename);
  ...

  Local<Value> result = script->Run();
  ... 
}

node_native, natives

그럼 일단, MainSource 함수에서 보이는 node_native 란것은 무엇일까?
node.js 소스를 컴파일하는 과정 중에 node/out/Release/obj/gen/ 폴더에 node_native.h가 생성된다. 이 헤더파일에는 아래처럼 node.js 및 lib에 있는 js 코드내용들이 ascii 코드값으로 저장되어 있는 배열들이 정의된다.
  //node_native.h
  const char node_native[] = { 47, 47, 32, 67, ...생략, 배열의 내용은 node.js
  const char _debugger_native[] = { 47, 47, 32,... => 내용은 _debugger.js
  const char _linklist_native[] = { 47, 47, 32,...
  const char assert_native[] = { 47, 47, 32, 10....
  const char buffer_native[] = { 47, 47, 32, 67....
  const char buffer_ieee754_native[] = { 47, 47,...
  const char child_process_native[] = { 47, 47,...
  .....
  const char os_native[] = { 47, 47, 32, 67, 111,... => os.js 내용이 ascii로 저장됨.
  .....

struct _native {
  const char* name;
  const char* source;
  size_t source_len;
};
그리고 이 배열을 element로 가지는 _native 구조체들의 배열^^, natives 이 존재한다.
//node_native.h
static const struct _native natives[] = {
  { "node", node_native, sizeof(node_native)-1 },
  ...
  { "events", events_native, sizeof(events_native)-1 },
  { "freelist", freelist_native, sizeof(freelist_native)-1 },
  { "fs", fs_native, sizeof(fs_native)-1 },
  { "http", http_native, sizeof(http_native)-1 },
  ...
  { "https", https_native, sizeof(https_native)-1 },
  { "module", module_native, sizeof(module_native)-1 },
  { "net", net_native, sizeof(net_native)-1 },
  { "os", os_native, sizeof(os_native)-1 },
  ...
  { NULL, NULL, 0 } /* sentinel */
};
만약 node_native [] 배열이 가진 이 아스키 코드값의 내용을 보고싶다면, char 변환해서 화면에 출력해서 확인 해보면 될것이다.
 #include <stdio.h>
 int main(void)
 {
    const char node_native[] = { 47, 47, 32, 67, 111, ...생략.. ,0};
    int i = 0;
    while ( node_native [i] ) // 친절하게 배열이 0으로 끝난다.
    {
      printf("%c", node_native [i] );
      i++;
    }
    return 0;
 }
출력되는 내용은 src/node.js 의 코드와 동일하다.
그렇다면, 이것을 통해 우리가 알 수 있는것은..
Local<Value> f_value = ExecuteString(env, MainSource(env), script_name);
//script_name 은 "node.js"가 전달되었음
이 함수 호출은, ascii 로 된 javascript 소스를 읽어들여서 그 내용을 실행하는것이다.
그리고 실행되는 javascript의 내용은 MainSource() 함수가 돌려주는,
const char node_native[] = { 47, 47, 32, 67, 111, ...생략.. ,0};
node_native [] 배열이 될것이고, 배열이 가진 내용은 src/node.js 파일의 내용과 동일하다.
그리고 리턴값을 함수로 casting한후 Call함수로 호출하게 된다.
  ...
  Local<Function> f = Local<Function>::Cast(f_value);
  ...
  Local<Object> global = env->context()->Global();
  ...
  Local<Value> arg = env->process_object();

  // 입력인자를 전달하면서 node.js (ascii 형태의 소스)코드 실행으로 얻은 함수를 실행.
  f->Call(global, 1, args);
node.js 파일을 열어보면 그내용 자체가 함수인것을 알수있다.
지금 우리는 사용자가 작성한 javascript코드를 node 실행파일이 처리하고 있는 부분을 살펴보고 있다.
하지만 사용자는 node 실행프로그램의 다양한 옵션값도 함께 지정해서 수행중일수도 있다.
그 입력인자들은 어디로 간것인가?
앞서 SetupProcessObject 함수 코드를 보면,
다음처럼 사용자의 모든 입력인자들을 저장하고 있는것을 볼수있다.
process->Set(env->argv_string(), arguments);// 모든 인자를 보관! 
종합해보면, node.js 코드가 사용자가 작성한 javascript를 동작시키는 역활을 하는 것이란것을 알수 있다.

src/node.js

그렇다면 이제 src 디렉토리에 존재하는 node.js 파일을 확인해 볼 차례이다.
이 파일은 다음처럼 시작되고 있다.
// This file is invoked by node::Load in src/node.cc, and responsible for
// bootstrapping the node.js core. Special caution is given to the performance
// of the startup process, so many dependencies are invoked lazily.
(function(process) {
  this.global = this;
  .....
  function startup()
  {
    ....
  }
  .......
  startup();
});
주석을 보니, node.js의 core를 초기화 기동시키는 역활을 하고 있다고 되어 있다.
익명 함수를 정의하고 있고, 입력인자는 process객체를 받고 있다.
() 로 둘러싸있고 이 코드가 수행이 되면, 함수 실행 코드를 리턴하게 된다.
그리고 startup()을 호출 하고있다.
function startup()
{
  ...
  startup.globalVariables();
  ....
  startup.processChannel();
  startup.resolveArgv0();

  //다음은 사용자가 어떤식으로 node 를 실행시켰는지 구분해서 처리한다.
  if (NativeModule.exists('_third_party_main')) {
    //이부분은 node.js를 사용자가 확장시키기를 원할때 사용된다.
    ...
  } else if (process.argv[1] == 'debug') {
    // Start the debugger agent
    ...
  } else if (process._eval != null) {
    // User passed '-e' or '--eval' arguments to Node.
    evalScript('[eval]');
  } else if (process.argv[1]) {
    // 여기가 사용자의 스크립트를 인자로 실행한 경우에 일반적으로 수행되는 부분이다.
    // make process.argv[1] into a full path
    var path = NativeModule.require('path');
    process.argv[1] = path.resolve(process.argv[1]);
    ...
    //node.js가 구현한 commonJS 모듈 시스템은 NativeModule과 Module 2가지 이다.
    var Module = NativeModule.require('module');

    if (global.v8debug &&
        process.execArgv.some(function(arg) {
          return arg.match(/^--debug-brk(=[0-9]*)?$/);
        })) {
      ...
      setTimeout(Module.runMain, debugTimeout);

    } else {
      // Main entry point into most programs:
      // 결국, Module.runMain 으로 사용자의 스크립트를 실행하게 된다.
      Module.runMain();
    }
  }
  else {
    var Module = NativeModule.require('module');
    // 이 부분은 Read-Eval-Print-Loop (REPL) 처리부분이다.
    ....
  }
}
결국 Module.runMain 으로 사용자의 스크립트를 실행하게 된다.
우리가 관심있는 runMain함수는 다음과 같다.
//lib/module.js
Module.runMain = function() {
  // Load the main module--the command line argument.
  Module._load(process.argv[1], null, true);
  // Handle any nextTicks added in the first tick of the program
  process._tickCallback();
}; 
여기서 Module 은 node 에서 구현된 모듈 시스템을 의미한다.

module system

node.js가 구현한 모듈시스템을 살펴보자.
즉 다음과 같이, 다른 모듈의 기능을 사용 할 수 있게 해주는 부분이다.
var Module = NativeModule.require('module');
node.js에서 사용되는 commonJS 모듈 시스템은 ModuleNativeModule 2가지로 구분된다.
  • Module : 우리가 일반적으로 사용하는 모듈 시스템이다.
    native module, 제 3자(타 개발자 등)가 만든 모듈까지 사용할수 있다. lib/module.js에 정의됨.
  • NativeModule : native module(node에서 제공하는 js 모듈) 만을 사용하기 위한 용도이다.
    src/node.js에 정의됨. 일반적으로 사용자가 직접 호출할 경우는 없다.
위 예제코드를 보면 Module 의 기능을 얻기 위해서 NativeModule 을 이용하고 있는것을 볼수있다. 이것은 약간 미묘한듯 보이지만, Module도 node가 제공하는 native module이므로 NativeModule 를 사용해서 기능을 가져와야 한다.

Module

Module은 실질적인 node의 모듈시스템이라고 할수있으며 native module 과 사용자 모듈 모두를 사용할수있게 해준다.
우리가 일상적으로 사용하는require 를 구현하고 있다. 또한 사용자가 작성된 js 를 수행시키는 핵심이기도 하다.
예를들어 사용자가 다음과 같은 스크립트를 작성했다고 가정해보자.
//mymodule.js
exports.name = function() {
    console.log('My name is kojh'); //자신이 제공하는 기능을 구현하고, exports 에 저장시킨다.
};

//이안에서 native module의 기능이 필요한 경우에도 require함수를 이용 가능하다.
var myos = require('os'); //native module 사용 경우
exports.hostname = function() {
    console.log('My hostname is ',myos.hostname());
};
사용자가 만든 모듈 mymodule.js의 기능을 사용하기 위해 Module이 제공하는 require 함수를 이용할수 있다.
//driver.js
var mymodule = require ('./mymodule.js'); //사용자의 모듈 사용 경우
mymodule.hostname();
mymodule.name();
실행 결과
$ node driver.js
My hostname is  kojh-mb-pro.local
My name is kojh
이것을 보면 Module의 내부 구현에는 분명, 사용자의 모듈과 native module을 구분해서 처리하는 로직이 존재할 것 같다.
그럼 module.js 를 확인해 보도록 하자.
//lib/module.js
var NativeModule = require('native_module');
var util = NativeModule.require('util');
var runInThisContext = require('vm').runInThisContext;
var runInNewContext = require('vm').runInNewContext;
....

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  if (parent && parent.children) {
    parent.children.push(this);
  }

  this.filename = null;
  this.loaded = false;
  this.children = [];
}
module.exports = Module;
.....
사용자가 작성한 js는 Module.runMain 함수를 통해서 처리되는것을 앞서 볼수 있었다. 결과적으로 Module._load 을 호출하게 된다.
Module._load = function(request, parent, isMain) {
  ...
  var filename = Module._resolveFilename(request, parent);
  ....

  if (NativeModule.exists(filename)) {
    // 사용자가 require('fs') 등, native module을 요청한 경우이다.
    // REPL is a special case, because it needs the real require.
    if (filename == 'repl') {
      ...
    }
    ...
    //사용자가 require('fs') 같은 native module을 호출한 경우, 아래 부분이 수행될것이다.
    return NativeModule.require(filename);
  }

  //여기부터는 native module 이 아닌경우 처리부분이다.
  var module = new Module(filename, parent);

  if (isMain) {
    process.mainModule = module;
    module.id = '.';
  }
  ...
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  return module.exports;
};
짐작했던 것처럼 Module 내부적으로는 native module 과 사용자의 모듈을 구분해서 처리함을 알수있다.
native module 을 이용하기 위해서 NativeModule 를 사용하는데 이는 잠시 뒤 설명하기로 하고, 사용자의 모듈을 처리하는 부분을 따라가 본다. 위 코드에서 다음부터 시작되는 부분이다.
var module = new Module(filename, parent);
...
Module 의 load 함수가 호출되는데 그정의는 다음과 같다.
Module.prototype.load = function(filename) {
  ...
  this.filename = filename;
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  //모듈 확장자가 js 인 경우에, extension 는 'js' 이다.
  var extension = path.extname(filename) || '.js';

  if (!Module._extensions[extension]) extension = '.js';
  Module._extensions[extension](this, filename);
  this.loaded = true;
};
결국
 Module._extensions[extension](this, filename);
이것으로, _extensions 객체에서 extension 키로 찾은 함수를 호출하는 것이다.
지금은 사용자 작성 스크립트(mymodule.js)를 실행하는 경우기 때문에, 키는 '.js' 가 된다.
그 밖에 .json, .node 확장자의 경우 각각 다른 로직이 수행된다.
그럼 _extensions 을 한번 살펴보자.
// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  module._compile(stripBOM(content), filename);
};
해당 key 에 대한 value 로 함수를 설정하고 있다.
그리고 그 함수에서는 다시, 주어진 파일명으로 파일을 읽어서 (즉, 사용자의 js 스크립트, mymodule.js) , _compile 함수를 호출한다.
사용자의 모듈을 읽기 위해서는 다음처럼 NativeModule.require 를 이용한다.
var fs = NativeModule.require('fs'); //파일을 읽는것은 native module fs.js 이므로
나머지 소스도 살펴보면,
Module.wrapper = NativeModule.wrapper;
Module.wrap = NativeModule.wrap;
....

Module.prototype.require = function(path) {
  return Module._load(path, this);
};

Module.prototype._compile = function(content, filename) {
  var self = this;
  ...
  function require(path) {
    return self.require(path);
  }
  ...
  var dirname = path.dirname(filename);
  ...
  // create wrapper function
  var wrapper = Module.wrap(content);

  var compiledWrapper = runInThisContext(wrapper, { filename: filename });
  ...
  var args = [self.exports, require, self, filename, dirname];
  return compiledWrapper.apply(self.exports, args);
};
여기서, 우리가 일반적으로 사용하는 모듈시스템의 구현, require의 정의를 볼수있다.
Module.prototype.require = function(path) {
  return Module._load(path, this);
};
즉 모듈시스템 require는 Module._load를 호출하는 것이다.
사용자의 모듈안에서 또다른 모듈을 호출한다면 계속 _load의 재귀적인 호출이 일어날 것이다 (만약 require 호출이 반복된다면 stack overflow 발생).
이제 사용자가 작성한 스크립트가 실행되는 부분, _compile에 도착했다.
이부분에서 중요한 점은, 사용자가 작성한 모듈은 함수로 변경된다는 점이다.
즉 다음의 코드를 통해서
var wrapper = Module.wrap(content);
contents(사용자 모듈의 내용)는 다음과 같은 함수 정의로 변경될것이다.
'(function (exports, require, module, __filename, __dirname) {
  ...contents source...
  });'
이제 모든 모듈안에서 전역으로 사용되던 exports, require, module 등이 어디서 온것인지 알수있다.
그것들은 node가 사용자 모듈을 함수로 변경하면서 인자로 넘겨주는 것들이다.
이제 사용자 스크립트가 다음처럼 인자를 받아서 실행이 된다.
var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
이때 전달되는 파라메터들을 살펴보자.
var args = [self.exports, require, self, filename, dirname]; 
즉, 사용자의 모듈입장에서 전달되어진 인자들은 exports = self.exports , module = self 이다. 그러므로, 사용자 모듈 입장에서는 exports 는 module.exports와 동일하다.

NativeModule

native module 의 기능을 이용하기 위해서 NativeModule 이 존재한다. 앞서 Module에서 알아본바와 같이 Module 내부에서 node가 기본적으로 제공하는 native module의 기능을 이용할때 NativeModule 를 시용하고 있다.
//node.js
var ContextifyScript = process.binding('contextify').ContextifyScript;
function runInThisContext(code, options) {
  var script = new ContextifyScript(code, options);
  return script.runInThisContext();
}

function NativeModule(id) {
  this.filename = id + '.js';
  this.id = id;
  this.exports = {};
  this.loaded = false;
}

NativeModule._source = process.binding('natives');
NativeModule._cache = {};

NativeModule.require = function(id) {
  if (id == 'native_module') {
    return NativeModule;
  }
  ...
  var nativeModule = new NativeModule(id);

  nativeModule.compile();
  nativeModule.cache();

  return nativeModule.exports;
};
....

NativeModule.getSource = function(id) {
  return NativeModule._source[id];
}

NativeModule.wrap = function(script) { //요청한 js를 함수호출로 감싼다! Module에서와 동일.
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

NativeModule.prototype.compile = function() {
  var source = NativeModule.getSource(this.id);
  source = NativeModule.wrap(source);

  var fn = runInThisContext(source, this.filename, true);
  fn(this.exports, NativeModule.require, this, this.filename);

  this.loaded = true;
};
.....
먼저 process.binding('natives') 호출을 따라가보면, node.cc 파일의 DefineJavaScript() 함수를 호출하게 된다.
static Handle<Value> Binding(const Arguments& args) {
  HandleScope scope;
  ...
  } else if (!strcmp(*module_v, "natives")) {
    exports = Object::New(env->isolate());
    DefineJavaScript(env, exports);
    cache->Set(module, exports);
  }
  ...
}
그럼 DefineJavaScript () 함수에서는 어떤 일을 수행하는지 알아보자.
/* node_src/node_javascript.cc */
void DefineJavaScript(Environment* env, Handle<Object> target) {
  HandleScope scope(env->isolate());

  for (int i = 0; natives[i].name; i++) {
    if (natives[i].source != node_native) {
      Local<String> name = String::NewFromUtf8(env->isolate(), natives[i].name);
      Handle<String> source = String::NewFromUtf8(env->isolate(),
                                                  natives[i].source,
                                                  String::kNormalString,
                                                  natives[i].source_len);
      target->Set(name, source);
    }
  }
}
앞서 알아본 바에 의하면, natives 는 node.js의 native javascript파일의 내용을 ascii 형식으로 저장하고 있는 배열 이었다. 이 natives 배열 요소중에서 node_native(즉 node.js파일 내용)를 제외한 것들을 key=모듈명, value=스크립트 내용 으로 target(즉, javascript 객체, exports) 에 설정하게 된다.
그리고 NativeModule.require 정의는 다음과 같다.
NativeModule.require = function(id) {
    if (id == 'native_module') {
      return NativeModule;
    }

    var cached = NativeModule.getCached(id);
    if (cached) {
      return cached.exports;
    }

    if (!NativeModule.exists(id)) {
      throw new Error('No such native module ' + id);
    }

    process.moduleLoadList.push('NativeModule ' + id);

    var nativeModule = new NativeModule(id);

    nativeModule.cache();
    nativeModule.compile();

    return nativeModule.exports;
  };

ex) fs.js module을 요청한 경우

사용자가 fs.js native module을 요청하는 경우를 알아보자.
  • 최초로 호출한 경우라면 캐쉬에 없으므로, new NativeModule('fs') 실행.
  • 새로 생성된 객체의 속성은 다음과 같게된다.
    this.filename = 'fs.js';
    this.id = 'fs';
    this.exports = {};
    this.loaded = false;
    
  • nativeModule.compile(); 이 수행이 된다.
NativeModule.prototype.compile = function() {
    var source = NativeModule.getSource(this.id);
    source = NativeModule.wrap(source);

    var fn = runInThisContext(source, { filename: this.filename });
    fn(this.exports, NativeModule.require, this, this.filename);

    this.loaded = true;
  };
  • NativeModule._source에서 'fs'를 key로 찾는다. 이미 _source에는 모든 js내용이 해당 key로 존재한다.
var source = NativeModule.getSource(this.id);
  • NativeModule.wrap(source); 이 돌려주는 결과는 다음과 같은 문자열이 된다.
    '(function (exports, require, module, __filename, __dirname) {
    ... 요청한 fs.js 소스 문자열...
    });'
    
    fs.js 에서 global object 로 사용되는 exports, require, module 이 이제 어디서 오는것인지 알수있다.
    그것들은 node 가 fs.js 모듈을 함수 호출로 처리하면서, 인자로 넘겨주는 것들이다. 이는 앞서 Module 에서 처리하는 방식과 동일하다.
  • 요청한 모듈을 함수로 호출하면서 인자들을 넘겨준다.
    fn(this.exports, NativeModule.require, this, this.filename);
    
    호출되는 fs.js 의 내부를 잠깐 살펴보면,
    //fs.js
    ....
    var util = require('util'); //require 또한 모듈 호출시 전달된 인자이며, 객체(함수)이다.
    ...
    var fs = exports; //exports 또한 모듈 호출시 전달된 인자.
    
    //그리고 전달된 exports객체에 자신의 고유 기능을 저장시킨다.
    fs.exists = function(path, callback) {
    ...
    };
    
    fs.existsSync = function(path) {
    ...
    };
    
    fs.readFile = function(path, encoding_) {
    ...
    
    전달된 exports객체에 자신의 고유 기능을 저장 시키는 부분을 확인할수 있다.
  • 이제 exports 객체에는 사용하길 원하는 native module 들이 제공하는 속성이나 함수등이 저장되어 있을 것이다.

결론

사용자가 작성한 자바스크립트는 앞에서 살펴본것처럼 node가 제공하는 프레임워크적인 기능들, 즉 c++로 작성된 builtin과의 연동 및 모듈 시스템을 이용한 타 모듈과의 연동을 통해, javascript만으로 할수 없었던 다양한 부가기능을 구현할수 있게 된다.


댓글 2개:

  1. Node.JS를 분석할 일이 있었는데 정말 도움이 많이 되었습니다!!

    답글삭제
  2. 정말 굿입니다!! 한참을 찾던 자료네요 ㅎ

    답글삭제