【Blazor】C#でブラウザ拡張を開発する

# はじめに
**Blazor**とは、JavaScriptが担っているDOM操作やリクエストといったクライアントサイドの動作を、C#を用いたテンプレートエンジン**Razor**の記法で行えるようにしたものです。
サーバーサイドでバイナリを実行し、結果をSignalRを通してクライアントに反映させる**Server-side Blazor**のみが正式版としてリリースされ、クライアントサイドでバイナリを直接実行する**Client-side Blazor**は、.NET Core 3.1現在プレビュー版となっています。

これを用いて先日、以下のようなものを作成しました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ブラウザ上だけでC#のコンパイルから実行、できた…。<br>ついでにchrome拡張に乗っけて完全オフラインコードテストを書きました(UIはciffeliaさんのものを拝借して、テストロジックだけを書き換えました)<br>ブラウザ上とは思えない速さで正直驚いています <a href="https://t.co/Tf2osJYjNW">pic.twitter.com/Tf2osJYjNW</a></p>&mdash; keymoon (@kymn_) <a href="https://twitter.com/kymn_/status/1203033961120686080?ref_src=twsrc%5Etfw">December 6, 2019</a></blockquote>

これは、コンパイラのRoslynをClient-side Blazorを用いてBackground pageで実行し、それに対してコンパイルと実行のリクエストをコンテンツスクリプトから送ることによって実現しています。
Client-side Blazorは静的な要素のみで完結しているため、このようなアドオンのようなものも作成可能です。

本記事では、Client-side Blazorで作成した簡単なアドオンを通じ、RazorにはなかったClient-side Blazorの要素やアドオンとして発行するに当たっての注意点等に触れていきたいと思います。

本記事を通じて作成していくアドオンは、以下のような現在地を取得して天気予報を表示するアドオンです。アドオンのpopup上に外部のAPIを叩いた結果を表示する簡素なものです。(Chrome、Firefoxで動作を確認しました。)
![j.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216639/2edac834-12b3-76d7-3f9f-bec6a94b192f.gif)

完成したリポジトリは以下になります。
[key-moon/WeatherForecastExtensionWithBlazor](https://github.com/key-moon/WeatherForecastExtensionWithBlazor)

# 1 JavaScript-C#間の相互運用

## 1.1 C#からJavaScriptを呼び出す

JavaScriptの関数は、C#から`IJSRuntime.InvokeAsync<T>(string identifier, params object[] args)`メソッドを用いることによって呼び出すことができます。以下にて、現在の座標を取得するコードを通じて使い方を見ていきます。

現在の座標は、JavaScriptで`navigator.geolocation.getCurrentPosition`関数を用いることで取得することができます。
これは引数にコールバック関数を要求します。ここで、C#からAction<T>を渡したとしても動くことはありません。C#からJavaScriptにオブジェクトを渡す場合はJSONを経由することで引渡しが行われるため、渡されたオブジェクトは持っていたメソッド等の情報を失うからです。
次項で説明しますが、専用のラッパで包むことで擬似的に参照の引渡しを実現できます。それによってC#の関数をコールバックとして実行することはできますが、ここではもう少し簡易的なアプローチを取ることにします。
まず、以下のようにして`getCurrentPosition`を非同期関数でラップします。

```main.js 
async function get_coord() {
    return new Promise((success, reject) => {
        if (!navigator.geolocation) reject("Geolocation is not supported by this browser.");
        navigator.geolocation.getCurrentPosition((position) => {
            success({ lat: position.coords.latitude, lon: position.coords.longitude });
        });
    }); 
}
```

そして、、C#側で`InvokeAsync`を用いることでJavaScript側の関数を非同期的に呼び出すことが可能になります。`InvokeAsync`はJavaScriptでのPromiseをC#でのValueTaskに置き換えてくれるため、これは以下のようにawaitすることで返り値を取得することができます。

```FetchData.razor.cs
async Task<Coord> GetCoordAsync()
{
    return await Runtime.InvokeAsync<Coord>("get_coord");
}

class Coord { public double lat { get; set; } public double lon { get; set; } }
```

## 1.2 JavaScriptからC#を呼び出す

今回は使用しませんでしたが、先程述べたようにC#側のメソッドをJavaScriptから呼び出すこともできます。
先程の例に続いて、コールバック関数として渡したC#側関数オブジェクトである`Action<T>`の`Invoke`をJavaScriptから呼ぶことを目標としましょう。
`JavaScript`側から呼び出すことができるC#のメソッドは、`[JSInvokable]`属性がついているものです。このような属性がついていた場合、`invokeMethod`で呼び出すことができます。staticメソッドの場合は`DotNet.invokeMethod`を、非staticの場合は後述の`DotNetObjectReference`オブジェクトの`invokeMethod`を使用します。staticメソッドの場合、引数は`(アセンブリ名, メソッド名, 引数1, 引数2…)`と取り、そうでない場合は`(メソッド名, 引数1, 引数2…)`と取ります。
つまり、もし`Action<T>.Invoke`に`[JSInvokable]`が指定されていたならば、`funcRef.invokeMethod("Invoke", position);`と書くことができるようになるということです。しかし、残念ながらそのようなことはありません。そのため、引数で渡されたActionを実行するだけの`InvokeTo`というstaticメソッドを作ってやります。(以下のコードでジェネリックを使っていないのは、ジェネリックメソッドにも`JSInvokable`を指定することができないからです。)

`DotNetObjectReference.Create`はJavaScriptに渡せる参照のラッパを作るメソッドです。作成された`DotNetObjectReference`オブジェクトは、Valueプロパティを参照すれば元の値を得ることができます。
ここでは、callbackの参照をラップして渡し、帰ってきたcallbackからValueを取り出して実行しています。

```_FetchData.razor.cs
void GetCoord(Action<Coord> callback)
{
    return await Runtime.InvokeVoidAsync(
        "get_coord_with_callback", 
        DotNetObjectReference.Create(callback)
    );
}

[JSInvokable]
static void InvokeTo(DotNetObjectReference<Action<Coord>> action, Coord arg)
    => action.Value.Invoke(arg);

class Coord { public double lat { get; set; } public double lon { get; set; } }
```

そして、JavaScript側では以下のように`DotNet.invokeMethod`を呼ぶ関数を指定するようにすれば良いです。

```_main.js 
function get_coord_with_callback(funcRef) {
    if (!navigator.geolocation) throw new Exception("Geolocation is not supported by this browser.");
    navigator.geolocation.getCurrentPosition((position) => {
        DotNet.invokeMethod("AsmName", "InvokeTo", funcRef, position);
    });
}
```

# 2 リクエストを送る

C#からAPIを叩いてみます。これは、普段C#で書いているのと同じようにHttpRequestを用いることで可能です。
今回であれば天気予報を取得したいため、[OpenWeather](https://openweathermap.org/)の座標から3時間おきの天気予報を得ることができるエンドポイントを叩くこととします。([ドキュメント](https://openweathermap.org/forecast5))
ここで注意することとして、以下のようにデフォルトでDIされている`HttpClient`を使ってはいけません。

```cs
@inject HttpClient Client
```

Blazorでシングルトンとして生成されるHttpClientは、`BaseAddress`を`location.origin`に指定します。アドオンのポップアップページでの`location.origin`はスキームが特殊なため、`BaseAddress`に指定しようとした時点で例外を起こしてしまいます[^2]。

[^2]: 余談ですが、上記の理由からアドオンに置いた別のファイルを動的にダウンロードする、といったことがC#側からだとできません。そのため、もしもファイルをロードをする場合はJavaScriptを経由させる必要があります。

これを踏まえた上で実装すると以下のようになります。

```FetchData.razor.cs
HttpClient Client = new HttpClient();
const string AppID = "xxxx";
static readonly Uri BaseURL = new Uri("https://api.openweathermap.org");
private City City;
private Forecast[] forecasts;

protected override async Task OnInitializedAsync()
{
    var coord = await GetCoordAsync();
    var query = new Uri(BaseURL, $"/data/2.5/forecast?lat={coord.lat}&lon={coord.lon}&units=metric&appid={AppID}");
    var forecast = await Client.GetJsonAsync<Forecasts>(query.AbsoluteUri);
    City = forecast.city;
    forecasts = forecast.list;
}
```

# 3. アドオンとして発行する

`Client-side Blazor`では、ファイルに発行をすることでビルド後に静的デプロイできるファイルを`[BuildDirectory]/[ProjectName]/dist`に配置します。そのため、`dist`ディレクトリをアドオンとして発行すれば良いこととなります。
発行するためには、アプリケーションの設定等を記した`manifest.json`が必須です。これはアドオンのルートディレクトリに書き出されていたいため、ウェブサーバーに静的コンテンツを配置する場合に用いる`wwwroot`ディレクトリ直下に追加します。
今回は位置情報を使用しているのでpermissionには"geolocation"を指定しています。
そして、popupに表示されるHTMLには`index.html`を指定しました。
また、Blazorのブートや実行時に`eval`や`script`タグの埋め込みを行うため、`content_security_policy`でそれを許可する必要があります。`eval`については`unsafe-eval`を、`script`タグについては、実行されるscriptのhashを指定します。`script`タグは、ブートでしか追加されず、追加されるscriptは常に一定なのでhashを調べることができます。
結果として、`manifest.json`は以下のようになります。

```manifest.json
{
  "name": "ForecastViewer",
  "version": "1.0",
  "description": "",
  "content_security_policy": "script-src 'self' 'unsafe-eval' 'sha256-v8v3RKRPmN4odZ1CWM5gw80QKPCCWMcpNeOmimNL2AA='; object-src 'self'",
  "permissions": [ "geolocation" ],
  "browser_action": {
    "default_popup": "index.html"
  },
  "manifest_version": 2
}
```

ここで`index.html`をページとして指定した関係で、アプリケーションのマッピングを`/index.html`にする必要があります。(デフォルトでは`/`などになっていることが多いです。)

```FetchData.razor.cs
@page "/index.html"
```

また、ファイル名/ディレクトリ名の先頭にアンダーバーを含めることはできません。そのため、発行されたところにある`_framework/_bin`というディレクトリを改名する必要があります。
改名した後、index.html,blazor.webassembly.jsに含まれるこれらの文字列も変える必要があります。

```sh
# ディレクトリからアンダーバーを除去
mv _framework/_bin _framework/bin
mv _framework framework
# スクリプト内で登場しているところを書き換え
sed 's/_bin/bin/g' framework/blazor.webassembly.js
sed 's/_framework/framework/g' framework/blazor.webassembly.js index.html
```

これで、晴れてアドオンとして発行できます。`Chrome`ならば`パッケージ化されていない拡張機能を読み込む`で`dist`ディレクトリを、`Firefox`ならば`一時的なアドオンを読み込む`で`manifest.json`を指定してください。

# おわりに
いかがでしたでしょうか。
冒頭で触れたような特殊なケース以外では、アドオンを作る際の第一の選択肢として自信を持ってBlazorをオススメすることは現状できません。
ただ、Blazorに触れて数時間の素人である私がある程度のことができるように、様々なことが簡素に行えるとても柔軟性の高い技術であることを感じ取っていただければ幸いです。