· · 3 Minuten Lesezeit

Node.js Native Addons in C# mit .NET Native AOT schreiben

Das C# Dev Kit-Team ersetzte C++ Node.js-Addons durch .NET Native AOT — das Ergebnis ist sauberer, sicherer und benötigt nur das .NET SDK.

.NET C# Native AOT Node.js VS Code Interop Developer Tooling
Dieser Beitrag ist auch verfügbar in:English, Español, Català, Français, Português, Italiano, 日本語, 中文, 한국어, Русский, हिन्दी, Polski, Türkçe, العربية, Bahasa Indonesia, Nederlands

Dieser Beitrag wurde automatisch übersetzt. Die englische Originalversion findest du hier.

Dieses Szenario gefällt mir: Ein Team, das an .NET-Tooling arbeitet, hatte native Node.js-Addons, die in C++ geschrieben und über node-gyp kompiliert wurden. Es funktionierte. Aber es erforderte Python auf jeder Entwicklermaschine — eine alte Python-Version wohlgemerkt — nur um ein Paket zu bauen, das niemand im Team direkt anfassen würde.

Also stellten sie eine sehr vernünftige Frage: Wir haben das .NET SDK bereits installiert, warum schreiben wir überhaupt C++?

Die Antwort war Native AOT, und das Ergebnis ist wirklich elegant. Drew Noakes vom C# Dev Kit-Team hat dokumentiert, wie sie es gemacht haben, und ich denke, es lohnt sich, das zu verstehen — auch wenn du keine VS Code-Extensions baust.

Die Grundidee

Ein nativer Node.js-Addon ist eine Shared Library (.dll unter Windows, .so unter Linux, .dylib unter macOS), die Node.js zur Laufzeit laden kann. Die Schnittstelle heißt N-API — eine stabile, ABI-kompatible C-API. N-API kümmert sich nicht darum, welche Sprache die Library produziert hat, nur dass sie die richtigen Symbole exportiert.

.NET Native AOT kann genau das produzieren. Es kompiliert C#-Code ahead-of-time in eine native Shared Library mit beliebigen Einstiegspunkten. Das ist der ganze Trick.

Projekt-Setup

Die Projektdatei ist minimal:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

PublishAot weist das SDK an, bei dotnet publish eine Shared Library zu produzieren. AllowUnsafeBlocks wird für das N-API-Interop mit Funktionszeigern und Fixed Buffers benötigt.

Den Einstiegspunkt exportieren

Node.js erwartet, dass deine Library napi_register_module_v1 exportiert. In C# macht [UnmanagedCallersOnly] genau das:

public static unsafe partial class RegistryAddon
{
    [UnmanagedCallersOnly(
        EntryPoint = "napi_register_module_v1",
        CallConvs = [typeof(CallConvCdecl)])]
    public static nint Init(nint env, nint exports)
    {
        Initialize();
        RegisterFunction(env, exports, "readStringValue"u8, &ReadStringValue);
        return exports;
    }
}

Ein paar Dinge, die erwähnenswert sind: nint ist ein nativer Integer — das verwaltete Äquivalent von intptr_t. Das u8-Suffix produziert einen ReadOnlySpan<byte> mit einem UTF-8-String-Literal, direkt an N-API übergeben ohne jede Encoding-Allokation. Und [UnmanagedCallersOnly] exportiert die Methode mit genau dem Einstiegspunkt-Namen, den Node.js sucht.

N-API gegen den Host-Prozess auflösen

N-API-Funktionen werden von node.exe selbst exportiert, nicht von einer separaten Library. Statt gegen etwas zu linken, werden sie beim Start gegen den laufenden Prozess aufgelöst:

private static void Initialize()
{
    NativeLibrary.SetDllImportResolver(
        System.Reflection.Assembly.GetExecutingAssembly(),
        ResolveDllImport);

    static nint ResolveDllImport(
        string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
    {
        if (libraryName is not "node") return 0;
        return NativeLibrary.GetMainProgramHandle();
    }
}

Damit funktionieren P/Invoke-Deklarationen sauber mit [LibraryImport] und quellegenerierten Marshalling.

Eine echte exportierte Funktion

Hier ist der Registry-Reader, den sie gebaut haben:

[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, "Expected two string arguments: keyPath, valueName");
            return 0;
        }

        using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false);

        return key?.GetValue(valueName) is string value
            ? CreateString(env, value)
            : GetUndefined(env);
    }
    catch (Exception ex)
    {
        ThrowError(env, $"Registry read failed: {ex.Message}");
        return 0;
    }
}

Wichtiger Hinweis zum try/catch: Eine unbehandelte Exception in einer [UnmanagedCallersOnly]-Methode stürzt den Host-Prozess ab. Immer abfangen und via ThrowError an JavaScript weiterleiten.

Von TypeScript aufrufen

const registry = require('./native/win32-x64/RegistryAddon.node') as RegistryAddon;
const sdkPath = registry.readStringValue(
    'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk', 'InstallLocation');

TypeScript → C#, kein Python, kein C++.

Was sie gewonnen haben

Der sofortige Gewinn war die Contributor-Experience: keine bestimmte Python-Version mehr nötig, yarn install funktioniert mit Node.js und dem .NET SDK. CI-Pipelines wurden ebenfalls einfacher. Die Performance war vergleichbar mit der C++-Implementierung.

Fazit

Das C# Dev Kit-Team ersetzte Python/C++-Overhead durch sauberen C#-Code, den jeder im Team schon kann. Den vollständigen Walkthrough mit allen String-Marshalling-Helfern findest du im Originalartikel auf dem .NET-Blog.

Teilen:
Quellcode dieses Beitrags auf GitHub ansehen ↗
← VS Code 1.117: Agents Bekommen Eigene Git-Branches und Ich Bin Voll Dabei
azd + GitHub Copilot: KI-gestütztes Projekt-Setup und intelligente Fehlerbehandlung →