🔌

Django + Inertia + Vite + React で作る Web アプリの新たな選択肢

2024/06/04に公開

はじめに

みなさん、マイクロサービスに疲れていませんか?

  • バックエンドにDjango, Laravelをたてているのに、フロントエンドで別途Next.js(Node.js)をたてているのが意味わからん
  • モダンにWebサービスをたてたいだけなのに、なぜAPIを解放しないといけないのか
  • [Django React アプリ構築] [検索]
    • 単純にバックエンドはDjango, フロントエンドにReactを使いたい、それだけなのに、こんな複雑な構成にしないといけないの...?

今回ご紹介するモジュラモノリスなアーキテクチャでは、以下のようにサクッとWebサービスを構築できます。

views.py
import django
import inertia

@intertia('ShowVersion')
def index(request):
  return {
    'version': django.get_version()
  }
ShowVersion.tsx
type Props = {
    version: string;
}

export default Page({ version }: Props){
    return (<div>
        Django: {verison}
    </div>);
}

この書き方に「おっ!」と感じた方が、今回この記事をお届けする対象の方です!
この記事では、 Django + Inertia.js + Vite + React を用いてモジュラモノリスなアーキテクチャを持つWebサービスを作る手法を紹介します。

なぜ?

近年、プロダクションとして採用される構成に、React + 任意のバックエンド環境をマイクロサービスとして立ち上げ、両者をRESTful APIやGraphQLなどで立ち上げる構成が一般的になってきました。これらの構成は結合度が低く、各部分におけるリプレイスがしやすい側面がありますが、欠点として挙げられるものに「開発工数の増大化」があると思います。

例として、React + Djangoの両者を独立した構成で立ち上げたとしましょう。フロントエンドは静的ホスティングでホストをし、両者間のやり取りをする構成が必要なので、RESTful APIをDjangoで構成します。この時、以下のことを懸念するでしょう。

  • APIをあけたけど、エンドユーザーにいじられたくはないな...けどCSRFトークンの発行は難しいな
  • ベースのHTMLを動的に変えたくなってきたな...Next.jsに切り替えるか?あれ?そうすると Node.jsも建てる必要があるよね
  • ...冷静に考えたら エンドユーザーに機能を提供するわけでもないのにAPIを開けるのはオーバーエンジニアリング なきがしてきたな

こんなことを考えているうちに 「俺は何をやっているんだ...ただシンプルなWebアプリを作りたいだけなのに」 なんて思いませんか? 近年、大規模なプロジェクトにおいて、結合度が低いマイクロサービスはかなり評価されてきましたが、 小規模のアプリケーションを作る個人にとっては、もしかしたら最適なソリューションではないのかも? いま手元で開発しているそのプロジェクトは、将来的にいずれかをリプレイスする可能性はあるでしょうか?

対して、今回紹介する モジュラーモノリス は、たったこれだけで済みます。

views.py
import django
import inertia

@intertia('ShowVersion')
def index(request):
  return {
    'version': django.get_version()
  }
ShowVersion.tsx
type Props = {
    version: string;
}

export default Page({ version }: Props){
    return (<div>
        Django: {verison}
    </div>);
}

どうですか?単純にフロントエンドとバックエンドが分かれているだけで、上記で気にしたような問題は気にしなくて良くなり、何よりも結合のためのAPIも開けずに済みます[1]

使えそうだなと思うケース

以下に示すもので、とくに今後リプレイスをする可能性が低いもの開発工数が物を言う(納期/締切がタイトな)ものに向いていると言えると思います

  • 個人開発
  • ハッカソン
  • 教育機関/授業などで構築する課題など

個人的には、特に教育機関でなにかシステムを構築する際に良いソリューションになるのではないかと感じています。今回自分がこのアーキテクチャを採用するのも、学校課題でDjangoが指定されたからなんですよね。

モジュラーモノリスに関しては、以下の記事に細かく書いてあったので、こちらもご参照ください!
https://r-kaga.com/blog/what-is-modular-monolith

結論

以下でやっていることはすべて下記リポジトリで行っています。
構築が面倒くさかったら以下をお使いください。

https://github.com/BonyChops/djangoReactApp?new

構築してみる[2][3]

現状、Inertia.jsは様々なバックエンド、フロントエンドに対応しているのですが、構築が少々大変です😅 ここらへんもViteなどが持つ対話形式のセットアップがあると良いんですけどね...

筆者の環境

参考にしてください。

Python 3.9.6
pip 23.2.1
Django 4.2.13

Django のセットアップ

適当なディレクトリを作ってcdします。

mkdir djangoReactApp
cd djangoReactApp
venvを使うなら
python3 -m venv myenv
source ./.venv/bin/activate

Djangoを入れ、セットアップします。

pip install Django
django-admin startproject djangoReactApp .

起動してみましょう。

python manage.py runserver 8080

上記のようにポート8080で開けている場合、リンクは http://localhost:8080 になります。

こんな感じに起動すればOK。ターミナルに戻って、 ^(Ctrl) + Cで止めておきます。

React + Vite のセットアップ

npm create vite@latest frontend
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC // SWCはVercel製の速いコンパイラ、なしでもOK

Scaffolding project in /Users/bonychops/PycharmProjects/djangoReactApp/frontend...

Done. Now run:

  cd frontend
  npm install
  npm run dev

やれと言われたので以下を実行。

cd frontend
npm install
npm run dev

デフォルトの場合、 http://localhost:5173/ で立ち上がると思います。

起動しました🎉
確認できたら ^ + C をして元のディレクトリに帰ります。

cd ..

アダプター、インテグレーションのセットアップ

Django側

続いて、DjangoとReactの架け橋になる部分を作ります。

pip install inertia-django django-vite

下記の通り djangoReactApp/djangoReactApp/settings.py を編集します。

djangoReactApp/djangoReactApp/settings.py
 # ...
 INSTALLED_APPS = [
     # ...
     'django.contrib.staticfiles', # DjangoのAPPの下に追記する
+    "django_vite",
+    "inertia",
     # ... その他自分で入れる/入れたAppはこの先に記述
 ]

 MIDDLEWARE = [
     # ...
     'django.middleware.clickjacking.XFrameOptionsMiddleware', # DjangoのMiddlewareの下に追記する
+    "inertia.middleware.InertiaMiddleware",
      # ... その他自分で入れる/入れたMiddlewareはこの先に記述
 ]

次に、 djangoReactApp/templates/base.htmlを作成します。

djangoReactApp/templates/base.html
{% load django_vite %}
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <!-- vite hmr -->
        {% vite_hmr_client %}
        {% vite_react_refresh %}
        {% vite_asset 'src/main.tsx' %}
        <title>djangoReactApp</title>
    </head>
    <body>      
        <!-- inertia -->
        {% block inertia %}{% endblock %}
    </body>
</html>

上記で作成したtemplates/base.htmlsettings.pyに記述します。

djangoReactApp/djangoReactApp/settings.py
 TEMPLATES = [
     {
         'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [],
+        'DIRS': [BASE_DIR / 'templates'],
         # ...
     },
 ]

次に、ファイルの末尾に以下を追加してください。

djangoReactApp/djangoReactApp/settings.py
 USE_I18N = True

 USE_TZ = True

+# 静的ファイル(CSS、JavaScript、画像)
+# https://docs.djangoproject.com/en/4.2/howto/static-files/
+
+STATIC_URL = 'static/'
+
+# デフォルトの主キーのフィールドタイプ
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+INERTIA_LAYOUT = 'base.html'
+
+# Djangoフォームの投稿に必要
+CSRF_HEADER_NAME = 'HTTP_X_XSRF_TOKEN'
+CSRF_COOKIE_NAME = 'XSRF-TOKEN'
+
+# ViteJSアセットがビルドされる場所
+DJANGO_VITE_ASSETS_PATH = BASE_DIR / 'frontend' / 'dist'
+
+# HMRを使用するかどうか
+DJANGO_VITE_DEV_MODE = DEBUG
+
+# Vite サーバーポート設定
+DJANGO_VITE_DEV_SERVER_PORT = 3000
+ 
+# 静的ファイルのフォルダの名前(python manage.py collectstaticを実行した後)
+STATIC_ROOT = BASE_DIR / 'static'
+
+# DJANGO_VITE_ASSETS_PATHをSTATICFILES_DIRSに含め、python manage.py collectstaticコマンドを実行した際に内部にコピーされるようにする
+STATICFILES_DIRS = [DJANGO_VITE_ASSETS_PATH]

views.pyを作成します。

djangoReactApp/djangoReactApp/views.py
from inertia import inertia


@inertia('Index/index')  # 描画Reactコンポーネントの選択
def index(request):
    return {}  # 空Props

最後に、作成したViewを urls.py に記載します。

djangoReactApp/djangoReactApp/urls.py
 from django.contrib import admin
 from django.urls import path
+from djangoReactApp import views
 
 urlpatterns = [
+    path('', views.index, name='home'),
     path('admin/', admin.site.urls),
 ]

React側

後にvite.config.tsで使用する都合で @types/node を入れます

cd frontend
npm install -D @types/node

vite.config.ts を編集します。

djangoReactApp/frontend/vite.config.ts
 import {defineConfig} from 'vite'
 import react from '@vitejs/plugin-react-swc'
+import { resolve }  from 'path'
 
 // https://vitejs.dev/config/
-export default defineConfig({
+export default defineConfig((env) => ({
     plugins: [react()],
+    base: 'static/',
+    server: {
+        host: '127.0.0.1',
+        port: 3000,
+        open: false,
+        watch: {
+            usePolling: true,
+            disableGlobbing: false,
+        },
+        origin: env.mode === "development" ?  'http://127.0.0.1:3000' : "",
+    },
+    resolve: {
+        alias: {
+            '@': resolve(__dirname, './src')
+        }
+    },
+    build: {
+        outDir: resolve('./dist'),
+        manifest: "manifest.json",
+        assetsDir: "assets",
+        target: 'es2015',
+        rollupOptions: {
+            input: {
+                main: resolve('./src/main.tsx'),
+            },
+            output: {
+                entryFileNames: `assets/[name]/bundle.js`,
+            },
+        },
+    },
-})
+}))

InertiaがReactのRootとして用意してくれるdivが <div id="app">なので微調整。

djangoReactApp/frontend/src/App.css
-#root {
+#app {
   max-width: 1280px;
   margin: 0 auto;
   padding: 2rem;
   text-align: center;
 }
npm i -D @inertiajs/react

main.tsxを以下にすべて書き換えてください。

djangoReactApp/frontend/src/main.tsx
import 'vite/modulepreload-polyfill';
import {createRoot} from 'react-dom/client';
import {createInertiaApp} from '@inertiajs/react';
import './index.css'
import {StrictMode} from "react";


document.addEventListener('DOMContentLoaded', () => {
    createInertiaApp({
        resolve: (name) => {
            const pages = import.meta.glob('./pages/**/*.tsx', {eager: true});
            return pages[`./pages/${name}.tsx`];
        },
        setup({el, App, props}) {
            createRoot(el).render(
                <StrictMode>
                    <App {...props} />
                </StrictMode>
            );
        }
    }).then(() => {
    });
});

mkdir -p src/pages/Index
mv src/App.tsx src/pages/Index/index.tsx
djangoReactApp/frontend/src/pages/index/Index.tsx
 import { useState } from 'react'
-import reactLogo from './assets/react.svg'
+import reactLogo from '@/assets/react.svg'
 import viteLogo from '/vite.svg'
-import './App.css'
+import '@/App.css'

起動

後は、実際に起動して確かめてみます。
以下のコマンド群を別々のターミナルで実行してみてください。

ターミナル1
python manage.py runserver 8080
ターミナル2
cd frontend
npm run dev

この状態で http://localhost:8080 にアクセスします。

問題なく表示されれば、最低限のセットアップが完了しました🎉 お疲れ様でした。

本番環境

本番環境で使うには、以下の手順に従ってください。

djangoReactApp/djangoReactApp/settings.py
-DEBUG = True
+DEBUG = False

-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = [
+    "localhost" # 本番環境で使うには公開するホストの設定が必要、ここではlocalhost
+]

以下はFrontend側に変更があった際に毎回やらなければいけないことに留意してください。

cd frontend
npm run build
cd ../
python manage.py collectstatic

起動します。

python manage.py runserver 8080 # デフォルトではstaticを配信しないため、必要の場合には --insecure を加える

おまけ

以下はコラム的な要素です。興味があればどうぞ

Djangoロゴを増やしてみる

djangoReactApp/djangoReactApp/views.py
+import django
 from inertia import inertia


 @inertia('Index/index')  # 描画Reactコンポーネントの選択
 def index(request):
-    return {}
+    return {
+        "services": [
+            {
+                "version": django.get_version(),
+                "name": "Django",
+                "url": "https://www.djangoproject.com/",
+                "iconUrl": "https://github.com/django.png"
+            }
+        ]
+    }
djangoReactApp/frontend/src/pages/Index/index.tsx
 import {useState} from 'react'
 import reactLogo from '@/assets/react.svg'
 import viteLogo from '/vite.svg'
 import '@/App.css'
 import {Link} from "@inertiajs/react";
 
+type Props = {
+    services: {
+        version: string;
+        name: string;
+        url: string;
+        iconUrl: string;
+    }[]
+}
 
-function App() {
+function App(props: Props) {
+    const {services} = props;
     const [count, setCount] = useState(0)
 
     return (
         <>
             <div>
                 <a href="https://vitejs.dev" target="_blank">
                     <img src={viteLogo} className="logo" alt="Vite logo"/>
                 </a>
                 <a href="https://react.dev" target="_blank">
                     <img src={reactLogo} className="logo react" alt="React logo"/>
                 </a>
+                {services.map(v => (
+                    <a href={v.url} target="_blank">
+                        <img src={v.iconUrl} className="logo" alt={`${v.name} logo`}/>
+                    </a>
+                ))}
             </div>
 
-            <h1>Vite + React</h1>
+            <h1>{["Vite", "React", ...services.map(v => v.name)].join(" + ")}</h1>

ちゃんとバージョンまで出ているのが確認できると思います。

Code splitting

Intertiaは当然Code splittingにも対応しています。現状の設定では、最初のリクエスト時にすべてのコンポーネントをfetchしてから描画をするようになっているため、これを現在見ているページのみfetchさせるよう変更するには、以下を変えるだけです。

djangoReactApp/frontend/src/main.tsx
 document.addEventListener('DOMContentLoaded', () => {
     createInertiaApp({
         resolve: (name) => {
-            const pages = import.meta.glob('./pages/**/*.tsx', {eager: true});
+            const pages = import.meta.glob('./pages/**/*.tsx', {eager: false});
-            return pages[`./pages/${name}.tsx`];
+            return pages[`./pages/${name}.tsx`]();
         },

ただ、IntertiaはCode splittingをすることをあまり推奨していません。というのも、「お前が作るWebアプリは1コンポーネントがそんなに重いのか? 最初に全部fetchさせたほうが毎リクエスト飛ばないからそっちのほうが効率的かもよ?」 という考えのようです。

参考に、以下はCode splittingを有効にしたときと無効にしたときのバンドルサイズです。

Code splitting なし Code splitting あり

上記は2ページ(コンポーネント)を作ったときの例ですが、どうでしょうか? main/bundle.jsの差が200Bしかないにも関わらず、Code splitting 有効のほうが分割されたファイルを鑑みると若干増えていることがわかりますね。
そもそもCode splitting 有効でも元のbundle.jsが200kBほどあるということを念頭に置いて検討したほうが良さそうですね。

まあ自分はそれでも Code splitting 有効にするんですけど。[4]

脚注
  1. 厳密に言うと、この場合はInertia.jsがよしなに開けてくれています。ただ、その場合でも初回fetch時にはHTMLへの埋め込み、ページ遷移時にはAPIによるJSONレスポンスによって最小限のリクエスト...などの工夫が、ユーザーの工夫を必要とせずに行ってくれています。詳しくはThe protocol - Inertia.jsをご覧ください。 ↩︎

  2. Build Web Fullstack Apps with DIRT: Django, Inertia, React & Tailwind CSS aka D.I.R.T Stack - DEV Community ↩︎ ↩︎

  3. Django ドキュメント | Django ドキュメント | Django ↩︎

  4. 見ていないページの分までfetchされるというのがなんとなく腑に落ちない... ↩︎

Discussion