はじめに
.NET環境では、従来から「フロントエンドとバックエンドを一体化する」解決策が存在しており、例えばBlazor ServerやBlazor WebAssembly、ASP.NET CoreによるIISホスティングなどが該当します。しかし、これらの手法はいずれもフロントエンドの別々なデプロイを必要としたり、実行時に多くの依存関係を伴い、ソースコードが公開されやすく、起動時間やクロスオリジン設定などの問題を引き起こすことがあります。 一方、GoやRustの世界では、一つの実行ファイルでHTTPサーバーと静的サイトを完結させることができ、コピーして実行するだけで動作し、コンパイル後にはソースコードの痕跡すら見られません。 C#でも同様のことが可能です。本記事では、「単一ファイル」「AOT」「フロントエンドとバックエンドを統合したexe」の構築手順を詳しく解説します。
プロジェクトの準備(3分で完了)
このプロジェクトは従来通りフロントエンドとバックエンドの分離アーキテクチャを採用し、フロントエンドはVueやReactなどのフレームワークを使って実装し、.NET側でそれらを統合(フロントエンドのコードは埋め込み形式)します。 全体の流れはシンプルで、.NET 10を前提として以下の手順で作成できます:
dotnet new webapi -n AotDocsify -aotを実行して、ASP.NET Core Web API (native AOT)プロジェクトを作成します。- プロジェクトルートに
wwwrootフォルダを作成します。 - フロントエンドのビルド成果物(または任意のdistフォルダ)を
wwwrootにコピーします(本例ではdocsifyプロジェクト(純粋なマークダウンドキュメントライブラリ)を使用し、nginxのhtmlディレクトリのファイルをここに移動しました)。
埋め込み静的ファイルの処理
.csproj ファイルを開き、wwwroot フォルダ内のすべてのファイルを埋め込みリソースとして指定します。
コンパイラによりこれらのファイルはPEリソースセクションに書き込まれ、AOTコンパイル後もアクセス可能になります。リフレクションや動的生成は不要で、安心して使用できます。
<ItemGroup>
<EmbeddedResource Include="wwwroot\**\*" />
</ItemGroup>
WebApplicationでの埋め込みファイル認識
既存のバックエンドコードはそのまま保持し、静的ファイルを追加します。EmbeddedFileProvider を使用して、埋め込まれたリソースを静的ファイルとして扱います。
EmbeddedResource でファイル名を変更しない場合、完全なデフォルトネームスペース(アセンブリ名)を指定する必要があります。つまり "AotDocsify.wwwroot" のように記述します。これにより、パスを指定することで対応する埋め込みファイルを返すことができます。
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new EmbeddedFileProvider(Assembly.GetExecutingAssembly(), "AotDocsify.wwwroot"),
RequestPath = ""
});
ポイント:Assembly.GetExecutingAssembly().GetManifestResourceStream はNativeAOTにおいて「本格的なリフレクション」ではなく、コンパイル時に既にPEに埋め込まれたメタデータテーブルに対する定数検索です。もし .csproj で index.html を EmbeddedResource として指定していれば、NativeAOTコンパイラはそのリソース名とともにそれを最終イメージに書き込み、リフレクションや動的コード生成なしのスタブコードを生成します。そのため、実行時には「リフレクションが削除された」という例外も発生せず、「リソースが見つからない」エラーも発生しません。このため、このコードはそのままコンパイル可能です。
ページルーティング設定
ルートパス / およびSPAの404ハンドリングは手動で設定する必要があります。例えば localhost:5000 にアクセスした際、サーバーは404を返します。これは、ルートディレクトリのルーティングが未設定であるためです。従来の方法では物理ファイルパスに依存しているため、明示的にルートを追加する必要があります。具体的なコードは以下の通りです:
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("AotDocsify.wwwroot.index.html");
if (stream is null) throw new Exception("埋め込みの index.html が見つかりません");
string indexHtml;
using (var reader = new StreamReader(stream))
indexHtml = reader.ReadToEnd();
app.MapGet("/", () => Results.Content(indexHtml, "text/html"));
app.MapFallback(() => Results.Content(indexHtml, "text/html"));
以下のコードで、埋め込まれたリソースとそのパスを確認できます:
var names = Assembly.GetExecutingAssembly().GetManifestResourceNames();
Console.WriteLine("=== 埋め込みリソース一覧 ===");
foreach (var n in names) Console.WriteLine(n);
// === 埋め込みリソース一覧 ===
// AotDocsify.wwwroot..nojekyll
// AotDocsify.wwwroot.about.contributing.md
// AotDocsify.wwwroot.about.project.md
// AotDocsify.wwwroot.advanced.i18n.md
// AotDocsify.wwwroot.advanced.plugins.md
// AotDocsify.wwwroot.advanced.theme.md
// AotDocsify.wwwroot.examples.code-highlight.md
// AotDocsify.wwwroot.examples.markdown.md
// AotDocsify.wwwroot.examples.math.md
// AotDocsify.wwwroot.features.cover.md
// AotDocsify.wwwroot.features.multipage.md
// AotDocsify.wwwroot.features.navbar.md
// AotDocsify.wwwroot.features.sidebar.md
// AotDocsify.wwwroot.guide.basic-usage.md
// AotDocsify.wwwroot.guide.installation.md
// AotDocsify.wwwroot.guide.quickstart.md
// AotDocsify.wwwroot.README.md
// AotDocsify.wwwroot._sidebar.md
// AotDocsify.wwwroot.index.html
ビルド後、exe以外のファイル(wwwrootフォルダを含む)をすべて削除します。この時点ですべてのフロントエンドファイルは埋め込まれているため、本例で生成されたAOT実行ファイルは約10MBで、実行後にフロントエンドとバックエンドともに正しく動作し、バックエンドのリソースはフロントエンドから呼び出し可能で、クロスオリジンの問題も発生しません。http://localhost:5000 にアクセスするとページが表示され、http://localhost:5000/todos にアクセスするとAPIのレスポンスが得られます。
まとめ
本記事では、最小限のコードで「フロントエンドのdist + バックエンドAPI」を1つのAOT実行ファイルに圧縮し、デプロイを「コピー → 実行」の2ステップに簡略化する方法を紹介しました。 読者の皆様にご不明点や実際に試行中に問題が生じた場合は、いつでもご質問ください。皆様からのフィードバックや提案を楽しみにしており、コンテンツの改善に役立ててまいります。今後も「萤火初芒」の公式アカウントをご購読いただければ、より多くの有益な技術情報を提供できると考えています。一緒に学び、成長していきましょう。