본문으로 건너뛰기

API 주입 메커니즘 및 플러그인 시작 프로세스_VSCode 플러그인 개발 노트 2

무료2018-03-09#Node#vscode#vscode extension host#vscode extension activationEvents#vscode插件启动流程

안에서 밖으로 VS Code 의 강력한 플러그인 메커니즘 이해

서문에

플러그인 Helloworld 에는 하나의 예시 용법이 있습니다:

// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';

var disposable = vscode.commands.registerCommand('extension.sayHello', () => {
    // Display a message box to the user
    vscode.window.showInformationMessage('Hello World!');
});

플러그인 프로세스 환경에서는 vscode 모듈을 도입하여 플러그인이 사용 가능한 API 에 액세스할 수 있습니다. 조금 호기심이 있다면, node_modules 하위에 vscode 모듈이 존재하지 않으며, 게다가 vscode 모듈명도 define() 되지 않았다는 것을 발견할 수 있습니다. 마치 존재하지 않는 모듈을 require 한 것처럼 보입니다. 그렇다면, 이것은 어디에서 온 것인가?

P.S.define() 에 대한 더 많은 정보는, [VS Code 소스 코드 간략 분석 | Renderer Process 초기화](/articles/vs-code 소스 코드 간략 분석/#articleHeader9) 참조

一.require

단서를 따라, 먼저 Node 모듈을 도입할 때 무슨 일이 일어나는지 봅시다?

Node 는 require(name) 함수를 통해 모듈을 로드하며, 모듈명 name 을传入하고, Module 인스턴스를 반환합니다. 대략적인 프로세스는 다음과 같습니다:

  1. name 파라미터가 Module._resolveFilename() 메서드를 통해 완전한 파일 경로에 매핑

  2. cache[fullName] 이 존재하면, cache[fullName].exports 를 반환 (캐시 우선). 모듈은 1 회만 로드되며, 모듈 로드 속도를 향상. 캐시를 우회하고 싶다면, require(name) 전에 cache[fullName]delete 할 수 있습니다. 예를 들어 delete require.cache[require.resolve('./my-module.js')]

  3. 그렇지 않으면, 해당 파일의 소스 코드를 로드하고, 전처리 (모듈 레벨 변수 주입) 를 수행합니다. Module.prototype.load 참조

  4. 마지막으로, 변환된 소스 코드를 컴파일 (실행) 하고, module.exports 의 값을 반환합니다. Module.prototype._compile 참조

P.S. 모듈 캐시에 대한 더 많은 정보는, node.js require() cache - possible to invalidate? 참조

간단한 장면을 봅시다. 두 개의 소스 코드 파일이 있다고 가정:

  // my-modue.js
module.exports = 'my-modue';

// index.js
const m = require('./my-module.js');

엔트리 파일의 첫 번째 행 require('./my-modue.js') 를 실행하는 대략적인 프로세스는:

// module.js
function require(path) {
  return mod.require(path);
}
Module.prototype.require = function(path) {
  return Module._load(path, this, /* isMain */ false);
}
Module._load = function(request, parent, isMain) {
  var filename = Module._resolveFilename(request, parent, isMain);
  var module = new Module(filename, parent);
  Module._cache[filename] = module;
  tryModuleLoad(module, filename);
  return module.exports;
}

그 중에서 tryModuleLoad() 는 구체적으로:

function tryModuleLoad(module, filename) {
  module.load(filename);
}
Module.prototype.load = function(filename) {
  // 위에 액세스 가능한 모든 node_modules 디렉토리 검색
  this.paths = Module._nodeModulePaths(path.dirname(filename));
  // 파일 확장자에 따라 모듈 로드
  Module._extensions[extension](this, filename);
}
Module._extensions['.js'] = function(module, filename) {
  // 소스 코드 읽기
  var content = fs.readFileSync(filename, 'utf8');
  // 컴파일 (실행)
  module._compile(internalModule.stripBOM(content), filename);
}
Module.prototype._compile = function(content, filename) {
  // IIFE 로 모듈 소스 코드를 감싸고, 모듈 레벨 변수 주입. NativeModule.wrap() 참조
  var wrapper = Module.wrap(content);
  // 더 안전한 eval() 에 상당하며, 감싸진 function 소스 코드를 컴파일하여 실행 가능한 Function 인스턴스 획득
  var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });
  var dirname = path.dirname(filename);
  // 주입할 모듈 레벨 require() 메서드
  var require = internalModule.makeRequireFunction(this);
  // 모듈 파라미터 주입, 실행
  result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
  // 이 반환 값은 폐기되며, 쓸모없음. 모듈 내용은 this.exports 로 가져옴
  return result;
}

모듈 소스 코드 바깥쪽에 감싸진 IIFE 는 이렇습니다:

NativeModule.wrap = function(script) {
  // NativeModule.wrapper[0] = "(function (exports, require, module, __filename, __dirname) { "
  // NativeModule.wrapper[1] = "\n});"
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

간단히 정리하면, 실제 이 프로세스의핵심적인 작업은:

// 1.파일 읽기
const moduleScript = fs.readFileSync(fullFilename, 'utf8');
// 2.모듈 구축 (모듈 작용역 격리, 모듈 레벨 변수 선언)
const wrapped = `(function (exports, require, module, __filename, __dirname) {
  ${moduleScript}
});`;
// 2.5.컴파일하여 실행 가능 모듈 획득
const moduleFunction = eval(wrapped);
// 3.실행 (모듈 레벨 변수 값 주입)
let exportsHost = {};
moduleFunction.call(exportsHost, exportsHost);
const m = exportsHost;

그렇다면, require 는 (모듈 레벨의) 국소 변수이므로, 손을 대기 (하이재킹/개조) 가 불편합니다. 그렇다면, 반드시 Module 에 무언가를 해서, 존재하지 않는가상 모듈의 로드를 지원할 수 있어야 합니다

P.S.require('internal/module').makeRequireFunction 팩토리 메서드를 하이재킹하여 require 를 개조하려고 생각하지 마십시오. internal module 에 대한 액세스는 허용되지 않습니다:

NativeModule.nonInternalExists = function(id) {
  return NativeModule.exists(id) && !NativeModule.isInternal(id);
};
NativeModule.isInternal = function(id) {
  return id.startsWith('internal/');
};

Module._resolveFilename 시에 외부자로 간주되어, 외부에서 찾고, 우리가 원하는 그 인스턴스에 액세스할 수 없습니다

二.extension API 주입

require('vscode') 의 프로세스를 debug 하면, 손을 댄 장소를 쉽게 발견할 수 있습니다:

// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI(factory: IExtensionApiFactory, extensionPaths: TernarySearchTree<IExtensionDescription>): void {

  // 각 확장 기능은 자신의 api 구현을 얻음
  const extApiImpl = new Map<string, typeof vscode>();
  let defaultApiImpl: typeof vscode;

  const node_module = <any>require.__$__nodeRequire('module');
  const original = node_module._load;
  node_module._load = function load(request, parent, isMain) {
    if (request !== 'vscode') {
      return original.apply(this, arguments);
    }

    // 파일명에서 확장 기능 id 와 확장 기능용 api 획득
    const ext = extensionPaths.findSubstr(parent.filename);
    if (ext) {
      let apiImpl = extApiImpl.get(ext.id);
      if (!apiImpl) {
        apiImpl = factory(ext);
        extApiImpl.set(ext.id, apiImpl);
      }
      return apiImpl;
    }

    // 기본 구현으로 폴백
    if (!defaultApiImpl) {
      defaultApiImpl = factory(nullExtensionDescription);
    }
    return defaultApiImpl;
  };
}

Module._load() 메서드가 하이재킹되어, vscode 를 만나면 apiImpl 이라는 가상 모듈을 반환합니다.주의, 각 플러그인이 얻는 API 는 독립적입니다 (아마도 플러그인 안전 격리 고려로, API 를 하이재킹하여 다른 플러그인에 영향을 주는 것을 피하기 위함)

P.S. 주의, require.__$__nodeRequire('module') 가 필요한 이유는, global.require 가 이미 하이재킹되었기 때문입니다 ([VS Code 소스 코드 간략 분석 | Renderer Process 초기화](/articles/vs-code 소스 코드 간략 분석/#articleHeader9) 의 loader 부분 참조)... VS Code 팀의수법은 매우 야생적입니다

三.플러그인 메커니즘 초기화 프로세스

이전 [VS Code 시작 프로세스](/articles/vs-code 소스 코드 간략 분석/#articleHeader9) 의 UI 레이아웃 부분에서 언급:

UI 엔트리
src/vs/workbench/electron-browser/bootstrap/index.html
  src/vs/workbench/electron-browser/bootstrap/index.js
    src/vs/workbench/workbench.main js index 파일
      src/vs/workbench/electron-browser/main.ts
        src/vs/workbench/electron-browser/shell.ts 화면과 기능 서비스의 접속점
          src/vs/workbench/electron-browser/workbench.ts 화면 생성
            src/vs/workbench/browser/layout.ts 레이아웃 계산, 절대 위치

WorkbenchShell 의 생성부터 정식으로 기능 구역 UI 레이아웃에 진입하며, UI 는 Shell 이라고 불리며, 기능을承载하는 용기 ("껍질") 로 사용됩니다

src/vs/workbench/electron-browser/shell.ts 부터 화면의 생성, 및 화면과 기능 서비스의对接에 착수합니다.上次는 주 시작 프로세스 관련 부분만关注했지만, 이번에는 플러그인 메커니즘의 초기화 프로세스를 봅니다

플러그인 메커니즘 초기화 관련 파일의遞進관계:

src/vs/workbench/electron-browser/shell.ts 화면과 기능 서비스의 접속점
  src/vs/workbench/services/extensions/electron-browser/extensionService.ts
    src/vs/workbench/services/extensions/electron-browser/extensionHost.ts
      src/vs/workbench/node/extensionHostProcess.ts
        src/vs/workbench/node/extensionHostMain.ts

ExtensionService 생성

src/vs/workbench/electron-browser/shell.tscreateContents() 메서드는 ExtensionService 와 관계있으며, 주요 내용은 다음과 같습니다:

private createContents(parent: Builder): Builder {
  // Instantiation service with services
  const [instantiationService, serviceCollection] = this.initServiceCollection(parent.getHTMLElement());
}
private initServiceCollection(container: HTMLElement): [IInstantiationService, ServiceCollection] {
  this.extensionService = instantiationService.createInstance(ExtensionService);
  serviceCollection.set(IExtensionService, this.extensionService);
}

ExtensionServicesrc/vs/workbench/services/extensions/electron-browser/extensionService.ts 에서来て, 关键 부분은 다음과 같습니다:

lifecycleService.when(LifecyclePhase.Running).then(() => {
  // extensionHost 의 생성과 확장 기능 스캔 지연
  // workbench 실행 후까지
  // 1.extensionHost 초기화
  this._startExtensionHostProcess([]);
  // 2.설치된 플러그인 스캔
  this._scanAndHandleExtensions();
});

private _startExtensionHostProcess(initialActivationEvents: string[]): void {
  // 이미 존재하는 ExtensionHost 프로세스 정지
	  this._stopExtensionHostProcess();
  // ExtensionHostProcessWorker 생성 및 시작
  this._extensionHostProcessWorker = this._instantiationService.createInstance(ExtensionHostProcessWorker, this);
  this._extensionHostProcessProxy = this._extensionHostProcessWorker.start().then(
    //...
  );
  // 장면별로 트리거激活하는 이벤트 등록 (예를 들어 특정 파일을 열었을 때만 플러그인激活)
  this._extensionHostProcessProxy.then(() => {
    initialActivationEvents.forEach((activationEvent) => this.activateByEvent(activationEvent));
  });
}

먼저 ExtensionHostProcessWorker 를 통해 extensionHost 프로세스를 시작하고, 동시에 설치된 플러그인을 스캔합니다. extensionHost 프로세스 생성完毕后, 按需激活하는 플러그인을 등록합니다 (activationEvents["*"] 가 아닌 플러그인)

extensionHost 프로세스 시작

ExtensionHostProcessWorkersrc/vs/workbench/services/extensions/electron-browser/extensionHost.ts 에서来て, 关键 부분은 다음과 같습니다:

public start(): TPromise<IMessagePassingProtocol> {
  const opts = {
    env: objects.mixin(objects.deepClone(process.env), {
      AMD_ENTRYPOINT: 'vs/workbench/node/extensionHostProcess'
    })
  };

  // 현재 프로세스의 fork 로서 Extension Host 실행
  this._extensionHostProcess = fork(URI.parse(require.toUrl('bootstrap')).fsPath, ['--type=extensionHost'], opts);
}

fork() 는 언뜻 AMD_ENTRYPOINT 와 관계없어 보이지만, 실제, fork 로 얻어진 자 프로세스의 엔트리는:

// URI.parse(require.toUrl('bootstrap')).fsPath
// toUrl 변환 후 대응
// out/bootstrap

src/bootstrap.js 로, 关键 부분은 다음과 같습니다:

require('./bootstrap-amd').bootstrap(process.env['AMD_ENTRYPOINT']);

멀리 돌아서 돌아오는 것은, loader 를 통해 엔트리 파일을 실행하기 위해:

var loader = require('./vs/loader');
exports.bootstrap = function (entrypoint) {
  loader([entrypoint], function () { }, function (err) { console.error(err); });
};

그럼 이제, 엔트리 src/vs/workbench/node/extensionHostProcess.ts 에 발을 들입니다:

// 사물 설정
const extensionHostMain = new ExtensionHostMain(renderer.rpcProtocol, renderer.initData);
onTerminate = () => extensionHostMain.terminate();
return extensionHostMain.start();

ExtensionHostMain 으로 전환되고, 대응 소스 코드 파일은 src/vs/workbench/node/extensionHostMain.ts:

public start(): TPromise<void> {
  return this._extensionService.onExtensionAPIReady()
    // 가장 급한 일괄 플러그인 시작
    .then(() => this.handleEagerExtensions())
    .then(() => this.handleExtensionTests())
    .then(() => {
      this._logService.info(`eager extensions activated`);
    });
}
// "eager" 激活 확장 기능 처리
private handleEagerExtensions(): TPromise<void> {
  this._extensionService.activateByEvent('*', true).then(null, (err) => {
    console.error(err);
  });
  return this.handleWorkspaceContainsEagerExtensions();
}

여기까지 와서, 무조건으로 시작하는 플러그인도激活되며, 플러그인 메커니즘 초기화가 완료됩니다

플러그인激活

구체적인 플러그인激活프로세스는 매우 번잡하며, Extension Pack 형 플러그인 (플러그인이 다른 플러그인에 의존하는 것을 허용) 을 지원하므로, 플러그인을激活하려면 플러그인 의존 트리도 처리해야 하며, 의존하는 모든 플러그인이 성공裡에激活된 후에야, 현재의 플러그인을激活할 수 있습니다

P.S. 구체적인 프로세스를 이해하고 싶다면, 이 두 개의 파일을 볼 수 있습니다:

src/vs/workbench/api/node/extHostExtensionService.ts
src/vs/workbench/api/node/extHostExtensionActivator.ts

篇幅의 제한으로, 번잡한 의존 처리環節을 스킵하고, 직접 플러그인 pkg.main 엔트리 파일을 로드하는 부분을 봅니다:

private _doActivateExtension() {
  // require 로 플러그인 엔트리 파일 로드
  loadCommonJSModule(this._logService, extensionDescription.main, activationTimesBuilder),
		this._loadExtensionContext(extensionDescription).then(values => {
    // 그 activate() 메서드 실행
    return ExtHostExtensionService._callActivate(this._logService, extensionDescription.id, <IExtensionModule>values[0], <IExtensionContext>values[1], activationTimesBuilder);
  });
}

// 엔트리 파일 로드
function loadCommonJSModule() {
  r = require.__$__nodeRequire<T>(modulePath);
  return TPromise.as(r);
}
// 約定의 activate() 메서드 실행
private static _callActivateOptional() {
  if (typeof extensionModule.activate === 'function') {
    const activateResult: TPromise<IExtensionAPI> = extensionModule.activate.apply(global, [context]);
  }
}

직접 node require 로 플러그인 엔트리 파일을 실행하여 모듈 인스턴스를 획득하고, 이후 apply 로 그 activate 메서드를 호출하여, 플러그인이走り出합니다

四.프로세스 모델

至此, VS Code 내에 적어도 3 개의 프로세스가 있다는 것을 이해했습니다:

  • Electron Main Process:App 주 프로세스

  • Electron Renderer Process:UI 프로세스

  • Extension Host Process:플러그인 호스트 프로세스, 플러그인에 실행 환경 제공

그 중에서 Extension Host Process 는 (각 VS Code 窗體) 1 개만 존재하며, 모든 플러그인이 해당 프로세스에서 실행됩니다.각 플러그인이 독립된 프로세스라는 것은 아닙니다

주의, 플러그인 호스트 프로세스는보통의 Node 프로세스(childProcess.fork() 로 생성) 로, Electron 프로세스가 아니며, 게다가electron 의 사용이 제한되었습니다:

// 환경 변수
ELECTRON_RUN_AS_NODE: '1'

따라서 플러그인 실행 환경에서 require('electron').BrowserWindow.getAllWindows() 를 사용하여 UI 를 곡線적으로 변경할 수 없습니다

P.S. 플러그인 UI 커스터마이징 능력에 대한 논의는, access electron API from vscode extension 참조

프로세스 간 통신 방식

      <Electron IPC>
Main ---------------- Renderer
 |
 |
 | <Child Process IPC>
 |
 |
Extension Host

그 중에서, Extension Host 와 Main 간의 통신은 fork() 내장 IPC 를 통해 완료되며, 구체적으로:

// extension host 로부터의 로그 지원
this._extensionHostProcess.on('message', msg => {
  if (msg && (<IRemoteConsoleLog>msg).type === '__$console') {
    this._logExtensionHostMessage(<IRemoteConsoleLog>msg);
  }
});

이는 단방향 통신 (플러그인 -> Main) 만이며, 실제로는 this._extensionHostProcess.send({msg}) 를 통해另一半 (Main -> 플러그인) 을 완료할 수 있습니다

P.S. 프로세스 간 통신에 대한 더 많은 정보는, [Nodejs 프로세스 간 통신](/articles/nodejs 프로세스 간 통신/) 참조

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성