C# と .NET の勉強を目的に、自分用の Web アプリを ASP.NET Core MVC で組んだことがあった。 ローカルの macOS で動かす個人ツールで、SQLite にデータを入れて Razor で画面を返すだけのシンプルな構成。 せっかく作ったのでそのまま機能を足しながら日常的に使っていたが、ふだん書く言語が Python に寄ってきたこともあり、いつか書き換えたいとは思っていた。
書き換え先に FastAPI を選んだのは、比較的新しいフレームワークで自分が最もよく使う Python であること、それにこの規模のアプリにちょうどいい軽量さだったから。
この記事では、ASP.NET Core MVC と FastAPI の対応関係を整理しながら、書き換えの過程で気づいたことをまとめる。
ASP.NET Core MVC と FastAPI
まず今回の主役である 2 つのフレームワークを簡単に整理しておく。
ASP.NET Core MVC
Microsoft の Web フレームワーク。C# で書き、MVC(Model-View-Controller)パターンに沿ってアプリケーションを構成する。
.NET のエコシステムは成熟していて、DI(依存性注入)コンテナやミドルウェアパイプラインが標準で組み込まれている。
NuGet でパッケージ管理し、dotnet CLI でビルド・実行する。
HTTP サーバーも Kestrel が内蔵されているので、アプリ単体で起動すればそのままリクエストを受けられる。
型が強く、コンパイル時にエラーを拾ってくれる安心感がある一方、クラスやインターフェースの定義が増えがちで、小規模なアプリでもファイル数が膨らみやすい。
FastAPI
Python の Web フレームワーク。2018 年にリリースされた比較的新しいフレームワークで、Python の型ヒント(type hints)を活用するのが特徴。
Python の Web フレームワークといえば Django や Flask が有名だが、FastAPI はこの 2 つとは少し立ち位置が違う。 Django は「全部入り」で、ORM・管理画面・認証などが最初からセットになっている。 Flask は「最小限」で、必要な機能をプラグインで足していくスタイル。 FastAPI は Flask に近い軽量さを持ちつつ、型ヒントによる自動バリデーションと API ドキュメント自動生成(Swagger UI)が標準で付いてくる。
もう一つの特徴は非同期(async/await)対応。 FastAPI は ASGI(Asynchronous Server Gateway Interface)という非同期対応のインターフェース仕様に準拠している。 従来の Python Web フレームワーク(Django や Flask)は WSGI(Web Server Gateway Interface)という同期的なインターフェースが主流だったが、 ASGI はリクエストを非同期に処理できるため、I/O 待ちが多い場面でのスループットが高い。
ただし ASGI アプリケーション単体では HTTP リクエストを受けられないので、Uvicorn のような ASGI サーバーが別途必要になる。 .NET の Kestrel のように「フレームワークにサーバーが内蔵されている」わけではないのがこの点の違い。
なぜ FastAPI にしたか
書き換えたいとは思いつつ、.NET のまま使い続けていたのは「動いているから」に尽きる。ただ、macOS で dotnet を使う運用にはいくつか面倒な点があった。
- .NET ランタイムのバージョン管理(メジャーバージョンが上がるたびに
.csprojのTargetFrameworkを書き換えてパッケージを更新する必要がある) - ビルド時間の長さ(自分しか使わないアプリで毎回コンパイルが走るのが重い)
- NuGet パッケージの依存解決がたまに壊れる
自分しか使わないローカルツールにしては構えが大きい、というのが正直な感覚だった。
書き換え先を Python にしたのは、ふだん最もよく書いている言語だったから。macOS なら Homebrew で python3 が入るし、SQLite は標準ライブラリに含まれている。インタプリタ言語なのでビルド不要、ファイルを書き換えたら即反映される。
Python の Web フレームワークの中で FastAPI を選んだのは、比較的新しいフレームワークであることと、型ヒントベースのバリデーションが .NET の「型で守る」感覚に近かったこと。 Flask も候補にあったが、Flask は型ヒントがあくまでドキュメント的な役割にとどまる。 FastAPI は型ヒントからバリデーションルールと API ドキュメントが自動生成されるので、書いた型が実際に動作に効く。
Django は今回の用途に対して機能が多すぎた。ORM も管理画面も認証も不要で、SQLite を直接叩いて HTML を返すだけのアプリには FastAPI くらいの粒度がちょうどいい。
HTMX とは
HTMX は、HTML の属性だけでサーバーとの非同期通信を実現するライブラリ。JavaScript をほぼ書かずに、ページの部分更新ができる。
従来の Web アプリでは、フォーム送信後にサーバーがページ全体を返してリダイレクト → ページ丸ごと再読み込みという流れが一般的だった。
部分更新をやりたければ jQuery の $.ajax() や fetch() で JavaScript を書く必要がある。
React や Vue のような SPA フレームワークを入れる手もあるが、自分用のツールにはオーバースペック。
HTMX はその中間を埋めてくれる。
<!-- 削除ボタン -->
<button hx-post="/items/42/delete"
hx-target="#item-list"
hx-swap="innerHTML">
削除
</button>
<!--
hx-post : POST /items/42/delete にリクエストを送る
hx-target : レスポンス HTML で #item-list の中身を差し替える
hx-swap : 差し替え方法(innerHTML = 中身だけ入れ替え)
-->
この例だと、削除ボタンを押すと HTMX が POST /items/42/delete をバックグラウンドで送信し、サーバーが返した HTML 断片で #item-list の中身を差し替える。ページ全体のリロードは起きない。
サーバー側は「HTML の断片を返すだけ」でよくて、JSON API を設計する必要がない。 テンプレートエンジンで HTML を生成する従来のサーバーサイドレンダリングの延長線上で部分更新が実現できるのが、自分のようなバックエンド寄りの人間にはありがたかった。
.NET 版ではフォーム送信のたびにページ全体をリダイレクトで再読み込みしていたが、HTMX を入れたことで操作のたびに画面がちらつくことがなくなった。
プロジェクト構成の比較
.NET(ASP.NET Core MVC)のプロジェクトはこんな形になる。Controller・Service・Model・View がフォルダで分かれていて、それぞれが役割ごとのレイヤーを持つ構造。
MyApp/
├── MyApp.sln
├── MyApp/
│ ├── MyApp.csproj
│ ├── Program.cs # エントリポイント
│ ├── appsettings.json # 設定
│ ├── Controllers/
│ │ └── ItemController.cs # ルーティング + ハンドラ
│ ├── Services/
│ │ └── ItemService.cs # ビジネスロジック
│ ├── Models/
│ │ ├── Entities/
│ │ │ └── ItemEntity.cs # テーブルに対応する型
│ │ ├── ViewModels/
│ │ │ └── ItemViewModel.cs # View に渡すモデル
│ │ └── DTOs/
│ │ └── AddItemRequest.cs # POST リクエスト
│ ├── DataAccess/
│ │ ├── DbConnectionFactory.cs # DB 接続管理
│ │ └── ItemRepository.cs # SQL 実行(Dapper)
│ ├── Views/
│ │ ├── Shared/
│ │ │ └── _Layout.cshtml # 共通レイアウト
│ │ └── Item/
│ │ └── Index.cshtml # 個別ページ
│ └── wwwroot/ # 静的ファイル
│ ├── css/
│ ├── js/
│ └── lib/ # Bootstrap, jQuery 等 Models の中が Entities・ViewModels・DTOs と分かれ、さらに DataAccess が独立しているのが .NET の MVC らしいところ。 Entity はデータベースのテーブルに対応するクラス、ViewModel は画面に渡すデータをまとめたクラス、DTO(Data Transfer Object)はフォームの入力値などリクエストデータを受け取るクラス。 Repository は DB へのクエリ実行を担当する。それぞれ専用のクラスを作るのが .NET の流儀。
FastAPI 版はこうなった。
my-app/
├── app/
│ ├── main.py # エントリポイント・ルーター登録
│ ├── config.py # 設定(環境変数)
│ ├── templating.py # Jinja2 インスタンス
│ ├── routers/
│ │ └── items.py # ルーティング + ハンドラ
│ ├── db/
│ │ ├── __init__.py # sqlite3 接続管理
│ │ └── queries.py # SQL クエリ関数
│ ├── templates/
│ │ ├── base.html # 共通レイアウト
│ │ ├── items.html # 個別ページ
│ │ └── components/ # HTMX 用パーシャル
│ └── static/
│ ├── css/
│ └── js/
├── data/ # SQLite DB ファイル
├── requirements.txt
└── tests/ ルーター(= Controller)・DB・テンプレート(= View)でフォルダを分けている。.NET 版にあった Service 層・ViewModel・Entity・DTO がまるごとない。
.NET 版では Controller → Service → Repository → Entity / ViewModel と 4 つのレイヤーを通るのが定番だったが、 FastAPI ではルーターから直接 DB 関数を呼んでテンプレートに渡す 2 層構成にした。 レイヤーが減った分だけ追うコードも減る。 チーム開発やテスタビリティを考えれば層を分けたほうがいいが、自分専用ツールならこのくらいの粒度がちょうどいい。
エントリポイント
アプリケーションの起動地点がどう違うかを見てみる。
ASP.NET Core の Program.cs
.NET の Program.cs はビルダーパターンでサービス登録やミドルウェアの設定を行い、最後に app.Run() で起動する。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// DB 接続文字列を appsettings.json から取得して DI に登録
var connStr = builder.Configuration.GetConnectionString("Default")
?? "Data Source=data/app.db";
builder.Services.AddSingleton(new DatabaseConfig(connStr));
var app = builder.Build();
app.UseStaticFiles();
app.MapControllers();
app.Run(); builder.Services.AddControllersWithViews() で MVC に必要なサービス一式を DI コンテナに登録し、DB の接続文字列も DatabaseConfig として DI に入れておく。
この「ビルダーでサービスを登録 → パイプラインを構築 → 起動」という流れは ASP.NET Core の基本パターン。
app.Run() を呼ぶと内蔵の Kestrel サーバーが HTTP リクエストの受付を開始する。
別途 Web サーバーを用意しなくてもアプリが起動するのは便利だが、裏を返すとアプリのコードとサーバーの起動が一体化している。
FastAPI の main.py
FastAPI 版の main.py はこう書く。
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.routers import items
BASE_DIR = Path(__file__).resolve().parent
app = FastAPI(title="My App")
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
app.include_router(items.router) FastAPI() のインスタンスを作って、ルーターを include_router で登録するだけ。.NET の Program.cs と比べるとかなり短い。
ここで一つ気になるのが「サーバーの起動コードがない」こと。
FastAPI は ASGI アプリケーションを定義するだけで、HTTP サーバーとしての起動は Uvicorn に任せる。
つまり main.py は「アプリの定義」だけを書くファイルで、「起動」は別のコマンド(uvicorn app.main:app)で行う。
.NET では builder.Services.AddControllersWithViews() で MVC の一式を DI コンテナに登録するが、FastAPI にはそういう仕組みがない。
ルーターのモジュールを import して include_router するだけ。
DI に慣れていると最初は「これだけで動くの?」と思うが、Python ではモジュールの import 自体が事実上の DI として機能する。
lifespan(起動・終了時の処理)
.NET の Program.cs で起動時に DB 初期化などを書いていた部分は、FastAPI では lifespan で書く。
@asynccontextmanager で囲んだジェネレータの yield 前が起動処理、yield 後が終了処理にあたる。
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# 起動時の初期化処理(DB マイグレーションなど)
print("アプリケーション起動")
yield
# 終了時のクリーンアップ
print("アプリケーション終了")
app = FastAPI(title="My App", lifespan=lifespan)
.NET だと Program.cs の上から下に初期化コードを書けば起動時に実行されるし、終了処理は IHostedService や IDisposable で書くのが一般的。
FastAPI の lifespan は Python のコンテキストマネージャの仕組みを使っていて、yield の前後で起動・終了を分けるのは Python らしい書き方。
最初は少し独特に感じたが、起動と終了の処理が 1 つの関数にまとまるので見通しはよい。
Controller → Router
リクエストを受けて処理を振り分ける部分。.NET では Controller、FastAPI では Router と呼ぶ。名前は違うが、やっていることは同じ。
ASP.NET Core の Controller
.NET の Controller はクラスにアクションメソッドを並べる形。[Route] 属性でパスを、[HttpPost] で HTTP メソッドを指定する。
public class ItemController : Controller
{
private readonly IItemService _service;
public ItemController(IItemService service)
{
_service = service;
}
[Route("/")]
public IActionResult Index()
{
var model = _service.CreateIndexViewModel();
return View(model);
}
[Route("items/add")]
[HttpPost]
public IActionResult AddItem(AddItemRequest request)
{
_service.AddItem(request);
return RedirectToAction("Index");
}
[Route("items/delete")]
[HttpPost]
public IActionResult DeleteItem(int id)
{
_service.DeleteItem(id);
return RedirectToAction("Index");
}
}
Controller クラスは Controller を継承し、メソッドごとに [Route("...")] と [HttpPost] などの属性(Attribute)を付けてルーティングを定義する。
GET では return View(model) で Razor テンプレートをレンダリングし、POST では RedirectToAction で一覧に戻る。
コンストラクタで IItemService を受け取っているのは DI(依存性注入)のパターン。
FastAPI の Router
FastAPI の Router は関数にデコレータを付ける形。@router.get("/") や @router.post("/items") で同じことを書ける。
from contextlib import closing
from fastapi import APIRouter, Form, Request
from app.db import get_connection, queries
from app.templating import templates
router = APIRouter()
@router.get("/")
async def index(request: Request):
with closing(get_connection()) as conn:
rows = queries.load_items(conn)
return templates.TemplateResponse(request, "items.html", {"items": rows})
@router.post("/items")
async def add_item(request: Request, name: str = Form("")):
if name.strip():
with closing(get_connection()) as conn:
queries.insert_item(conn, name.strip())
rows = queries.load_items(conn)
return templates.TemplateResponse(
request, "components/item_list.html", {"items": rows}
)
@router.post("/items/{item_id}/delete")
async def delete_item(request: Request, item_id: int):
with closing(get_connection()) as conn:
queries.delete_item(conn, item_id)
rows = queries.load_items(conn)
return templates.TemplateResponse(
request, "components/item_list.html", {"items": rows}
) .NET の「クラス + 属性」に対して、FastAPI は「関数 + デコレータ」。パスとHTTPメソッドの指定方法が違うだけで、やっていることは変わらない。
FastAPI 版では HTMX のパーシャルレスポンスを返している。 POST の処理が終わったら更新後のリスト HTML だけを返して、HTMX が画面の該当部分だけを差し替える。 .NET 版の「POST → リダイレクト → ページ全体を再読み込み」に比べて、画面がちらつかない。
POST パラメータの受け取り方
フォームから送信されたデータをサーバー側でどう受け取るかは、両者で書き方が結構違う。
.NET は DTO(Data Transfer Object)クラスを定義して、ASP.NET のモデルバインド機能で自動マッピングする。
// POST リクエストを受け取るための DTO クラス
public class AddItemRequest
{
public string Name { get; set; }
public int Category { get; set; }
}
// Controller 側ではこう受け取る
[HttpPost]
public IActionResult AddItem(AddItemRequest request)
{
// ASP.NET が自動的にフォームの値を AddItemRequest にマッピングする
_service.AddItem(request);
return View("RedirectIndex");
}
FastAPI は Form(...) で各フィールドを関数の引数として直接受け取る。
# FastAPI 側ではデコレータの引数として受け取る
@router.post("/items")
async def add_item(
request: Request,
name: str = Form(""), # <input name="name"> に対応
category: int = Form(0), # <input name="category"> に対応
):
if name.strip():
with closing(get_connection()) as conn:
queries.insert_item(conn, name.strip(), category)
...
.NET の方式は「フィールドが増えても DTO クラスに足すだけ」で管理しやすいが、クラスファイルが増える。
FastAPI の方式は関数の引数を見ればフォームの項目がわかるのが利点。
ただしフィールドが 10 個を超えるような大きなフォームだと引数が長くなるので、そういう場合は Pydantic の BaseModel にまとめることもできる。
Service 層はどこに行ったか
.NET 版にはこういう Service クラスがあった。Controller から呼ばれ、ViewModel の組み立てと Repository の呼び出しを担当する。
public class ItemService : IItemService
{
private readonly IItemRepository _repository;
private readonly DatabaseConfig _dbConfig;
public ItemService(IItemRepository repository, DatabaseConfig dbConfig)
{
_repository = repository;
_dbConfig = dbConfig;
}
public IndexViewModel CreateIndexViewModel()
{
using var db = new SqliteConnection(_dbConfig.ConnectionString);
var model = new IndexViewModel
{
Items = _repository.LoadItems(db)
};
return model;
}
public void AddItem(AddItemRequest request)
{
using var db = new SqliteConnection(_dbConfig.ConnectionString);
_repository.InsertItem(db, request);
}
public void DeleteItem(int id)
{
using var db = new SqliteConnection(_dbConfig.ConnectionString);
_repository.DeleteItem(db, id);
}
} Controller → Service → Repository の 3 層構造は .NET の定番パターンだが、見てのとおり Service がやっていることは「DB から取ってきて ViewModel に詰める」だけ。 ビジネスロジックが薄いアプリだと、Service 層はただの受け渡し役になりがち。
FastAPI 版ではこの層を作らなかった。
ルーターの関数内で直接 queries.load_items(conn) を呼び、結果をテンプレートに辞書で渡している。
Python は関数ベースなので「わざわざクラスにまとめる必要がない」場面が多い。処理が複雑になったらモジュールを切り出せばよくて、最初から層を設けなくても困らなかった。
DB アクセス: Dapper → sqlite3 標準ライブラリ
Dapper とは
Dapper は .NET のマイクロ ORM。Entity Framework のようなフル ORM とは違い、SQL は自分で書く。 Dapper がやってくれるのは「クエリ結果を C# のクラスに自動マッピングする」ことと「パラメータのバインド」。
SQL を自分でコントロールしたいが、結果の変換は自動でやってほしい――そんな需要にちょうどいい。Stack Overflow が開発元で、パフォーマンスの高さに定評がある。
public IEnumerable<ItemEntity> LoadItems(SqliteConnection db)
{
db.Open();
var sql = @"SELECT * FROM Items";
return db.Query<ItemEntity>(sql);
}
public void InsertItem(SqliteConnection db, AddItemRequest request)
{
db.Open();
var sql = @"INSERT INTO Items(Name, CreatedAt) VALUES(@Name, @CreatedAt)";
db.Query(sql, new { Name = request.Name, CreatedAt = DateTime.Now.ToString("d") });
}
public void DeleteItem(SqliteConnection db, int id)
{
db.Open();
var sql = @"DELETE FROM Items WHERE Id = @id";
db.Query(sql, new { id = id });
} db.Query<ItemEntity>(sql) で、クエリの結果が ItemEntity クラスのプロパティに自動的にマッピングされる。
パラメータは匿名オブジェクト(new { Name = request.Name })で渡し、SQL 内の @Name に対応づけられる。
結果を受け取る Entity クラスは別途定義する。
// テーブルのカラムに対応するクラス
public class ItemEntity
{
public int Id { get; set; }
public string Name { get; set; }
public string CreatedAt { get; set; }
}
// Dapper がクエリ結果を自動で ItemEntity にマッピングする
// → row.Id, row.Name のようにプロパティでアクセスできる Python の sqlite3 標準ライブラリ
Python 版では外部パッケージの ORM を入れず、sqlite3 標準ライブラリを直接使った。
Python には SQLAlchemy という有名な ORM があるが、Dapper と同じく SQL を直接書くスタイルで移行したかったので不要と判断した。
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "app.db"
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=5)
conn.row_factory = sqlite3.Row # カラム名でアクセス可能にする
conn.execute("PRAGMA journal_mode=WAL")
return conn
ポイントは conn.row_factory = sqlite3.Row の行。
これを設定しないと、クエリ結果はタプル((1, "りんご"))で返ってくるためカラム名がわからない。
sqlite3.Row を指定すると辞書のようにカラム名でアクセスできるようになる。
# sqlite3.Row を使うと、辞書のようにカラム名でアクセスできる
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT Id, Name FROM Items").fetchone()
print(row["Id"]) # => 1
print(row["Name"]) # => "りんご"
# row_factory を設定しないと、タプルで返る
# row = (1, "りんご") ← カラム名がわからない クエリ関数の比較
クエリ関数を並べてみる。書き味はかなり近い。
def load_items(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return conn.execute("SELECT Id, Name, CreatedAt FROM Items").fetchall()
def insert_item(conn: sqlite3.Connection, name: str) -> None:
conn.execute(
"INSERT INTO Items (Name, CreatedAt) VALUES (?, ?)",
(name, datetime.now().isoformat(timespec="seconds")),
)
conn.commit()
def delete_item(conn: sqlite3.Connection, item_id: int) -> None:
conn.execute("DELETE FROM Items WHERE Id = ?", (item_id,))
conn.commit() Dapper も sqlite3 も「SQL を自分で書く」スタイルなので、移植はほぼ機械的だった。違いは 2 点。
- パラメータのバインドが
new { Name = param.Name }(名前付き)から(name,)(位置指定のタプル)に変わる - 結果のマッピングが Entity クラスへの自動変換(Dapper)から
sqlite3.Row(辞書風アクセス)になる
sqlite3.Row は Entity クラスのように事前にプロパティを定義する必要がない。
その分だけファイルが減るが、型安全性は落ちる。row["Naem"] のようなタイポがあっても実行時まで気づけない。
自分用ツールなら許容範囲だが、チーム開発だと Entity クラスのような型定義があるほうが安全。
DB 接続管理の違い
.NET 版では接続文字列を DatabaseConfig クラスに持たせ、DI で各 Service に渡す。接続のオープン・クローズは using ステートメントで管理する。
// DI で接続文字列を共有するための設定クラス
public class DatabaseConfig
{
public string ConnectionString { get; }
public DatabaseConfig(string connectionString)
{
ConnectionString = connectionString;
}
}
// 使う側では DI で受け取り、using で接続を開閉する
using var db = new SqliteConnection(_dbConfig.ConnectionString);
Python 版では get_connection() 関数と contextlib.closing で同じことをやっている。
C# の using var db = new SqliteConnection(...) と Python の with closing(get_connection()) as conn: は構文が違うだけで意図は同じ。「スコープを抜けたらリソースを閉じる」という確実なクリーンアップを保証する仕組み。
Python 版では PRAGMA journal_mode=WAL を設定している。SQLite の WAL(Write-Ahead Logging)モードで、複数プロセスが同時に DB を読み書きする場合の競合を緩和する設定。
テンプレート: Razor → Jinja2
サーバー側で HTML を生成するテンプレートエンジン。.NET では Razor、FastAPI では Jinja2 を使う。
Razor(.NET)
Razor は C# の式をテンプレート内に埋め込むエンジン。@ から始まる記法で C# のコードを HTML の中に書ける。
@model で ViewModel の型を宣言し、@foreach で繰り返す。
@model IndexViewModel
@{
ViewData["Title"] = "アイテム一覧";
}
<h2>アイテム一覧</h2>
<form method="post" action="/items/add">
<input type="text" name="Name" />
<button type="submit">追加</button>
</form>
<ul>
@foreach (var item in Model.Items)
{
<li>
@item.Name
<button onclick="deleteItem(@item.Id)">削除</button>
</li>
}
</ul>
共通レイアウトは _Layout.cshtml に書き、各ページの内容を @RenderBody() で差し込む。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - MyApp</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<script src="~/lib/jquery/dist/jquery.min.js"></script>
</head>
<body>
<nav>
<a href="/">ホーム</a>
</nav>
<main>
@RenderBody()
</main>
</body>
</html> Jinja2(FastAPI)
Jinja2 は Python のテンプレートエンジン。Flask でも標準的に使われていて、Python のテンプレートエンジンとしては最もメジャーな存在。FastAPI でも Jinja2Templates クラスを通じて利用できる。
Razor の @ が Jinja2 では 2 種類の記法に分かれる。
{{ 変数名 }}… 変数の値を出力する(Razor の@item.Nameに相当){% 制御構文 %}… if / for / block などの制御文(Razor の@foreach/@ifに相当)
{% extends "base.html" %}
{% block content %}
<h2>アイテム一覧</h2>
<form method="post" action="/items"
hx-post="/items" hx-target="#item-list">
<input type="text" name="name" />
<button type="submit">追加</button>
</form>
<div id="item-list">
{% include "components/item_list.html" %}
</div>
{% endblock %}
共通レイアウトは base.html に書き、各ページは {% extends "base.html" %} で継承する。Razor の @RenderBody() に相当するのが {% block content %}。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<script src="https://unpkg.com/htmx.org"></script>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<nav>
<a href="/">ホーム</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html> パーシャルテンプレート(HTMX との連携)
HTMX を導入したことで「パーシャルテンプレート」という概念が加わった。ページ全体ではなく、画面の一部分だけを表す HTML 断片のテンプレートのこと。
<!-- components/item_list.html -->
<ul>
{% for item in items %}
<li>
{{ item["Name"] }}
<button hx-post="/items/{{ item['Id'] }}/delete"
hx-target="#item-list">
削除
</button>
</li>
{% endfor %}
</ul>
フォーム送信時に hx-post でサーバーにリクエストを送ると、サーバーはこのパーシャルテンプレートだけをレンダリングして返す。
HTMX が受け取った HTML 断片で hx-target の要素を差し替えるので、ページ全体を再読み込みする必要がない。
.NET 版では Razor にもパーシャル(Partial View)の仕組みはあったが、活用していなかった。 フォーム送信 → ページ全体をリダイレクト → 再読み込みという古典的な流れで実装していたので、操作のたびに画面がちらついていた。 HTMX のパーシャルに変えてからは、画面の更新が滑らかになって使い勝手が明らかに良くなった。
テンプレートインスタンスの共有
.NET では return View(model) と書けば Razor エンジンが暗黙に動くが、FastAPI では Jinja2Templates のインスタンスを自分で作る必要がある。全ルーターで同じインスタンスを使い回すために、別モジュールに切り出した。
from pathlib import Path
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=Path(__file__).resolve().parent / "templates")
各ルーターのファイルで from app.templating import templates と書けば、同じ Jinja2Templates インスタンスを共有できる。
.NET のように DI コンテナで管理するのではなく、Python のモジュールシステムそのものがシングルトンの役割を果たしている。
設定管理: appsettings.json → 環境変数
.NET は appsettings.json を中心とした設定管理の仕組みが組み込まれている。appsettings.json(共通)と appsettings.Development.json(開発環境用)のように、環境別のファイルを用意すると自動でマージしてくれる。
{
"ConnectionStrings": {
"Default": "Data Source=wwwroot/DB/MyDB.db"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
コード内では Configuration.GetConnectionString("Default") のように、キー名を指定して値を取得する。
環境(Development / Production)は ASPNETCORE_ENVIRONMENT 環境変数で切り替わり、対応する JSON ファイルが自動的に読み込まれる。
FastAPI(Python)側には、こういった組み込みの設定管理機構はない。os.environ.get() で環境変数から読むのが一般的な方法。
import os
from pathlib import Path
_BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = Path(
os.environ.get("APP_DB_PATH", str(_BASE_DIR / "data" / "app.db"))
).expanduser()
STATIC_DIR = Path(
os.environ.get("APP_STATIC_DIR", str(_BASE_DIR / "app" / "static"))
).expanduser()
Pydantic の BaseSettings を使えば、.env ファイルからの読み込みと型バリデーションを自動化できるが、今回は自分用なのでシンプルに os.environ で済ませた。デフォルト値をコード内に書いておけば、環境変数を設定しなくてもそのまま動く。
.NET の「環境ごとに JSON ファイルを分ける」仕組みは整理されていて便利だが、ローカル専用のアプリで環境が 1 つしかないなら環境変数のほうが素直。設定ファイルがないぶん管理するファイルが減る。
実行環境とデプロイ
開発時の起動
.NET では dotnet run でアプリを起動する。コンパイル → ビルド → Kestrel サーバー起動が一連で実行される。ファイル変更時の自動リロードは dotnet watch run で対応。
FastAPI は ASGI アプリケーションなので、Uvicorn のような ASGI サーバーを使って起動する。
# 開発時(ファイル変更で自動リロード)
uvicorn app.main:app --reload --port 8000
# 本番(全インターフェースでリッスン)
uvicorn app.main:app --host 0.0.0.0 --port 8000 app.main:app の意味は「app パッケージの main モジュールにある app という変数」。
.NET の dotnet run がプロジェクトファイルからエントリポイントを自動解決するのに比べると明示的だが、「何を起動しているか」がコマンドを見るだけでわかるのはよい。
--reload を付けるとファイル変更時に自動リロードしてくれる。.NET の dotnet watch run と同じ役割だが、Python はコンパイル不要なので再起動が速い。ファイルを保存した瞬間にはもう反映されている。
本番環境の常駐化(launchd)
macOS で 24 時間動かすために launchd を使っている。.NET 版でも同じく launchd で dotnet MyApp.dll を常駐させていたので、起動コマンドを書き換えるだけで移行できた。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.myapp.web</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/my-app/.venv/bin/uvicorn</string>
<string>app.main:app</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>8000</string>
</array>
<key>WorkingDirectory</key>
<string>/path/to/my-app</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist> RunAtLoad で OS 起動時に自動開始、KeepAlive でプロセスが落ちたら自動再起動。
.NET 版では dotnet /path/to/MyApp.dll だったところを .venv/bin/uvicorn app.main:app に書き換えた。
仮想環境(.venv)内の uvicorn をフルパスで指定するのがポイントで、launchd はログインシェルの PATH を引き継がないのでフルパスでないと見つからない。
テスト
.NET では xUnit や NUnit でテストを書くのが一般的。テストメソッドに [Fact] や [Theory] 属性を付けて実行する。
FastAPI には TestClient というテスト用のクライアントが用意されている。サーバーを実際に起動しなくてもリクエストを投げられるので、テストの書き方がシンプル。
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_index():
response = client.get("/")
assert response.status_code == 200
def test_add_item():
response = client.post("/items", data={"name": "テスト"})
assert response.status_code == 200 pytest で実行する。
.NET のように属性を付ける必要がなく、test_ で始まる関数を書くだけで自動収集される。
TestClient は内部で ASGI アプリを直接呼び出すので、テスト用にポートを開ける必要もない。
.NET のテストでは WebApplicationFactory を使ってテスト用サーバーを立てるのが一般的だが、セットアップのコードがそこそこ長くなる。FastAPI の TestClient は 2 行で使い始められるので、テストを書くハードルが低い。
パッケージ管理
本番用に必要なパッケージはこの 4 つだけ。
fastapi
uvicorn[standard]
jinja2
python-multipart fastapi… Web フレームワーク本体uvicorn[standard]… ASGI サーバー。[standard]で HTTP パーサーや WebSocket 対応が付くjinja2… テンプレートエンジン。FastAPI に同梱されていないので別途インストールが必要python-multipart… フォームデータの解析用。これがないとForm()が使えない
開発用は pytest や mypy などを追加する。
# requirements-dev.txt
-r requirements.txt
pytest
httpx
mypy
ruff
.NET 版の .csproj には Microsoft.Data.Sqlite と Dapper の NuGet パッケージがあったが、
Python 版では SQLite が標準ライブラリに入っているので外部パッケージは不要。依存パッケージが少ないほど、バージョン更新やセキュリティパッチの追従が楽になる。
対応関係のまとめ
| .NET (ASP.NET Core MVC) | Python (FastAPI) |
|---|---|
| Controller クラス | Router(APIRouter) |
| [Route] / [HttpPost] 属性 | @router.get() / @router.post() デコレータ |
| Service 層 | なし(ルーターから直接呼ぶ) |
| ViewModel クラス | テンプレートに辞書で渡す |
| View (.cshtml / Razor) | Jinja2 テンプレート (.html) |
| _Layout.cshtml + @RenderBody() | base.html + {% block %} |
| Dapper + Microsoft.Data.Sqlite | sqlite3 標準ライブラリ |
| Entity クラス(プロパティアクセス) | sqlite3.Row(辞書風アクセス) |
| DTO クラス(モデルバインド) | Form() 引数 |
| appsettings.json(環境別) | os.environ(環境変数) |
| dotnet run / Kestrel(内蔵サーバー) | uvicorn(外部 ASGI サーバー) |
| using var db = new SqliteConnection(...) | with closing(get_connection()) |
| NuGet (.csproj) | pip (requirements.txt) |
| DI コンテナ | モジュールの import |
| AJAX(jQuery / fetch) | HTMX(HTML 属性のみ) |
| launchd(dotnet 起動) | launchd(uvicorn 起動) |
書き換えてみて
全体としてコード量は半分くらいになった。 .NET 版にあった Service 層・ViewModel・Entity・DTO クラスの大半がなくなったのが大きい。 Python のほうが書くときは楽だが、型情報が減る分だけ「あのカラム名なんだっけ」と DB を確認する頻度が増えた気はする。
Dapper → sqlite3 の移行が思ったよりスムーズだったのは発見だった。 どちらも「SQL を自分で書く」スタイルなので、クエリ部分はほぼコピペで移植できた。 仮に .NET 版で Entity Framework のようなフル ORM を使っていたら、テーブル定義やマイグレーションの仕組みごと作り直す必要があったはずで、こうはいかなかったと思う。
FastAPI の開発体験で一番よかったのは Swagger UI が勝手に生えてくるところ。
/docs にアクセスするだけで API の一覧と試し打ちができる。
.NET でも Swagger は使えるが、NuGet パッケージ(Swashbuckle)の追加と設定が必要で、FastAPI のように何もしなくても出てくる手軽さはない。
HTMX の導入は副次的な効果が大きかった。 .NET 版のときはフォーム送信のたびにページ全体がリロードされていたが、HTMX で部分更新にしたことで操作感がかなり良くなった。 JavaScript を書かずに HTML の属性だけで実現できるのも、保守の手間が増えなくて助かる。
DB ファイルをそのまま引き継げたのも助かった。テーブル定義が変わらないので、.NET 版で溜めたデータがそのまま使える。 フレームワーク間の移行でデータ移行が不要だったのは地味だが大きい。SQLite はファイル 1 つで完結するので、こういうときの身軽さが際立つ。