🐍

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

2024/07/21に公開
タイトルに見覚えがありますか?

実は 学校課題の要件を見間違えており、使用するバックエンドが Django ではなく Flask だったため書き直しました😇😇😇😇😇
Django版はこちら

https://zenn.dev/bony_chops/articles/5c10ffabf8af7c

はじめに

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

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

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

app.py
from flask import Flask
from flask_vite import Vite
from flask_inertia import Inertia
from flask_inertia import render_inertia
import inertia
app = Flask(__name__)
app.config.from_object(__name__)
vite = Vite(app)
inertia = Inertia()
inertia.init_app(app)
@app.route("/")
def show_version()
    data = {
        "version": flask.__version__,
    }
    return render_inertia(
        component_name="Index",
        props=data,
        view_data={},
    )
ShowVersion.tsx
type Props = {
    version: string;
}
export default Page({ version }: Props){
    return (<div>
        Flask: {verison}
    </div>);
}

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

なぜ?

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

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

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

app.py
import flask
from flask import render_inertia
import inertia
@app.route("/")
def show_version()
    data = {
        "version": flask.__version__,
    }
    return render_inertia(
        component_name="Index",
        props=data,
        view_data={},
    )
ShowVersion.tsx
type Props = {
    version: string;
}
export default Page({ version }: Props){
    return (<div>
        Flask: {verison}
    </div>);
}

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

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

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

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

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

結論

以下でやっていることはすべて下記リポジトリで行っています。
構築が面倒くさかったら以下をお使いください。
https://github.com/BonyChops/flaskReactApp?1

構築してみる[2]

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

筆者の環境

参考にしてください。

Python 3.9.6
pip 23.2.1
Flask 3.0.3

Flask のセットアップ

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

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

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

pip install Flask

以下のようにapp.pyを作成します。

app.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
    return 'Hello World!'
if __name__ == '__main__':
    app.run()

起動してみましょう。

flask --debug run

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

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

React + Vite のセットアップ

npm create vite@latest vite
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC // SWCはVercel製の速いコンパイラ、なしでもOK
Scaffolding project in /Users/bonychops/PycharmProjects/flaskReactApp/vite...
Done. Now run:
  cd vite
  npm install
  npm run dev

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

cd vite
npm install
npm run dev

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

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

cd ..

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

Flask側

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

pip install flask-inertia flask-vite

下記の通り app.py を編集します。

app.py
-from flask import Flask
+from flask import Flask, send_from_directory
+from flask_vite import Vite
+from flask_inertia import Inertia
+from flask_inertia import render_inertia
+
+SECRET_KEY = "secret!" # 適宜変えてください
+INERTIA_TEMPLATE = "base.html"
 
 app = Flask(__name__)
+app.config.from_object(__name__)
+vite = Vite(app)
 
+inertia = Inertia()
+inertia.init_app(app)
 
 @app.route('/')
 def hello_world():
-    return 'Hello World!'
-
+    data = {}
+    return render_inertia(
+        component_name="Index",
+        props=data,
+        view_data={},
+    )

+@app.route('/_vite-static/<path:path>') # Vite側のアセットを表示するのに必要
+def send_public(path):
+    return send_from_directory('vite/dist', path)

 if __name__ == '__main__':
     app.run()

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

templates/base.html
<!doctype html>
<html lang="ja">
<head>
  <title>flaskReactApp</title>
  {% if config['DEBUG'] %}
  <script type="module">
    import RefreshRuntime from 'http://localhost:3000/@react-refresh'
    RefreshRuntime.injectIntoGlobalHook(window)
    window.$RefreshReg$ = () => {}
    window.$RefreshSig$ = () => (type) => type
    window.__vite_plugin_react_preamble_installed__ = true
  </script>
  {% endif %}
  {{ vite_tags() }}
  <script lang="javascript">
    {{ inertia.include_router() }}
  </script>
</head>
<body>
    <div id="app" data-page='{{ page | tojson }}'></div>
</body>
</html>

React側

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

cd vite
npm install -D @types/node

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

vite/vite.config.ts
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react-swc'
+import path, { resolve } from 'path'
 
 // https://vitejs.dev/config/
-export default defineConfig({
+export default defineConfig((env) => ({
   plugins: [react()],
-})
+  base: env.mode === "development" ? "" : "/_vite-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'),
+      '/main.js': path.resolve(__dirname, 'src/main.tsx')
+    }
+  },
+  build: {
+    outDir: resolve('./dist'),
+    assetsDir: "assets",
+    target: 'es2015',
+    rollupOptions: {
+      input: {
+        main: resolve('./src/main.tsx'),
+      },
+      output: {
+        entryFileNames: `assets/bundle.js`,
+      },
+    },
+  },
+}))

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

vite/src/App.css
-#root {
+#app {
   max-width: 1280px;
   margin: 0 auto;
   padding: 2rem;
   text-align: center;
 }

React側にInertiaを導入。

npm i -D @inertiajs/react

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

vite/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}/index.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
vite/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'

元のディレクトリに戻します。

cd ..

起動

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

ターミナル1
flask --debug run
ターミナル2
flask vite start

この状態で http://127.0.0.1:5000 (または http://127.0.0.1:5001 ) にアクセスします。

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

本番環境

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

まず、フロントエンドをビルドします。

flask vite build

起動します。

flask --app app run

おまけ

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

Flaskロゴを増やしてみる

app.py
 from flask_vite import Vite
 from flask_inertia import Inertia
 from flask_inertia import render_inertia
+import flask
 
 SECRET_KEY = "secret!" # 適宜変えてください
 INERTIA_TEMPLATE = "base.html"
@@ -15,7 +16,16 @@ inertia.init_app(app)
 
 @app.route('/')
 def hello_world():  # put application's code here
-    data = {}
+    data = {
+        "services": [
+            {
+                "version": flask.__version__,
+                "name": "Flask",
+                "url": "https://flask.palletsprojects.com/",
+                "iconUrl": "https://flask.palletsprojects.com/en/3.0.x/_images/flask-horizontal.png"
+            }
+        ]
+    }
     return render_inertia(
         component_name="Index",
         props=data,
vite/src/App.css
 }
 
 .logo {
+  width: 6em;
   height: 6em;
+  object-fit: cover;
+  object-position: left;
   padding: 1.5em;
   will-change: filter;
   transition: filter 300ms;
vite/src/pages/Index/index.tsx
 import viteLogo from '/vite.svg'
 import '@/App.css'
 
-function App() {
+type Props = {
+    services: {
+        version: string;
+        name: string;
+        url: string;
+        iconUrl: string;
+    }[]
+}
+
+function App(props: Props) {
+  const {services} = props
   const [count, setCount] = useState(0)
 
   return (
@@ -15,8 +25,14 @@ function App() {
         <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>
+      {services.map(v => <p>{v.name}: ${v.version}</p>)}
       <div className="card">
         <button onClick={() => setCount((count) => count + 1)}>
           count is {count}

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

Code splitting

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

vite/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}/index.tsx`];
+            return pages[`./pages/${name}/index.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 有効にするんですけど。[5]

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

  2. Welcome to Flask — Flask Documentation (3.0.x) ↩︎

  3. ruby on rails - Why always something is running at port 5000 on my mac - Stack Overflow ↩︎

  4. Flask-Vite側で読み込まれるJSファイルは、vite/dist/assetsの中に存在するJSの中から最初に見つかったものを採用する仕様になっているため、vite_tags()を使用せず手動で bundle.js を読み込むようにする必要があります。ただこれをやってもうまくいかないので調査中... ↩︎

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

Discussion