xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

MudBlazor 標準の css / js が 404 になる場合の対処方法

Blazor で綺麗な画面を作ろうと思ったら、まず UI Component ライブラリの利用を考えます。過去に利用経験があり、そこそこお気に入りなのが MudBlazor です。

久々に使ってみる機会があったので触ってみたら、導入段階でいきなり大きく躓いたので対処方法をメモ。

遭遇した事象

MudBlazor をプロジェクトに組み込もうとすると、NuGet 参照したあとに css / js を追加しておく必要があります。.NET 8 以降であれば App.razor に以下のように追加することになります。特に何も難しくなく、ほぼこれだけで下準備は終わりです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <!-- 2 つの css を末尾付近に追加 -->
    <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
    <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />

    <HeadOutlet />
</head>
<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>

    <!-- js を末尾付近に追加 -->
    <script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>

...が、いざデバッグ実行してみようとしたら css / js が 404 になりました!なぜ!

原因

何か実装を間違ったのかと思っていろいろ試してみた結果、環境変数の ASPNETCORE_ENVIRONMENTDevelopment 以外に設定すると発生することが分かりました。「Local みたいな独自の値を入れているときはどうするんじゃい!」と激おこぷんぷん丸ですね。

対処方法

ASPNETCORE_ENVIRONMENT を自由にしたいので、対処方法を探します。MudBlazor の導入ドキュメントには見当たらなかったのですが、GitHub で Issues を彷徨ってようやく発見しました。

NuGet Package に含まれている staticwebassets フォルダを参照するよう、明示的に Program.cs に記述することで回避できます。

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStaticWebAssets();  // ← これを追加

さらに調べていくと、ASP.NET Core Blazor の静的ファイル解決の仕様上 Development のときしか自動的に読み込まないようにしているみたいです。以下のドキュメントに記載がありました。

ASPNETCORE_ENVIRONMENTDevelopment 以外に設定したい方はご注意ください。僕は 2 時間以上を溶かしました。

C# 12 の新機能「Primary Constructors」に対する IntelliSense の神対応

2023 年 11 月に C# 12 が正式リリースされました!大変おめでたいし、Microsoft 本社の C# 開発チームの方々には (毎年のことながら) 感謝申し上げます...!

で、C# 12 で導入された目玉機能のひとつに Primary Constructors というものがあります。必須コンストラクタを record 型っぽい記述で書けるようにする機能です。

class A(int x)
{
    public int X { get; } = x;
}

これは C# 6 の頃から導入が検討されていた機能で、約 10 年弱の検討と改良を経て正式に採用されました。以下の記事は 2014/6/16 に書いたものですが、リリース直前に搭載が見送られたことまで残っていました。過去の自分、エラいね...w

正式リリースされたけど使うことに抵抗があった

正直なところ、Primary Constructors を自分が書くコードに採用するのには前向きではありませんでした。特に以下のふたつが理由です。

  1. コンストラクタ引数をキャプチャして利用できてしまう
  2. コンストラクタ内で何か処理を書きたくなったら通常のコンストラクタに変換しないといけない

「1」は Primary Constructors の挙動をちゃんと理解しないとダメで、自分がよくてもチーム全体で統制できるかが不安でした。「2」は単純に面倒だからです。通常のコンストラクタから Primary Constructors への変換機能は Visual Studio に搭載されているので簡単ですが、逆がないのが懸念でした。

けど「使っていこう」と決心した

しばらく抵抗感が強かったにも拘らず心変わりした理由は次の通りです。

  • .editorconfig を使って CS9124 をエラーにすることで多重 Backing Field 問題を防げる
    • C# の挙動を詳細まで把握していないチームメイトに対してもある程度の強制力を持って対処できる
  • DI 部分に限ればコンストラクタ内に処理を書くことがまずない
    • ので、そこに限れば「十分飲み込める」と思えるようになってきた
  • Visual Studio の IntelliSense が多重 Backing Field 問題を緩和していることに気付いた

特に最後の IntelliSense の挙動を知ったことが大きいです。「百聞は一見に如かず」ということで以下のキャプチャをご覧ください。

プロパティに引数を代入しさえすれば IntelliSense にすら表示されない という神対応!これによって「コードを書いてから CS9124 で間違いに気付くのではなく、コードを書く瞬間から間違いを起こさせない」ことになるので抵抗感がグッと下がりました。

まとめ

今後は Primary Constructors にも書き慣れていきたい所存。しかし、この IntelliSense はどうやって対応しているんでしょうね。決め打ちの特別対応なのかな。

Path.GetDirectoryName() は overload によって挙動が異なる

久々に Path.GetDirectoryName() を使ったら、件名の通りの挙動に引っ掛かりました。文章で説明するよりもサンプルコードを見た方が早いので、以下をご覧ください。

const string fileName = "A/B/C/D.cs";

var dir1 = Path.GetDirectoryName(fileName);
dir1.Dump("string");  // A\B\C

var dir2 = Path.GetDirectoryName(fileName.AsSpan());
dir2.Dump("ReadOnlySpan<char>");  // A/B/C

ご覧の通り string の overload だと /\ に化けますが、ReadOnlySpan<char> の方はそうなりません。だいぶ罠ですね。

string の overload は .SubString() をして返すだろうことは容易に想像が付きますが、\ への変換までオマケで付いてきます。PathInternal.NormalizeDirectorySeparators() なんて余計なことを...。 ReadOnlySpan<char> の overload は .Slice() をするだけでメモリアロケーションもなく、加えて変なオマケも付いてこないので安心です。

string の overload は過去の遺産ということで見なかったことにして、ReadOnlySpan<char> の overload を使っていきましょう。

Swashbuckle.AspNetCore.Cli で FileLoadException が出る場合の対処

最近社内で「CI で swagger.json を生成したいから対応してほしい」と言われてやってみました。そして思いっきりハマりました。ということで忘れないようにメモ。

ちなみに本内容は執筆時点での最新版である「.NET 7 + Swashbuckle.AspNetCore (v6.5.0)」で再現しているもので、バージョンが異なると挙動が変わる可能性があります。その点はご了承ください。

TL;DR

  1. SwaggerHostFactory.CreateHost を利用する
  2. 古き良き Startup クラスを使って IHost を生成する

発生した問題を再現してみる

最小プロジェクトを準備

細かいことは省略しつつ、大まかに以下のようなミニマムな ASP.NET Core MVC プロジェクトを生成します。

// Program.cs
var builder = WebApplication.CreateBuilder(args);

var services = builder.Services;
services.AddControllers();
services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.Run();
// SampleApiController.cs
[ApiController]
[SkipStatusCodePages]
[Route("api/sample")]
public class SampleApiController : ControllerBase
{
    [AllowAnonymous]
    [HttpGet("foo")]
    public IActionResult Foo()
        => this.Ok();
}

この状態で /swagger を実行すると以下のように正常に Swagger UI が表示されますし、/swagger/v1/swagger.json も開くことができます。非常に順調です。あとはこの swagger.json を CLI で出力できるようにするだけです。

{
    "openapi": "3.0.1",
    "info": {
        "title": "WebApplication2",
        "version": "1.0"
    },
    "paths": {
        "/api/sample/foo": {
            "get": {
                "tags": [
                    "SampleApi"
                ],
                "responses": {
                    "200": {
                        "description": "Success"
                    }
                }
            }
        }
    },
    "components": {}
}

CLI 出力を試みる

では Swashbuckle.AspNetCore.Cli をローカルツールとして導入して swagger.json を出力してみましょう。コマンドは以下のような感じです。

// インストール
dotnet new tool-manifest
dotnet tool install --local Swashbuckle.AspNetCore.Cli
// 出力
dotnet swagger tofile --output swagger.json WebApplication2/bin/Debug/net7.0/WebApplication2.dll v1

すると以下のように FileLoadException が出力されます。...なんでやねん!

Unhandled exception. System.IO.FileLoadException: The given assembly name was invalid.
File name: 'WebApplication2/bin/Debug/net7.0/WebApplication2.dll'
   at System.Reflection.AssemblyNameParser.ThrowInvalidAssemblyName()
   at System.Reflection.AssemblyNameParser.Parse()
   at System.Reflection.AssemblyNameParser.Parse(String name)
   at System.Reflection.AssemblyName..ctor(String assemblyName)
   at Microsoft.AspNetCore.Hosting.StartupLoader.FindStartupType(String startupAssemblyName, String environmentName)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.ScanAssemblyAndRegisterStartup(HostBuilderContext context, IServiceCollection services, WebHostBuilderContext webhostContext, WebHostOptions webHostOptions)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.<.ctor>b__6_2(HostBuilderContext context, IServiceCollection services)
   at Microsoft.AspNetCore.Hosting.BootstrapHostBuilder.RunDefaultCallbacks()
   at Microsoft.AspNetCore.Builder.WebApplicationBuilder..ctor(WebApplicationOptions options, Action`1 configureDefaults)
   at Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(String[] args)
   at Program.<Main>$(String[] args) in C:\Users\xin9le\Documents\Visual Studio 2022\Projects\WebApplication1\WebApplication2\Program.cs:line 1
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
   at System.Reflection.MethodInvoker.Invoke(Object obj, IntPtr* args, BindingFlags invokeAttr)
--- End of stack trace from previous location ---
   at Microsoft.Extensions.Hosting.HostFactoryResolver.HostingListener.CreateHost() in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostFactoryResolver.cs:line 271
   at Microsoft.Extensions.Hosting.HostFactoryResolver.<>c__DisplayClass8_0.<ResolveHostFactory>b__0(String[] args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostFactoryResolver.cs:line 75
   at Swashbuckle.AspNetCore.Cli.HostingApplication.GetServiceProvider(Assembly assembly) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostingApplication.cs:line 72
   at Swashbuckle.AspNetCore.Cli.Program.GetServiceProvider(Assembly startupAssembly) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 152
   at Swashbuckle.AspNetCore.Cli.Program.<>c.<Main>b__0_4(IDictionary`2 namedArgs) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 82
   at Swashbuckle.AspNetCore.Cli.CommandRunner.Run(IEnumerable`1 args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\CommandRunner.cs:line 68
   at Swashbuckle.AspNetCore.Cli.CommandRunner.Run(IEnumerable`1 args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\CommandRunner.cs:line 59
   at Swashbuckle.AspNetCore.Cli.Program.Main(String[] args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 121

対処方法

StackTrace を見るに、どうも Swashbuckle.AspNetCore.Cli は指定したアセンブリの Main メソッドをダイレクトに実行するようです。その過程で Startup クラスを探そうとして失敗している、と。しかし .NET 6 以降で標準テンプレートとなった Top Level Statement で初期化 / 実装をしているので Startup クラスはもうありません。どうも Top Level Statement に対応していない感じですね。

ではどう対処するのかというと、Swashbuckle.AspNetCore.Cli の実行時にのみ特別に解釈される IHost の生成処理を利用します。アセンブリ内にある特定の型名とメソッド名から IHost を返してあげればそれを利用するようになっています。特別扱いするのは以下のどちらかの書き方です。所謂ダックタイピング的アプローチですね。

public class SwaggerHostFactory
{
    public static IHost CreateHost()
    { }  // 省略
}
public class SwaggerWebHostFactory
{
    public static IWebHost CreateWebHost()
    { }  // 省略
}

実際にやってみる

では先の書き方に従って実装していきましょう。僕は次のように実装してみました。ポイントとして SwaggerHostFactoryinternal でも問題ありません。Startup クラスは同一ファイル内でしか利用しないので file スコープで大丈夫です。また、ここでも UseStartup をしない書き方で IHost を生成することは認められていません。同様に実行時エラーになります。

internal sealed class SwaggerHostFactory
{
    public static IHost CreateHost()
        => Host
        .CreateDefaultBuilder()
        .ConfigureWebHostDefaults(static x => x.UseStartup<Startup>())
        .Build();
}

file sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddSwaggerGen();
    }

    public void Configure(IApplicationBuilder _)
    { }
}

ここまで実装したら改めて dotnet swagger tofile コマンドを実行してみましょう。無事に出力されるはずです。めでたし ×2。

公式 GitHub にもこのような対処方法については書かれていないですし、実行時例外の StackTrace しか情報がなく解決に大変苦労しました。同じような問題にぶち当たっている方の一助になれば幸いです。

Azure Functions (Isolated) における ITelemetryInitializer / ITelemetryProcessor の注意点

最近 Azure Functions (Isolated) から出力される Application Insights のテレメトリと格闘していました。何を今更そんなに格闘することがあるのかというと、テレメトリの一種である RequestTelemetry に対するアクセスが全くできないという問題に遭遇したからです。今回はそんな挙動について調べたことを残していきます。

tl;dr;

  • Azure Functions (Isolated) において、一部のテレメトリ (RequestTelemetry など) にアクセスできない
  • Azure Functions (Isolated) が Host と Worker に分離したことに起因
  • Worker 側で ITelemetryInitializer 等を利用してテレメトリを操作しようにも、Host 側が出力したテレメトリには触れられない

同様の問題に対する Issue は既に挙がっています。

前提知識

まず Azure Functions の前提として、InProc Model と Isolated Model の違いを押さえておく必要があります。詳細は公式のドキュメントに譲りますが、Azure Functions を利用しているとこの違いがあれこれと面倒を起こします。

InProc Model

  • C#/.NET で動作させる場合の従来モデル
    • .NET 6 まではこちら
  • Function Host にアプリケーション .dll を読み込ませ、Host と一体となってコードを実行させる
  • .NET の最新バージョンを使いたい場合、Function Host が対応してくれない限りアプリ側が利用できない問題があった
    • それを解消するのが Isolated Model
    • Node.js など C#/.NET 以外で作る場合は最初から Isolated Model だったが、C#/.NET は特別に一体型だった

Isolated Model

  • Functions Host とアプリケーション側 (Worker) を分離させ、各々好き勝手できるようにしたモデル
    • .NET 6 以降で利用可能で、.NET 7 以降では完全にこちらのみ
  • Host / Worker の間は gRPC で繋がれている
    • これが色々と厄介な問題や制限を抱えるのだけど、一旦それはさておき...

検証

ということで、Isolated Model における Application Insights へのテレメトリ送信の実験をしてみます。Application Insights SDK はテレメトリ送信前をフックして内容を編集するための拡張ポイントである ITelemetryInitializerITelemetryProcessor を提供しています。今回は動作検証ということで ITelemetryInitializer のみで試してみましょう。以下のようなコードを書きます。

// テレメトリに投げる前に編集しちゃう君
internal class MyTelemetryInitializer : ITelemetryInitializer
{
    public void Initialize(ITelemetry telemetry)
    {
        var props = (ConcurrentDictionary<string, string>)telemetry.Context.Properties;
        props.TryAdd("SayHello", "Hello, World!!");  // カスタムプロパティを追加
        telemetry.Context.Component.Version = "1.2.3.4";  // バージョンを設定
    }
}
// Isolated Host を利用した Azure Functions の初期化処理
new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(static (context, builder) =>
    {
        // DI に登録
        builder.Services.AddSingleton<ITelemetryInitializer>(new MyTelemetryInitializer());
    })
    .Build()
    .Run();
// いわゆる HttpTrigger
internal class Http_Isolated_DebugStart
{
    [Function(nameof(Http_Isolated_DebugStart))]
    public async Task<HttpResponseData> EntryPoint(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "debug/start")] HttpRequestData request,
        FunctionContext context)
    {
        var logger = context.GetLogger<Http_Isolated_DebugStart>();
        logger.LogInformation($"Hello, Isolated!!");  // ログを吐いておく
        return request.CreateResponse(HttpStatusCode.OK);
    }
}

この状態で Azure にデプロイして Application Insights を確認してみます。すると次のような感じのテレメトリが出力されます。全プロパティを表示した画像のため拡大しないと見られないかもですが、その点はご了承ください。

RequestTelemetry (※A) TraceTelemetry (※A) TraceTelemetry (※B)
  • ※A : Host 側が出力
  • ※B : Worker 側が出力

※A で示した方には ITelemetryInitializer によるプロパティやバージョン値が入っていないことが分かります。一方で ※B で示した Worker 側で明示的に出力したログには値が入っていますね。Host と Worker が分離したことで、一部のテレメトリにはアクセスできていないことが確認できます。

まとめ

とういうことで Azure Functions (Isolated) におけるテレメトリ操作の注意点を簡単に見てきました。今回はサボッて載せませんが、InProc Model では (当然) すべてのテレメトリをフックして値を操作することができます。こういった部分の差分を認識して、運用時に困らないようにしていきたいですね。

Azure Functions (Isolated) は、まだまだ InProc と動作が異なる部分や InProc との機能差 (= Isolated の方が機能が足りてない) が残っています。Azure Functions チームはこれらを埋める作業を毎年地道に頑張っていて、最近では Durable Functions に対応したりもしています。応援!