2026年4月、.NETエコシステムはさらなる進化を遂げ、C#を用いたネイティブ開発の可能性が大きく広がっています。
その象徴的な事例の一つとして、VS Codeの拡張機能であるC# Dev Kitチームが、パフォーマンスと開発体験の向上のためにNode.jsアドオンの記述言語をC++からC#へ移行したことが発表されました。
この背景には、.NET 10.0でさらに強化されたNative AOT技術の存在があります。
開発現場が直面していた「node-gyp」の課題
VS Code拡張機能のフロントエンドは、Node.js上で動作するTypeScriptで構築されています。
しかし、Windowsレジストリの読み取りといったプラットフォーム固有のタスクを処理する場合、従来のNode.js開発ではC++で記述されたネイティブアドオンを使用するのが一般的でした。
これらのアドオンは通常、node-gypを使用してインストール時にビルドされますが、これには大きなオーバーヘッドが伴います。
特に以下の点が、.NETツール開発チームにとっての大きな摩擦となっていました。
- 依存関係の複雑化:
node-gypを動作させるために、すべての開発者の環境に特定のバージョンのPythonやC++コンパイラをインストールしておく必要がありました。 - CI/CDの鈍化:継続的インテグレーションのパイプラインにおいて、これらの依存ツールをプロビジョニングし、メンテナンスし続けるコストが発生し、ビルド時間の増大を招いていました。
- コンテキストスイッチ:C#のツールを開発しているチームが、一部の機能のために全く異なる言語スタック(C++/Python)を管理しなければならないという非効率性がありました。
.NET SDKがすでにインストールされている環境であれば、C#とNative AOTを活用して、エンジニアリングシステムを合理化できるのではないかという発想は、きわめて自然な流れと言えます。
Native AOTによるNode.jsアドオンの仕組み
Node.jsのネイティブアドオンは、Windowsでは.dll、Linuxでは.so、macOSでは.dylibとして出力される共有ライブラリです。
Node.jsがこれらのライブラリをロードすると、napi_register_module_v1という特定の関数を呼び出します。
このインターフェースを実現するのがN-API(Node-API)です。
N-APIは、ABI(Application Binary Interface)互換の安定したC言語APIであり、共有ライブラリがどの言語で生成されたかを問いません。
正しいシンボルをエクスポートし、正しい関数を呼び出すことさえできれば、Native AOTで生成されたC#ライブラリをNode.jsアドオンとして認識させることが可能です。
プロジェクト構成の定義
まず、.NETのプロジェクトファイル(.csproj)を設定します。
Native AOTを有効にするためには、PublishAotプロパティをtrueに設定する必要があります。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- 2026年時点の最新ターゲットフレームワーク -->
<TargetFramework>net10.0</TargetFramework>
<!-- Native AOTを有効化 -->
<PublishAot>true</PublishAot>
<!-- N-APIとの相互運用にポインタ操作が必要なため許可 -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
ここで重要なのは、Native AOTを使用することで、実行時にJITコンパイルを行うことなく、ターゲット環境のネイティブコードに直接コンパイルされる点です。
C#によるエントリポイントの実装
Node.jsが最初に呼び出すエントリポイントをC#で実装するには、[UnmanagedCallersOnly]属性を使用します。
これにより、C#のメソッドをネイティブコードから呼び出し可能な形式でエクスポートできます。
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public static unsafe partial class RegistryAddon
{
// Node.jsがロード時に呼び出す関数
[UnmanagedCallersOnly(
EntryPoint = "napi_register_module_v1",
CallConvs = [typeof(CallConvCdecl)])]
public static nint Init(nint env, nint exports)
{
// P/Invokeリゾルバーの初期化
Initialize();
// C#のメソッドをJavaScriptの関数として登録
RegisterFunction(
env,
exports,
"readStringValue"u8, // UTF-8文字列リテラル
&ReadStringValue);
return exports;
}
}
このコードでは、nint(ネイティブサイズの整数)を使用してN-APIのハンドルを扱っています。
また、"u8"サフィックスによるUTF-8文字列リテラルを活用することで、余計なエンコーディングやメモリ確保を排除した効率的な実装を実現しています。
N-APIとの相互運用(P/Invoke)
N-APIの関数は、ホストプロセスであるnode.exe自体がエクスポートしています。
そのため、通常のDLLのように外部ファイルを読み込むのではなく、実行中のホストプロセスからシンボルを解決する必要があります。
private static void Initialize()
{
// "node"という名前のインポートをホストプロセスにリダイレクトする設定
NativeLibrary.SetDllImportResolver(
System.Reflection.Assembly.GetExecutingAssembly(),
ResolveDllImport);
static nint ResolveDllImport(
string libraryName,
System.Reflection.Assembly assembly,
DllImportSearchPath? searchPath)
{
if (libraryName is not "node")
return 0;
// メインプログラム(node.exeなど)のハンドルを返す
return NativeLibrary.GetMainProgramHandle();
}
}
次に、[LibraryImport]を使用して、実際のN-API関数を定義します。
private static partial class NativeMethods
{
// 文字列生成のためのN-API
[LibraryImport("node", EntryPoint = "napi_create_string_utf8")]
internal static partial Status CreateStringUtf8(
nint env, ReadOnlySpan<byte> str, nuint length, out nint result);
// JavaScript関数の登録
[LibraryImport("node", EntryPoint = "napi_create_function")]
internal static unsafe partial Status CreateFunction(
nint env, ReadOnlySpan<byte> utf8name, nuint length,
delegate* unmanaged[Cdecl]<nint, nint, nint> cb,
nint data, out nint result);
// JavaScriptからの引数取得
[LibraryImport("node", EntryPoint = "napi_get_cb_info")]
internal static unsafe partial Status GetCallbackInfo(
nint env, nint cbinfo, ref nuint argc,
Span<nint> argv, nint* thisArg, nint* data);
}
ソース生成による[LibraryImport]は、Native AOTとの親和性が非常に高く、トリミング可能で最適化されたマーシャリングコードを生成します。
高効率なデータマーシャリング
JavaScriptと.NETの間でデータをやり取りする際、最も頻繁に行われるのが文字列の変換です。
N-APIはUTF-8を使用するため、.NETのSpan<byte>やArrayPoolを活用することで、ヒープ割り当てを最小限に抑えた高速な変換が可能です。
JavaScriptからC#への文字列取得
private static unsafe string? GetStringArg(nint env, nint cbinfo, int index)
{
nuint argc = (nuint)(index + 1);
Span<nint> argv = stackalloc nint[index + 1];
NativeMethods.GetCallbackInfo(env, cbinfo, ref argc, argv, null, null);
if ((int)argc <= index) return null;
// UTF-8としての長さを取得
NativeMethods.GetValueStringUtf8(env, argv[index], null, 0, out nuint len);
int bufLen = (int)len + 1;
byte[]? rented = null;
// 小さな文字列はスタック、大きな文字列はプールを使用
Span<byte> buf = bufLen <= 512
? stackalloc byte[bufLen]
: (rented = ArrayPool<byte>.Shared.Rent(bufLen));
try
{
fixed (byte* pBuf = buf)
NativeMethods.GetValueStringUtf8(env, argv[index], pBuf, len + 1, out _);
return Encoding.UTF8.GetString(buf[..(int)len]);
}
finally
{
if (rented is not null) ArrayPool<byte>.Shared.Return(rented);
}
}
このようにstackallocを併用することで、短寿命なオブジェクトによるGCへの負荷を劇的に軽減できます。
実装例:Windowsレジストリの読み取り
実際のビジネスロジックとして、Windowsレジストリから値を読み取る関数を実装します。
ここで、.NETが標準で提供する豊富なライブラリ(Microsoft.Win32.Registry)をそのまま利用できる点が、C#でアドオンを書く最大のメリットです。
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint ReadStringValue(nint env, nint info)
{
try
{
var keyPath = GetStringArg(env, info, 0);
var valueName = GetStringArg(env, info, 1);
if (keyPath is null || valueName is null)
{
ThrowError(env, "引数が不足しています。");
return 0;
}
// 標準の.NET APIを使用
using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false);
var result = key?.GetValue(valueName) as string;
return result != null ? CreateString(env, result) : GetUndefined(env);
}
catch (Exception ex)
{
// アンマネージドコード内での例外はプロセスをクラッシュさせるため
// 必ずキャッチしてJavaScript側のエラーとして報告する
ThrowError(env, $"レジストリ読み取り失敗: {ex.Message}");
return 0;
}
}
注意点として、[UnmanagedCallersOnly] メソッド内での未処理例外はホストプロセスを強制終了させます。そのため、必ずtry-catchで保護し、Node.js側に適切にエラーを伝播させる必要があります。
TypeScript側からの呼び出し
ビルドされたネイティブライブラリ(.dllなど)は、Node.jsの慣習に従って拡張子を.nodeに変更します。
TypeScriptからは、型定義を用意することで安全に呼び出すことができます。
// アドオンの型定義
interface RegistryAddon {
readStringValue(keyPath: string, valueName: string): string | undefined;
}
// ネイティブモジュールのロード
const registry = require('./RegistryAddon.node') as RegistryAddon;
// C#で書かれた関数を実行
const installLocation = registry.readStringValue(
'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk',
'InstallLocation'
);
TypeScript側からは、背後でC#が動作していることを意識せずに、型安全なネイティブ機能を利用できるようになります。
既存ライブラリとの比較
今回は軽量な実装のためにN-APIを直接ラップしましたが、より大規模な相互運用が必要な場合は、node-api-dotnetというフレームワークの検討も推奨されます。
| 項目 | 直接N-APIラップ | node-api-dotnet |
|---|---|---|
| 依存関係 | 最小(追加ライブラリ不要) | フレームワークへの依存あり |
| 柔軟性 | 非常に高い(全制御が可能) | 高い(規約に従う必要あり) |
| 実装コスト | ボイラープレートの記述が必要 | 自動生成機能により低い |
| 用途 | 単一機能の軽量なアドオン | 複雑なクラス構造の露出 |
移行によって得られた成果
C# Dev Kitチームがこの移行によって得た最大のメリットは、開発者体験(DX)の劇的な向上です。
- オンボーディングの簡略化:新しいコントリビューターは、Pythonの特定バージョンをインストールする手間から解放されました。
yarn installを実行するだけで、Node.js、C++ツール(VS Code開発に元々必要)、そして.NET SDKという、チームが慣れ親しんだツールセットのみで開発を開始できます。 - メンテナンスコストの削減:CIパイプラインの構成が簡素化され、依存関係の更新に伴う「ビルドが通らなくなる」リスクが減少しました。
- パフォーマンスの維持:Native AOTは高度に最適化されたバイナリを生成するため、C++実装と比較しても遜色のない実行速度を維持しています。
また、将来的には現在別プロセスで動作させている大規模な.NETワークロードを、この仕組みを用いてNode.jsプロセス内にインプロセスでホストし、シリアライズオーバーヘッドを排除するという構想も可能になります。
まとめ
.NET Native AOTを活用したNode.jsアドオンの構築は、「C#の生産性」と「ネイティブのパフォーマンス」を融合させる強力な手法です。
2026年現在の開発環境において、複数のプログラミング言語を使い分けることによる複雑性は、プロジェクトのスピードを阻害する大きな要因となり得ます。
Native AOTによって、.NETコードはもはや「.NETランタイムがインストールされた環境」だけで動くものではなくなりました。
Node.js環境、あるいは他のネイティブコードをロードできるあらゆる環境において、C#はその強力な標準ライブラリとメモリ安全性を武器に、C++に代わる「システムプログラミング言語」としての地位を確立しつつあります。
開発プロセスを合理化し、チームの技術資産をC#に集約したいと考えているなら、Native AOTによるアドオン開発は非常に価値のある選択肢となるでしょう。
