이 출시되었습니다! 11월의 새로운 기능 및 수정 사항을 읽어보세요.

WebAssembly를 사용한 확장 개발

2024년 5월 8일 by Dirk Bäumer

Visual Studio Code는 WebAssembly 실행 엔진 확장을 통해 WASM 바이너리 실행을 지원합니다. 주요 사용 사례는 C/C++ 또는 Rust로 작성된 프로그램을 WebAssembly로 컴파일한 다음 VS Code에서 직접 실행하는 것입니다. 대표적인 예는 Python 인터프리터를 VS Code for the Web에서 실행하기 위해 이 지원을 활용하는 Visual Studio Code for Education입니다. 이 블로그 게시물은 이 구현 방식에 대한 자세한 내용을 제공합니다.

2024년 1월, Bytecode Alliance는 WASI 0.2 미리 보기를 출시했습니다. WASI 0.2 미리 보기의 핵심 기술은 컴포넌트 모델입니다. WebAssembly 컴포넌트 모델은 인터페이스, 데이터 유형 및 모듈 구성을 표준화하여 WebAssembly 컴포넌트와 호스트 환경 간의 상호 작용을 간소화합니다. 이 표준화는 WIT(WASM 인터페이스 유형) 파일의 사용을 통해 이루어집니다. WIT 파일은 JavaScript/TypeScript 확장(호스트)과 Rust 또는 C/C++와 같은 다른 언어로 코딩된 계산을 수행하는 WebAssembly 컴포넌트 간의 상호 작용을 설명하는 데 도움이 됩니다.

이 블로그 게시물에서는 개발자가 컴포넌트 모델을 활용하여 WebAssembly 라이브러리를 확장 프로그램에 통합하는 방법을 설명합니다. 세 가지 사용 사례에 중점을 둡니다. (a) WebAssembly를 사용하여 라이브러리를 구현하고 JavaScript/TypeScript의 확장 코드에서 호출하기, (b) VS Code API를 WebAssembly 코드에서 호출하기, (c) 리소스를 사용하여 상태 저장 객체를 WebAssembly 또는 TypeScript 코드에 캡슐화하고 관리하는 방법 시연하기.

이 예제를 실행하려면 VS Code 및 NodeJS와 함께 최신 버전의 다음 도구가 설치되어 있어야 합니다. rust 컴파일러 도구 체인, wasm-tools, wit-bindgen.

이 기사에 대한 귀중한 피드백을 제공해 준 Fastly의 L. Pereira와 Luke Wagner에게도 감사를 표합니다.

Rust로 된 계산기

첫 번째 예제에서는 Rust로 작성된 라이브러리를 VS Code 확장 프로그램에 통합하는 방법을 보여줍니다. 앞서 언급했듯이 컴포넌트는 WIT 파일로 설명됩니다. 저희 예제에서 라이브러리는 덧셈, 뺄셈, 곱셈, 나눗셈과 같은 간단한 연산을 수행합니다. 해당 WIT 파일은 다음과 같습니다.

package vscode:example;

interface types {
	record operands {
		left: u32,
		right: u32
	}

	variant operation {
		add(operands),
		sub(operands),
		mul(operands),
		div(operands)
	}
}
world calculator {
	use types.{ operation };

	export calc: func(o: operation) -> u32;
}

Rust 도구인 wit-bindgen을 사용하여 계산기에 대한 Rust 바인딩을 생성합니다. 이 도구를 사용하는 두 가지 방법이 있습니다.

  • 구현 파일 내에서 직접 바인딩을 생성하는 절차적 매크로로 사용합니다. 이 방법은 표준이지만 생성된 바인딩 코드를 검사할 수 없다는 단점이 있습니다.

  • 디스크에 바인딩 파일을 생성하는 명령줄 도구로 사용합니다. 이 접근 방식은 아래 리소스에 대한 예제 코드에서 VS Code 확장 샘플 저장소에서 찾을 수 있는 코드에 예시되어 있습니다.

wit-bindgen 도구를 절차적 매크로로 사용하는 해당 Rust 파일은 다음과 같습니다.

// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
	// the name of the world in the `*.wit` input file
	world: "calculator",
});

그러나 `cargo build --target wasm32-unknown-unknown` 명령을 사용하여 Rust 파일을 WebAssembly로 컴파일하면 내보낸 `calc` 함수의 구현이 누락되어 컴파일 오류가 발생합니다. 다음은 `calc` 함수의 간단한 구현입니다.

// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
	// the name of the world in the `*.wit` input file
	world: "calculator",
});

struct Calculator;

impl Guest for Calculator {

    fn calc(op: Operation) -> u32 {
		match op {
			Operation::Add(operands) => operands.left + operands.right,
			Operation::Sub(operands) => operands.left - operands.right,
			Operation::Mul(operands) => operands.left * operands.right,
			Operation::Div(operands) => operands.left / operands.right,
		}
	}
}

// Export the Calculator to the extension code.
export!(Calculator);

파일 끝에 있는 `export!(Calculator);` 문은 확장 프로그램이 API를 호출할 수 있도록 WebAssembly 코드에서 `Calculator`를 내보냅니다.

wit2ts 도구는 VS Code 확장 내에서 WebAssembly 코드와 상호 작용하기 위한 필요한 TypeScript 바인딩을 생성하는 데 사용됩니다. 이 도구는 VS Code 확장 아키텍처의 특정 요구 사항을 충족하기 위해 VS Code 팀에서 개발했으며, 주요 이유는 다음과 같습니다.

  • VS Code API는 확장 호스트 워커 내에서만 액세스할 수 있습니다. 확장 호스트 워커에서 생성된 추가 워커는 VS Code API에 액세스할 수 없습니다. 이는 NodeJS 또는 브라우저와 같은 환경과 대조적이며, 여기서 각 워커는 일반적으로 거의 모든 런타임 API에 액세스할 수 있습니다.
  • 여러 확장이 동일한 확장 호스트 워커를 공유합니다. 확장은 해당 워커에서 시간이 오래 걸리는 동기 계산을 수행하지 않아야 합니다.

이러한 아키텍처 요구 사항은 VS Code용 WASI 미리 보기 1을 구현했을 때 이미 존재했습니다. 그러나 초기 구현은 수동으로 작성되었습니다. 컴포넌트 모델의 광범위한 채택을 예상하여 VS Code별 호스트 구현과 컴포넌트 통합을 용이하게 하는 도구를 개발했습니다.

명령 `wit2ts --outDir ./src ./wit`는 `src` 폴더에 `calculator.ts` 파일을 생성하며, 이는 WebAssembly 코드에 대한 TypeScript 바인딩을 포함합니다. 이러한 바인딩을 사용하는 간단한 확장은 다음과 같습니다.

import * as vscode from 'vscode';
import { WasmContext, Memory } from '@vscode/wasm-component-model';

// Import the code generated by wit2ts
import { calculator, Types } from './calculator';

export async function activate(context: vscode.ExtensionContext): Promise<void> {
  // The channel for printing the result.
  const channel = vscode.window.createOutputChannel('Calculator');
  context.subscriptions.push(channel);

  // Load the Wasm module
  const filename = vscode.Uri.joinPath(
    context.extensionUri,
    'target',
    'wasm32-unknown-unknown',
    'debug',
    'calculator.wasm'
  );
  const bits = await vscode.workspace.fs.readFile(filename);
  const module = await WebAssembly.compile(bits);

  // The context for the WASM module
  const wasmContext: WasmContext.Default = new WasmContext.Default();

  // Instantiate the module
  const instance = await WebAssembly.instantiate(module, {});
  // Bind the WASM memory to the context
  wasmContext.initialize(new Memory.Default(instance.exports));

  // Bind the TypeScript Api
  const api = calculator._.exports.bind(
    instance.exports as calculator._.Exports,
    wasmContext
  );

  context.subscriptions.push(
    vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
      channel.show();
      channel.appendLine('Running calculator example');
      const add = Types.Operation.Add({ left: 1, right: 2 });
      channel.appendLine(`Add ${api.calc(add)}`);
      const sub = Types.Operation.Sub({ left: 10, right: 8 });
      channel.appendLine(`Sub ${api.calc(sub)}`);
      const mul = Types.Operation.Mul({ left: 3, right: 7 });
      channel.appendLine(`Mul ${api.calc(mul)}`);
      const div = Types.Operation.Div({ left: 10, right: 2 });
      channel.appendLine(`Div ${api.calc(div)}`);
    })
  );
}

VS Code for the Web에서 위의 코드를 컴파일하고 실행하면 `Calculator` 채널에 다음과 같은 출력이 생성됩니다.

이 예제의 전체 소스 코드는 VS Code 확장 샘플 저장소에서 찾을 수 있습니다.

@vscode/wasm-component-model 내부

wit2ts 도구로 생성된 소스 코드를 검사하면 `@vscode/wasm-component-model` npm 모듈에 대한 종속성이 드러납니다. 이 모듈은 컴포넌트 모델의 표준 ABI에 대한 VS Code 구현 역할을 하며, 해당 Python 코드에서 영감을 받았습니다. 이 블로그 게시물을 이해하기 위해 컴포넌트 모델의 내부를 이해할 필요는 없지만, 특히 JavaScript/TypeScript와 WebAssembly 코드 간에 데이터가 어떻게 전달되는지에 초점을 맞춰 작동 방식을 간략하게 설명할 것입니다.

WIT 파일을 위한 바인딩을 생성하는 wit-bindgen 또는 jco와 같은 다른 도구와 달리, wit2ts는 메타 모델을 생성하며, 이는 다양한 사용 사례를 위해 런타임에 바인딩을 생성하는 데 사용될 수 있습니다. 이러한 유연성은 VS Code 내에서 확장 개발을 위한 아키텍처 요구 사항을 충족할 수 있도록 합니다. 이 접근 방식을 사용하여 바인딩을 "프로미스화"하고 워커에서 WebAssembly 코드를 실행할 수 있습니다. 이 메커니즘을 사용하여 WASI 0.2 미리 보기를 VS Code에 구현합니다.

바인딩을 생성할 때 함수가 `calculator._.imports.create` (밑줄에 유의)와 같은 이름으로 참조되는 것을 보셨을 것입니다. WIT 파일의 기호(예: `imports`라는 이름의 유형 정의가 있을 수 있음)와의 이름 충돌을 피하기 위해 API 함수는 `_` 네임스페이스에 배치됩니다. 메타 모델 자체는 `$` 네임스페이스에 있습니다. 따라서 `calculator.$.exports.calc`는 내보낸 `calc` 함수에 대한 메타데이터를 나타냅니다.

위 예제에서 `calc` 함수에 전달된 `add` 작업 매개변수는 작업 코드, 왼쪽 값, 오른쪽 값의 세 가지 필드로 구성됩니다. 컴포넌트 모델의 표준 ABI에 따르면 인수는 값으로 전달됩니다. 또한 데이터가 직렬화되고 WebAssembly 함수로 전달되고 반대편에서 역직렬화되는 방법을 설명합니다. 이 프로세스는 JavaScript 힙에 있는 객체 하나와 선형 WebAssembly 메모리에 있는 다른 객체 하나, 총 두 개의 작업 객체를 생성합니다. 다음 다이어그램은 이를 보여줍니다.

Diagram illustrating how parameters are passed.

아래 표는 사용 가능한 WIT 유형, VS Code 컴포넌트 모델 구현에서의 JavaScript 객체로의 매핑, 해당 TypeScript 유형을 나열합니다.

WIT JavaScript TypeScript
u8 숫자 type u8 = number;
u16 숫자 type u16 = number;
u32 숫자 type u32 = number;
u64 bigint type u64 = bigint;
s8 숫자 type s8 = number;
s16 숫자 type s16 = number;
s32 숫자 type s32 = number;
s64 bigint type s64 = bigint;
float32 숫자 type float32 = number;
float64 숫자 type float64 = number;
bool boolean boolean
문자열 문자열 문자열
char string[0] 문자열
record object literal type declaration
list<T> [] Array<T>
tuple<T1, T2> [] [T1, T2]
열거형 string values string enum
flags 숫자 bigint
variant object literal discriminated union
option<T> 변수 ? and (T | undefined)
result<ok, err> Exception or object literal Exception or result type

컴포넌트 모델은 저수준(C 스타일) 포인터를 지원하지 않습니다. 따라서 객체 그래프나 재귀 데이터 구조를 전달할 수 없습니다. 이 측면에서 JSON과 동일한 제약이 있습니다. 데이터 복사를 최소화하기 위해 컴포넌트 모델은 리소스라는 개념을 도입했으며, 이는 이 블로그 게시물의 향후 섹션에서 더 자세히 설명할 것입니다.

jco 프로젝트는 `type` 명령을 사용하여 WebAssembly 컴포넌트에 대한 JavaScript/TypeScript 바인딩 생성을 지원합니다. 앞서 언급했듯이 VS Code의 특정 요구 사항을 충족하기 위해 자체 도구를 개발했습니다. 그러나 가능한 경우 도구 간의 일관성을 보장하기 위해 jco 팀과 격주 회의를 진행합니다. 두 도구가 WIT 데이터 유형에 대한 동일한 JavaScript 및 TypeScript 표현을 사용해야 한다는 것이 기본 요구 사항입니다. 또한 두 도구 간의 코드 공유 가능성도 모색하고 있습니다.

WebAssembly 코드에서 TypeScript 호출

WIT 파일은 호스트(VS Code 확장)와 WebAssembly 코드 간의 상호 작용을 설명하여 양방향 통신을 용이하게 합니다. 저희 예제에서 이 기능을 통해 WebAssembly 코드는 활동에 대한 추적을 기록할 수 있습니다. 이를 위해 WIT 파일을 다음과 같이 수정합니다.

world calculator {

	/// ....

	/// A log function implemented on the host side.
	import log: func(msg: string);

	/// ...
}

Rust 측에서는 이제 log 함수를 호출할 수 있습니다.

fn calc(op: Operation) -> u32 {
	log(&format!("Starting calculation: {:?}", op));
	let result = match op {
		// ...
	};
	log(&format!("Finished calculation: {:?}", op));
	result
}

TypeScript 측에서는 확장 개발자가 해야 할 유일한 작업은 log 함수의 구현을 제공하는 것입니다. VS Code 컴포넌트 모델은 WebAssembly 인스턴스에 가져오기로 전달될 필요한 바인딩 생성을 용이하게 합니다.

export async function activate(context: vscode.ExtensionContext): Promise<void> {
  // ...

  // The channel for printing the log.
  const log = vscode.window.createOutputChannel('Calculator - Log', { log: true });
  context.subscriptions.push(log);

  // The implementation of the log function that is called from WASM
  const service: calculator.Imports = {
    log: (msg: string) => {
      log.info(msg);
    }
  };

  // Create the bindings to import the log function into the WASM module
  const imports = calculator._.imports.create(service, wasmContext);
  // Instantiate the module
  const instance = await WebAssembly.instantiate(module, imports);

  // ...
}

첫 번째 예제에 비해 `WebAssembly.instantiate` 호출에는 이제 `calculator._.imports.create(service, wasmContext)`의 결과가 두 번째 인수로 포함됩니다. 이 `imports.create` 호출은 서비스 구현에서 저수준 WASM 바인딩을 생성합니다. 첫 번째 예제에서는 가져오기가 필요하지 않았기 때문에 빈 객체 리터럴을 전달했습니다. 이번에는 디버거 하에서 VS Code 데스크톱 환경에서 확장을 실행합니다. Connor Peet의 훌륭한 작업 덕분에 Rust 코드에 중단점을 설정하고 VS Code 디버거를 사용하여 해당 코드를 단계별로 실행할 수 있습니다.

컴포넌트 모델 리소스 사용

WebAssembly 컴포넌트 모델은 상태를 캡슐화하고 관리하는 표준화된 메커니즘을 제공하는 리소스 개념을 도입합니다. 이 상태는 호출 경계의 한쪽(예: TypeScript 코드)에서 관리되며 다른 쪽(예: WebAssembly 코드)에서 액세스하고 조작됩니다. 리소스는 WASI 미리 보기 0.2 API에서 광범위하게 사용되며, 파일 디스크립터가 일반적인 예입니다. 이 설정에서는 상태가 확장 호스트에서 관리되고 WebAssembly 코드에서 액세스 및 조작됩니다.

리소스는 또한 반대 방향으로 작동할 수 있습니다. 즉, 상태가 WebAssembly 코드에서 관리되고 확장 코드에서 액세스 및 조작됩니다. 이 접근 방식은 VS Code에서 상태 저장 서비스를 WebAssembly로 구현하는 데 특히 유용하며, 이후 TypeScript 측에서 액세스됩니다. 아래 예제에서는 역 폴란드 표기법을 지원하는 계산기를 구현하는 리소스를 정의하며, 이는 Hewlett-Packard 휴대용 계산기에 사용되는 것과 유사합니다.

// wit/calculator.wit
package vscode:example;

interface types {

	enum operation {
		add,
		sub,
		mul,
		div
	}

	resource engine {
		constructor();
		push-operand: func(operand: u32);
		push-operation: func(operation: operation);
		execute: func() -> u32;
	}
}
world calculator {
	export types;
}

다음은 Rust로 된 계산기 리소스의 간단한 구현입니다.

impl EngineImpl {
	fn new() -> Self {
		EngineImpl {
			left: None,
			right: None,
		}
	}

	fn push_operand(&mut self, operand: u32) {
		if self.left == None {
			self.left = Some(operand);
		} else {
			self.right = Some(operand);
		}
	}

	fn push_operation(&mut self, operation: Operation) {
        let left = self.left.unwrap();
        let right = self.right.unwrap();
        self.left = Some(match operation {
			Operation::Add => left + right,
			Operation::Sub => left - right,
			Operation::Mul => left * right,
			Operation::Div => left / right,
		});
	}

	fn execute(&mut self) -> u32 {
		self.left.unwrap()
	}
}

TypeScript 코드에서 이전과 동일한 방식으로 내보내기를 바인딩합니다. 유일한 차이점은 바인딩 프로세스가 이제 WebAssembly 코드 내에서 `calculator` 리소스를 인스턴스화하고 관리하는 데 사용되는 프록시 클래스를 제공한다는 것입니다.

// Bind the JavaScript Api
const api = calculator._.exports.bind(
  instance.exports as calculator._.Exports,
  wasmContext
);

context.subscriptions.push(
  vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
    channel.show();
    channel.appendLine('Running calculator example');

    // Create a new calculator engine
    const calculator = new api.types.Engine();

    // Push some operands and operations
    calculator.pushOperand(10);
    calculator.pushOperand(20);
    calculator.pushOperation(Types.Operation.add);
    calculator.pushOperand(2);
    calculator.pushOperation(Types.Operation.mul);

    // Calculate the result
    const result = calculator.execute();
    channel.appendLine(`Result: ${result}`);
  })
);

해당 명령을 실행하면 출력 채널에 `Result: 60`이 표시됩니다. 앞서 언급했듯이 리소스의 상태는 호출 경계의 한쪽에 있으며, 핸들을 사용하여 다른 쪽에서 액세스됩니다. 리소스와 상호 작용하는 메서드에 전달되는 인수를 제외하고는 데이터 복사가 발생하지 않습니다.

Diagram illustrating how resources are accessed.

이 예제의 전체 소스 코드는 VS Code 확장 샘플 저장소에서 사용할 수 있습니다.

Rust에서 VS Code API 직접 호출

컴포넌트 모델 리소스는 WebAssembly 컴포넌트와 호스트 간의 상태를 캡슐화하고 관리하는 데 사용될 수 있습니다. 이 기능을 통해 리소스를 사용하여 VS Code API를 WebAssembly 코드로 표준적으로 노출할 수 있습니다. 이 접근 방식의 장점은 전체 확장이 WebAssembly로 컴파일되는 언어로 작성될 수 있다는 것입니다. 이 접근 방식을 탐색하기 시작했으며, 다음은 Rust로 작성된 확장의 소스 코드입니다.

use std::rc::Rc;

#[export_name = "activate"]
pub fn activate() -> vscode::Disposables {
	let mut disposables: vscode::Disposables = vscode::Disposables::new();

	// Create an output channel.
	let channel: Rc<vscode::OutputChannel> = Rc::new(vscode::window::create_output_channel("Rust Extension", Some("plaintext")));

	// Register a command handler
	let channel_clone = channel.clone();
	disposables.push(vscode::commands::register_command("testbed-component-model-vscode.run", move || {
		channel_clone.append_line("Open documents");

		// Print the URI of all open documents
		for document in vscode::workspace::text_documents() {
			channel.append_line(&format!("Document: {}", document.uri()));
		}
	}));
	return disposables;
}

#[export_name = "deactivate"]
pub fn deactivate() {
}

이 코드가 TypeScript로 작성된 확장과 유사하다는 점에 유의하세요.

이 탐색은 유망해 보이지만, 현재로서는 진행하지 않기로 결정했습니다. 주된 이유는 WASM에서 비동기 지원이 부족하기 때문입니다. 많은 VS Code API는 비동기적이어서 WebAssembly 코드로 직접 프록시하기 어렵습니다. WebAssembly 코드를 별도의 워커에서 실행하고 WebAssembly 워커와 확장 호스트 워커 간의 WASI 미리 보기 1 지원에 사용된 것과 동일한 동기화 메커니즘을 사용할 수 있습니다. 그러나 이 접근 방식은 동기 API 호출 중에 예상치 못한 동작을 유발할 수 있습니다. 왜냐하면 이러한 호출은 실제로 비동기적으로 실행되기 때문입니다. 결과적으로, 관찰 가능한 상태는 두 개의 동기 호출 사이에 변경될 수 있습니다(예: `setX(5); getX();`가 5를 반환하지 않을 수 있음).

또한 WASI에 대한 완전한 비동기 지원을 0.3 미리 보기 기간에 도입하기 위한 노력이 진행 중입니다. Luke Wagner는 WASM I/O 2024에서 비동기 지원의 현재 상태에 대한 업데이트를 제공했습니다. 이를 통해 보다 완전하고 깔끔한 구현이 가능하므로 지원이 나올 때까지 기다리기로 결정했습니다.

해당 WIT 파일, Rust 코드 및 TypeScript 코드에 관심이 있다면 vscode-wasm 저장소의 `rust-api` 폴더에서 찾을 수 있습니다. rust-api

다음 단계

현재 WebAssembly 코드를 확장 개발에 활용할 수 있는 다른 영역을 다룰 후속 블로그 게시물을 준비 중입니다. 주요 주제는 다음과 같습니다.

  • WebAssembly로 언어 서버 작성.
  • 생성된 메타 모델을 사용하여 장시간 실행되는 WebAssembly 코드를 별도의 워커로 투명하게 오프로드합니다.

VS Code에서 컴포넌트 모델의 VS Code 관용 구현을 갖추고 VS Code용 WASI 0.2 미리 보기 구현을 계속 진행합니다.

감사합니다.

Dirk와 VS Code 팀

행복한 코딩 되세요!

© . This site is unofficial and not affiliated with Microsoft.