LoginSignup
10

posted at

updated at

.NET 7の.NET JavaScript interop on WebAssemblyを試す

2022/09/15 未明に .NET 7.0 rc1 がリリースされました:tada:

GitHubの dotnet/runtime リポジトリIssueにあるスケジュールから予想されるどおりの日にリリースされましたね。

この記事では本日 .NET Blog で公開された ASP.NET Core updates in .NET 7 Release Candidate 1 で紹介されている、最も私が期待していた .NET JavaScript interop on WebAssemblyを試してみます。

.NET JavaScript interop on WebAssembly とは

ざっくりと説明すると、Blazor WebAssemblyのコアとなっていたWebAssemblyベースの.NETランタイムを、例えばNode.js上やBlazorを介さないブラウザ上で動作するようにしたものです。
Blazor WebAssemblyが提供していたJavaScriptから.NET コードを呼び出す機能や、.NET から JavaScriptを呼び出す機能が分離した、みたいなイメージです。

自分がこの技術を知ったのが

この辺りの配信で、最近でもちょくちょく触れられるようになりました。
その時は .NET WebAssembly without Blazor UI として紹介されていた技術となります。

実際に試してみる

それでは実際に触って確かめてみましょう。

以下の手順で行います。

  1. .NET 7.0 rc1のダウンロードおよびインストール (https://dotnet.microsoft.com/ja-jp/download/dotnet/7.0)
  2. wasm-experimental workloadのインストール
  3. テンプレートからプロジェクトの作成
  4. 実行

これらの手順の実行のフェーズではNode.jsが必要になるため、Node.js v16(もしくはv18などのバージョン)も利用可能にしておきます。

1ですが自分のOSやCPUアーキテクチャにあったものをダウンロードします。
preview 6, 7辺りではM1 macの方は sudo dotnet workload {subcommand} のコマンドでdotnetコマンドがクラッシュする不具合がありましたが、rc1では修正されているのでglobalインストールしても大丈夫のように思えます。
環境を分離しておきたい方は dotnet-install scripts を試すと良いでしょう。

2は.NET Blogの通りに

$ dotnet workload install wasm-experimental

で良いのですが、例えばインストール先をユーザディレクトリとかにしていない場合は sudo が必要な場合が多い(というか大体の人がsudo必要なはず)ので、先頭にsudoをつけて実行します。
これにより、.NET WebAssemblyを試すことが出来るテンプレートと、実際のランタイムなどがインストールされます。

3のテンプレートからプロジェクトの実行ですが、ここではNode.jsを使って試してみたいので

$ dotnet new wasmconsole

と入力します。
C# のプロジェクトなのに、jsファイルも生成されて、不思議な感じになってきましたね。

ファイルの中身を見てみると

Program.cs
using System;
using System.Runtime.InteropServices.JavaScript;

Console.WriteLine("Hello, Console!");

return 0;

public partial class MyClass
{
    [JSExport]
    internal static string Greeting()
    {
        var text = $"Hello, World! Greetings from node version: {GetNodeVersion()}";
        return text;
    }

    [JSImport("node.process.version", "main.mjs")]
    internal static partial string GetNodeVersion();
}

見慣れないAttributeが追加されています。
これについては後述します。

main.mjs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { dotnet } from './dotnet.js'

const is_node = typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node === 'string';
if (!is_node) throw new Error(`This file only supports nodejs`);

const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
    .withDiagnosticTracing(false)
    .create();

setModuleImports("main.mjs", {
    node: {
        process: {
            version: () => globalThis.process.version
        }
    }
});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const text = exports.MyClass.Greeting();
console.log(text);

await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);

どこからともなく現れた dotnet.js は無視するとして(オイ)、ビルダパターンでdotnetランタイムを初期化し、戻り値を利用して何やらしているようです。
これも後述します。

4の実行をしてみましょう。
READMEに沿ってビルドをしてみます。

$ dotnet build -c Debug -r browser-wasm

これでDebugビルドが行えました。

次に実行してみます。
READMEがrc1のテンプレートでは誤っているため以下のように実行します。

# dotnet run -c Debug -r browser-wasm -h=nodejs じゃなくて
$ dotnet run -c Debug -r browser-wasm --h=node # こう
# もしくはREADMEどおりに
$ node bin/$(Configuration)/net7.0/browser-wasm/AppBundle/main.mjs # このConfigurationは今回で言えばDebug

するとコンソールに

WasmAppHost --runtime-config /Users/yk-yamada/Projects/github.com/yamachu/try-net7-dotnet-webassembly/wasmconsole-rc1/bin/Debug/net7.0/browser-wasm/AppBundle//wasmconsole-rc1.runtimeconfig.json --h=node
Running: node main.mjs
Using working directory: /Users/yk-yamada/Projects/github.com/yamachu/try-net7-dotnet-webassembly/wasmconsole-rc1/bin/Debug/net7.0/browser-wasm/AppBundle
mono_wasm_runtime_ready fe00e07a-5519-4dfe-b35a-f867dbaf2e28
Hello, World! Greetings from node version: v16.15.1
Hello, Console!

こんな感じでログがベロっと出たと思います。
ここまで実行できれば後はコードを理解し改変することが出来ます。

コードを読み進める

まずは答えから行かずに、コンソールに出てきたログから探りを入れてみましょう。

まずは Hello, World! Greetings from node version: という文字列に注目してみましょう。
この文字列はどこに定義されているでしょうか。
そう、これは Program.cs 内の Greeting 関数に定義されている文字列です。

実際に文字列を変更して実行してみると変わっていることが確かめられます。
ではこの文字列をコンソールの出力に出しているのはどこでしょうか?

main.mjs の console.log(text) をコメントアウトしてみると消えました。
つまり

const text = exports.MyClass.Greeting();

の箇所は、Program.cs の MyClass クラスの Greeting 関数を実行した結果を text 変数に代入している、という風に捉えることが出来ます。
JavaScriptからC# のコードを呼び出すことが出来ています、素晴らしい…

これを可能にしているのが Greeting 関数に付与されている JSExport アトリビュートです。
まだDocumentが出ていない(はず)なので詳しくは説明できないのですが、要はこのアトリビュートが付与された静的メソッドを JavaScript 側から参照可能にする役割をもつもの、として見ると良さそうです。

参照可能にするためにもう一つ重要な役割を持つ関数があり、それが getAssemblyExports 関数です。
これは JavaScript 側で dotnet ランタイムを初期化した時の戻り値として得ることが出来ます。
この関数に対して、JavaScript 側に公開したい C# の関数が含まれるアセンブリ名を渡すことで、JavaScript 側から実行できる関数(もどき)を得ることが出来ます。

ここまでをまとめると

C# -> JavaScript の方向では JSExportgetAssemblyExports

さて、コードを更に読み進めていきましょう。
Greeting関数で得られた文字列の後ろには何やらバージョンが書いてあります。
Node.jsのREPLを立ち上げてバージョンを確かめてみると

$ node
Welcome to Node.js v16.15.1.
Type ".help" for more information.
> globalThis.process.version;
'v16.15.1'

どうやらこの文字列は実行するのに使用したNode.jsのバージョンのように見えます。
Greeting関数では partial な関数の GetNodeVersion を使用して Node.js のバージョンを取得しているようです。

ではこのバージョンを取得するためのコードはどこに定義されているか、そう、これは main.mjs 内に記載されています。

{
    node: {
        process: {
            version: () => globalThis.process.version,
        },
    },
}

こんな記述が見つかると思います。
実際に上記のObjectを生成し、 node.process.version() と Node.js上で実行するとバージョンが取得できます。
では実際に C# 側にはどのようにしてこのバージョンを渡しているのでしょうか?

ここで JSImport というアトリビュートを見てみると

[JSImport("node.process.version", "main.mjs")]

これらの文字列などを探してみると main.mjs 内に

setModuleImports("main.mjs", {
    node: {
        process: {
            version: () => globalThis.process.version,
        },
    },
});

こんな呼び出しが見つかります。
この関数により、JSImport アトリビュートが付与された関数の実体を JavaScript 側から与えることが出来るのです(厳密には違って、ビルド時コード生成が走って、 setModuleImports で与えた関数を実行するラッパーを生成するとかなのですが、この辺りはソースを読んでみてください…ドキュメントが出たらそれを読むのもいいでしょう)。

この main.mjs の部分は何でもよくて、例えば hoge とかにしても動きます(Program.csとmain.mjs双方で一致させる必要がある)。

ここまでをまとめると

JavaScript -> C# の方向では JSImportsetModuleImports
この辺りを理解すると後は色々試すだけになりました。
思いつく面白いものをガンガン作っていきましょう。

応用してみる

例えば引数に何が使えるのかとか気になると思います。
実際にこのアトリビュートが作られた時のPRを見ると概ね書いてあるのでそれを元にいくつか試してみましょう。

例えば

    [JSExport]
    [return: JSMarshalAs<JSType.Function<JSType.String>>]
    internal static Func<string> GreetingFn()
    {
        var text = $"Hello, World! Greetings from node version: {GetNodeVersion()}";
        return () => text;
    }

    [JSExport]
    internal static void DoFunc([JSMarshalAs<JSType.Function<JSType.String>>] Action<string> jsFunc)
    {
        jsFunc.Invoke("Is C#");
    }

こんな関数を Program.cs に生やして

exports.MyClass.DoFunc((csharpPassedText) => {
    console.log(`This is from C#: ${csharpPassedText}`);
});
const fn = exports.MyClass.GreetingFn();
console.log(fn());

JavaScript 側で実行してみると

This is from C#: Is C#
Hello, World! Greetings from node version: v16.15.1

この様に関数を渡したり、関数を返すことが出来ます。
引数だったり型の制約があるのですが、それに関してはPRを参照してください。

また関数を渡したり関数を返すために、どの様に型を解決するか指定する JSMarshalAs アトリビュートを使用しています。
普段は使用しなくても良いケースが多いですが、ビルド時にコード生成に失敗した時に付与することが多いです。

またこの様に

    [JSExport]
    internal static Task<int> TaskFn()
    {
        return Task.FromResult<int>(1);
    }

Taskも返すことが可能で

const value = await exports.MyClass.TaskFn();

この様に JavaScript 側で await して値を取得することも可能です。

終わりに

ここまでで .NET JavaScript interop on WebAssembly の試し方とちょっとした応用について説明してきました。
技術としては面白いものなので、これからもウォッチしていきたいですね。

最後にこの技術を試す上で参考になるかもしれない私のリポジトリを宣伝して終わろうと思います。

この技術とNativeReferenceでSQLiteを使用したアプリケーションデモ、GitHub Pagesで動作している様子が見える

JSExportのアトリビュートを見つけてTypeScriptの型定義を作るSourceGenerator、JSImportも頑張りたい…

Preview 6時代からの変遷が見れるリポジトリで、試すためのDockerイメージの作り方とかを載せていた
C100 で頒布した技術同人誌で解説するのに利用

追記

2022/11/09 に .NET 7がGAしました :tada:
Preview時代からwasm-toolsをインストールしていた方はもしかしたらリリース版の.NET 7をインストールすると

Unhandled exception: Microsoft.NET.Sdk.WorkloadManifestReader.WorkloadManifestCompositionException: マニフェスト 'microsoft.net.workload.mono.toolchain' [/usr/local/share/dotnet/sdk-manifests/7.0.100/microsoft.net.workload.mono.toolchain/WorkloadManifest.json] 内のワークロード定義 'wasm-tools' が、マニフェスト 'microsoft.net.workload.mono.toolchain.net7' [/usr/local/share/dotnet/sdk-manifests/7.0.100/microsoft.net.workload.mono.toolchain.net7/WorkloadManifest.json] と競合しています
   at Microsoft.NET.Sdk.WorkloadManifestReader.WorkloadResolver.ComposeWorkloadManifests()
   at Microsoft.NET.Sdk.WorkloadManifestReader.WorkloadResolver.Create(IWorkloadManifestProvider manifestProvider, String dotnetRootPath, String sdkVersion, String userProfileDir)
   at Microsoft.DotNet.Workloads.Workload.List.WorkloadInfoHelper..ctor(VerbosityOptions verbosity, String targetSdkVersion, Nullable`1 verifySignatures, IReporter reporter, IWorkloadInstallationRecordRepository workloadRecordRepo, String currentSdkVersion, String dotnetDir, String userProfileDir, IWorkloadResolver workloadResolver)
   at Microsoft.DotNet.Workloads.Workload.List.WorkloadListCommand..ctor(ParseResult result, IReporter reporter, IWorkloadInstallationRecordRepository workloadRecordRepo, String currentSdkVersion, String dotnetDir, String userProfileDir, String tempDirPath, INuGetPackageDownloader nugetPackageDownloader, IWorkloadManifestUpdater workloadManifestUpdater, IWorkloadResolver workloadResolver)
   at Microsoft.DotNet.Cli.WorkloadListCommandParser.<>c.<ConstructCommand>b__6_0(ParseResult parseResult)
   at System.CommandLine.Invocation.InvocationPipeline.<>c__DisplayClass4_0.<<BuildInvocationChain>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.DotNet.Cli.Parser.<>c__DisplayClass17_0.<<UseParseErrorReporting>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at System.CommandLine.CommandLineBuilderExtensions.<>c__DisplayClass11_0.<<UseHelp>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at System.CommandLine.CommandLineBuilderExtensions.<>c.<<UseSuggestDirective>b__17_0>d.MoveNext()
--- End of stack trace from previous location ---
   at System.CommandLine.CommandLineBuilderExtensions.<>c__DisplayClass15_0.<<UseParseDirective>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at System.CommandLine.CommandLineBuilderExtensions.<>c__DisplayClass7_0.<<UseExceptionHandler>b__0>d.MoveNext()

こんな感じになってしまうかもしれません…
これは sdk-manifest ファイルが途中から net7 の文字列を含むようになり、 net6, net7 を意識させようとした結果、何も入ってないファイルが残ってコンフリクトしてしまうためです。
暫定的ではありますが

このIssueにあるように手動でPreviewやRC時代に使われていたファイルを削除することで解消されます。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
10