iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

FARM スタックを使ったフルスタックアプリ開発

FARM スタックとは?

こんにちは。クリスと申します。

私はジュニアエンジニアとして、さまざまな技術にいつも興味があり、その概念をできるだけ理解しようとしています。最近はフルスタックエンジニアを目指して勉強しているのですが、その中で、複数の技術やツールを組み合わせてフルスタックアプリケーションを構築しながら、開発の一連の流れを理解するのに役立つ技術に出会いました

FARM スタックとは、以下の3つの強力な技術を組み合わせた、現代的な Web アプリケーション開発スタックです。

  • F: FastAPI(バックエンド)
  • R: React(フロントエンド)
  • M: MongoDB(データベース)

これらの技術を組み合わせることで、高速・スケーラブル・柔軟性のあるフルスタックアプリケーションを効率よく構築できます。


FARM スタックの構成要素

1. FastAPI — バックエンド

FastAPI は Python 製の最新 Web フレームワークで、以下の特徴があります。

  • 非常に高速(Python フレームワークの中でもトップクラス)
  • 型ヒントに基づく自動検証が可能
  • Swagger UI による API ドキュメント自動生成
  • 非同期処理に強い

信頼性の高い API を素早く構築できる点が魅力です。


2. React — フロントエンド

React は UI を構築するための JavaScript ライブラリで、以下が強みです:

  • コンポーネントベースの設計
  • 仮想DOMによる効率的な描画
  • 大規模アプリでも扱いやすい
  • 豊富なエコシステム

3. MongoDB — データベース

MongoDB はドキュメント指向の NoSQL データベースです。

  • JSON ライクな柔軟なデータ構造
  • スキーマの変更に強い
  • スケールアウトが容易

Todo アプリのような頻繁に構造が変わるデータには特に相性が良いです。

FARM スタックを使うメリット

  1. 高いパフォーマンス

    FastAPI + React + MongoDB の組み合わせは、API 応答と UI 更新が非常に高速。

  2. スケーラビリティが高い

    コンポーネントが分離されているため、個別にスケール可能。

  3. 大きなコミュニティと豊富なライブラリ

    問題解決や機能拡張に困らない。

  4. 柔軟性

    小規模なCRUDアプリから大規模システムまで対応可能。

これらの技術を組み合わせることで、FARMスタックは最新なWebアプリケーションを構築するための包括的なソリューションとなります。FastAPI を使って高速でスケーラブルなバックエンドを構築し、React で直感的かつレスポンシブなフロントエンドを実現し、MongoDB で柔軟かつ効率的なデータ保存を行うことができます。 このスタックは、リアルタイムな更新が必要なアプリケーションや、複雑なデータモデル、高いパフォーマンスが求められるアプリケーションに特に適しています。

今回のプロジェクト:Todo アプリ構築

この FARM スタックを利用し、オンラインの Todo アプリを参考に、機能を再現した フルスタック Todo アプリを構築しました。

Todo アプリの機能

  1. 複数の Todo リスト
    • 作成・表示・更新・削除が可能
    • 各リストには複数の Todo アイテムを保持
  2. Todo アイテム
    • 追加・更新・削除
    • "チェック済み / 未チェック" 状態の切り替え
  3. リアルタイムでUI更新
  4. レスポンシブデザイン対応

システムアーキテクチャ

アプリは典型的な FARM 構成を採用:

  1. フロントエンド(React)
  2. バックエンド(FastAPI)
  3. データベース(MongoDB)
  4. Docker によるコンテナ化
    • 環境差異を吸収し、デプロイも容易に

ここでは、プロジェクト構造、バックエンド、フロントエンド、Docker による運用までを段階的にセットアップしていきました。

プロジェクト用の新しいディレクトリを作成します:

   mkdir farm-stack-todo
   cd farm-stack-todo

バックエンドとフロントエンド用のサブディレクトリを作成します:

 mkdir backend frontend

ステップ 2:バックエンド環境をセットアップする

バックエンドディレクトリへ移動します:

cd backend

仮想環境を作成し、有効化します:

   python -m venv venv
   source venv/bin/activate  # On Windows, use: venv\Scripts\activate

バックエンドディレクトリに次のファイルを作成します:

    • Dockerfile
    • pyproject.toml

その後、ターミナルで必要なパッケージをインストールします:

pip install "fastapi[all]" "motor[srv]" beanie aiostream

requirements.txt ファイルを生成します:

pip freeze > requirements.txt

requirements.txt ファイルを作成した後(pip-compile で生成しても手動で作成しても)、次のコマンドで依存関係をインストールできます:

pip install -r requirements.txt

Dockerfile に次の内容を追加します:

 FROM python:3

   WORKDIR /usr/src/app
   COPY requirements.txt ./

   RUN pip install --no-cache-dir --upgrade -r ./requirements.txt

   EXPOSE 3001

   CMD [ "python", "./src/server.py" ]

pyproject.toml に次の内容を追加します:

  [tool.pytest.ini_options]
   pythonpath = "src"

バックエンド構造を設定するステップ 4:

バックエンドディレクトリの中に src ディレクトリを作成します:

 mkdir src

src ディレクトリ内に以下のファイルを作成します:

ステップ 5: データアクセス層 (DAL) を実装する

src/dal.py を開き、以下の内容を追加します:

from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument

from pydantic import BaseModel

from uuid import uuid4

class ListSummary(BaseModel):
  id: str
  name: str
  item_count: int

  @staticmethod
  def from_doc(doc) -> "ListSummary":
      return ListSummary(
          id=str(doc["_id"]),
          name=doc["name"],
          item_count=doc["item_count"],
      )

class ToDoListItem(BaseModel):
  id: str
  label: str
  checked: bool

  @staticmethod
  def from_doc(item) -> "ToDoListItem":
      return ToDoListItem(
          id=item["id"],
          label=item["label"],
          checked=item["checked"],
      )

class ToDoList(BaseModel):
  id: str
  name: str
  items: list[ToDoListItem]

  @staticmethod
  def from_doc(doc) -> "ToDoList":
      return ToDoList(
          id=str(doc["_id"]),
          name=doc["name"],
          items=[ToDoListItem.from_doc(item) for item in doc["items"]],
      )

class ToDoDAL:
  def __init__(self, todo_collection: AsyncIOMotorCollection):
      self._todo_collection = todo_collection

  async def list_todo_lists(self, session=None):
      async for doc in self._todo_collection.find(
          {},
          projection={
              "name": 1,
              "item_count": {"$size": "$items"},
          },
          sort={"name": 1},
          session=session,
      ):
          yield ListSummary.from_doc(doc)

  async def create_todo_list(self, name: str, session=None) -> str:
      response = await self._todo_collection.insert_one(
          {"name": name, "items": []},
          session=session,
      )
      return str(response.inserted_id)

  async def get_todo_list(self, id: str | ObjectId, session=None) -> ToDoList:
      doc = await self._todo_collection.find_one(
          {"_id": ObjectId(id)},
          session=session,
      )
      return ToDoList.from_doc(doc)

  async def delete_todo_list(self, id: str | ObjectId, session=None) -> bool:
      response = await self._todo_collection.delete_one(
          {"_id": ObjectId(id)},
          session=session,
      )
      return response.deleted_count == 1

  async def create_item(
      self,
      id: str | ObjectId,
      label: str,
      session=None,
  ) -> ToDoList | None:
      result = await self._todo_collection.find_one_and_update(
          {"_id": ObjectId(id)},
          {
              "$push": {
                  "items": {
                      "id": uuid4().hex,
                      "label": label,
                      "checked": False,
                  }
              }
          },
          session=session,
          return_document=ReturnDocument.AFTER,
      )
      if result:
          return ToDoList.from_doc(result)

  async def set_checked_state(
      self,
      doc_id: str | ObjectId,
      item_id: str,
      checked_state: bool,
      session=None,
  ) -> ToDoList | None:
      result = await self._todo_collection.find_one_and_update(
          {"_id": ObjectId(doc_id), "items.id": item_id},
          {"$set": {"items.$.checked": checked_state}},
          session=session,
          return_document=ReturnDocument.AFTER,
      )
      if result:
          return ToDoList.from_doc(result)

  async def delete_item(
      self,
      doc_id: str | ObjectId,
      item_id: str,
      session=None,
  ) -> ToDoList | None:
      result = await self._todo_collection.find_one_and_update(
          {"_id": ObjectId(doc_id)},
          {"$pull": {"items": {"id": item_id}}},
          session=session,
          return_document=ReturnDocument.AFTER,
      )
      if result:
          return ToDoList.from_doc(result)

これで、プロジェクト構造を設定し、FARMスタックのTodoアプリケーションのデータアクセス層 (DAL) を実装しました。次は、FastAPI サーバーを実装し、API エンドポイントを作成します。

FastAPI サーバーの実装

ステップ6: FastAPI サーバーを実装する

src/server.py を開き、次の内容を追加します:

from contextlib import asynccontextmanager
from datetime import datetime
import os
import sys

from bson import ObjectId
from fastapi import FastAPI, status
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import BaseModel
import uvicorn

from dal import ToDoDAL, ListSummary, ToDoList

COLLECTION_NAME = "todo_lists"
MONGODB_URI = os.environ["MONGODB_URI"]
DEBUG = os.environ.get("DEBUG", "").strip().lower() in {"1", "true", "on", "yes"}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup:
    client = AsyncIOMotorClient(MONGODB_URI)
    database = client.get_default_database()

    # Ensure the database is available:
    pong = await database.command("ping")
    if int(pong["ok"]) != 1:
        raise Exception("Cluster connection is not okay!")

    todo_lists = database.get_collection(COLLECTION_NAME)
    app.todo_dal = ToDoDAL(todo_lists)

    # Yield back to FastAPI Application:
    yield

    # Shutdown:
    client.close()

app = FastAPI(lifespan=lifespan, debug=DEBUG)

@app.get("/api/lists")
async def get_all_lists() -> list[ListSummary]:
    return [i async for i in app.todo_dal.list_todo_lists()]

class NewList(BaseModel):
    name: str

class NewListResponse(BaseModel):
    id: str
    name: str

@app.post("/api/lists", status_code=status.HTTP_201_CREATED)
async def create_todo_list(new_list: NewList) -> NewListResponse:
    return NewListResponse(
        id=await app.todo_dal.create_todo_list(new_list.name),
        name=new_list.name,
    )

@app.get("/api/lists/{list_id}")
async def get_list(list_id: str) -> ToDoList:
    """Get a single to-do list"""
    return await app.todo_dal.get_todo_list(list_id)

@app.delete("/api/lists/{list_id}")
async def delete_list(list_id: str) -> bool:
    return await app.todo_dal.delete_todo_list(list_id)

class NewItem(BaseModel):
    label: str

class NewItemResponse(BaseModel):
    id: str
    label: str

@app.post(
    "/api/lists/{list_id}/items/",
    status_code=status.HTTP_201_CREATED,
)
async def create_item(list_id: str, new_item: NewItem) -> ToDoList:
    return await app.todo_dal.create_item(list_id, new_item.label)

@app.delete("/api/lists/{list_id}/items/{item_id}")
async def delete_item(list_id: str, item_id: str) -> ToDoList:
    return await app.todo_dal.delete_item(list_id, item_id)

class ToDoItemUpdate(BaseModel):
    item_id: str
    checked_state: bool

@app.patch("/api/lists/{list_id}/checked_state")
async def set_checked_state(list_id: str, update: ToDoItemUpdate) -> ToDoList:
    return await app.todo_dal.set_checked_state(
        list_id, update.item_id, update.checked_state
    )

class DummyResponse(BaseModel):
    id: str
    when: datetime

@app.get("/api/dummy")
async def get_dummy() -> DummyResponse:
    return DummyResponse(
        id=str(ObjectId()),
        when=datetime.now(),
    )

def main(argv=sys.argv[1:]):
    try:
        uvicorn.run("server:app", host="0.0.0.0", port=3001, reload=DEBUG)
    except KeyboardInterrupt:
        pass

if __name__ == "__main__":
    main()

この実装では、FastAPI サーバーを CORS ミドルウェア付きで設定し、MongoDB に接続し、Todo アプリケーション用の API エンドポイントを定義します。

ステップ7: 環境変数を設定する

ルートディレクトリに .env ファイルを作成し、以下の内容を追加します。.mongodb.net/ の末尾にデータベース名("todo")を追加することを忘れないでください。

MONGODB_URI='mongodb+srv://fon2christian:<db_password>@cluster0.4fz7ql7.mongodb.net/?appName=Cluster0'

ステップ8: docker-compose ファイルを作成する

プロジェクトのルートディレクトリ(farm-stack-todo)に、compose.yml という名前のファイルを作成し、以下の内容を追加します。

name: todo-app
services:
  nginx:
    image: nginx:1.17
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
    ports:
      - 8000:80
    depends_on:
      - backend
      - frontend
  frontend:
    image: "node:22"
    user: "node"
    working_dir: /home/node/app
    environment:
      - NODE_ENV=development
      - WDS_SOCKET_PORT=0
    volumes:
      - ./frontend/:/home/node/app
    expose:
      - "3000"
    ports:
      - "3000:3000"
    command: "npm start"
  backend:
    image: todo-app/backend
    build: ./backend
    volumes:
      - ./backend/:/usr/src/app
    expose:
      - "3001"
    ports:
      - "8001:3001"
    command: "python src/server.py"
    environment:
      - DEBUG=true
    env_file:
      - path: ./.env
        required: true

ステップ9: Nginx の設定を行う

プロジェクトのルートに nginx という名前のディレクトリを作成します。

mkdir nginx

nginx ディレクトリ内に nginx.conf という名前のファイルを作成し、以下の内容を追加します。

server {
    listen 80;
    server_name farm_intro;

    location / {
        proxy_pass http://frontend:3000;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /api {
        proxy_pass http://backend:3001/api;
    }
}

この時点で、FastAPIサーバーの実装、環境変数の設定、docker-composeファイルの作成、Nginxの設定を完了しました。次は、FARMスタックのTodoアプリケーション用にReactフロントエンドのセットアップに取り組みます。

Reactフロントエンドのセットアップ

ステップ10: Reactアプリケーションを作成する

frontendディレクトリに移動します:

cd ../frontend

reate React Appを使って新しいReactアプリケーションを作成します:

npx create-react-app .

追加dependenciesをインストールします:

npm install axios react-icons

ステップ11: メインの App コンポーネントを設定する

src/App.js の内容を以下のものに置き換えます:

import { useEffect, useState } from "react";
import axios from "axios";
import "./App.css";
import ListToDoLists from "./ListTodoLists";
import ToDoList from "./ToDoList";

function App() {
  const [listSummaries, setListSummaries] = useState(null);
  const [selectedItem, setSelectedItem] = useState(null);

  useEffect(() => {
    reloadData().catch(console.error);
  }, []);

  async function reloadData() {
    const response = await axios.get("/api/lists");
    const data = await response.data;
    setListSummaries(data);
  }

  function handleNewToDoList(newName) {
    const updateData = async () => {
      const newListData = {
        name: newName,
      };

      await axios.post(`/api/lists`, newListData);
      reloadData().catch(console.error);
    };
    updateData();
  }

  function handleDeleteToDoList(id) {
    const updateData = async () => {
      await axios.delete(`/api/lists/${id}`);
      reloadData().catch(console.error);
    };
    updateData();
  }

  function handleSelectList(id) {
    console.log("Selecting item", id);
    setSelectedItem(id);
  }

  function backToList() {
    setSelectedItem(null);
    reloadData().catch(console.error);
  }

  if (selectedItem === null) {
    return (
      <div className="App">
        <ListToDoLists
          listSummaries={listSummaries}
          handleSelectList={handleSelectList}
          handleNewToDoList={handleNewToDoList}
          handleDeleteToDoList={handleDeleteToDoList}
        />
      </div>
    );
  } else {
    return (
      <div className="App">
        <ToDoList listId={selectedItem} handleBackButton={backToList} />
      </div>
    );
  }
}

export default App;

ステップ12: ListTodoLists コンポーネントを作成する

src/ListTodoLists.js という新しいファイルを作成し、以下の内容を追加します:

import "./ListTodoLists.css";
import { useRef } from "react";
import { BiSolidTrash } from "react-icons/bi";

function ListToDoLists({
  listSummaries,
  handleSelectList,
  handleNewToDoList,
  handleDeleteToDoList,
}) {
  const labelRef = useRef();

  if (listSummaries === null) {
    return <div className="ListToDoLists loading">Loading to-do lists ...</div>;
  } else if (listSummaries.length === 0) {
    return (
      <div className="ListToDoLists">
        <div className="box">
        <label>
          New To-Do List:&nbsp;
          <input id={labelRef} type="text" />
        </label>
        <button
          onClick={() =>
            handleNewToDoList(document.getElementById(labelRef).value)
          }
        >
          New
        </button>
        </div>
        <p>There are no to-do lists!</p>
      </div>
    );
  }
  return (
    <div className="ListToDoLists">
      <h1>All To-Do Lists</h1>
      <div className="box">
        <label>
          New To-Do List:&nbsp;
          <input id={labelRef} type="text" />
        </label>
        <button
          onClick={() =>
            handleNewToDoList(document.getElementById(labelRef).value)
          }
        >
          New
        </button>
      </div>
      {listSummaries.map((summary) => {
        return (
          <div
            key={summary.id}
            className="summary"
            onClick={() => handleSelectList(summary.id)}
          >
            <span className="name">{summary.name} </span>
            <span className="count">({summary.item_count} items)</span>
            <span className="flex"></span>
            <span
              className="trash"
              onClick={(evt) => {
                evt.stopPropagation();
                handleDeleteToDoList(summary.id);
              }}
            >
              <BiSolidTrash />
            </span>
          </div>
        );
      })}
    </div>
  );
}

export default ListToDoLists;

src/ListTodoLists.css という新しいファイルを作成し、以下の内容を追加します:

.ListToDoLists .summary {
    border: 1px solid lightgray;
    padding: 1em;
    margin: 1em;
    cursor: pointer;
    display: flex;
}

.ListToDoLists .count {
    padding-left: 1ex;
    color: blueviolet;
    font-size: 92%;
}

src/ToDoList.js という新しいファイルを作成し、以下の内容を追加します:

import "./ToDoList.css";
import { useEffect, useState, useRef } from "react";
import axios from "axios";
import { BiSolidTrash } from "react-icons/bi";

function ToDoList({ listId, handleBackButton }) {
  let labelRef = useRef();
  const [listData, setListData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await axios.get(`/api/lists/${listId}`);
      const newData = await response.data;
      setListData(newData);
    };
    fetchData();
  }, [listId]);

  function handleCreateItem(label) {
    const updateData = async () => {
      const response = await axios.post(`/api/lists/${listData.id}/items/`, {
        label: label,
      });
      setListData(await response.data);
    };
    updateData();
  }

  function handleDeleteItem(id) {
    const updateData = async () => {
      const response = await axios.delete(
        `/api/lists/${listData.id}/items/${id}`);
      setListData(await response.data);
    };
    updateData();
  }

  function handleCheckToggle(itemId, newState) {
    const updateData = async () => {
      const response = await axios.patch(
        `/api/lists/${listData.id}/checked_state`,
        {
          item_id: itemId,
          checked_state: newState,
        }
      );
      setListData(await response.data);
    };
    updateData();
  }

  if (listData === null) {
    return (
      <div className="ToDoList loading">
        <button className="back" onClick={handleBackButton}>
          Back
        </button>
        Loading to-do list ...
      </div>
    );
  }
  return (
    <div className="ToDoList">
      <button className="back" onClick={handleBackButton}>
        Back
      </button>
      <h1>List: {listData.name}</h1>
      <div className="box">
        <label>
          New Item:&nbsp;
          <input id={labelRef} type="text" />
        </label>
        <button
          onClick={() =>
            handleCreateItem(document.getElementById(labelRef).value)
          }
        >
          New
        </button>
      </div>
      {listData.items.length > 0 ? (
        listData.items.map((item) => {
          return (
            <div
              key={item.id}
              className={item.checked ? "item checked" : "item"}
              onClick={() => handleCheckToggle(item.id, !item.checked)}
            >
              <span>{item.checked ? "✅" : "⬜️"} </span>
              <span className="label">{item.label} </span>
              <span className="flex"></span>
              <span
                className="trash"
                onClick={(evt) => {
                  evt.stopPropagation();
                  handleDeleteItem(item.id);
                }}
              >
                <BiSolidTrash />
              </span>
            </div>
          );
        })
      ) : (
        <div className="box">There are currently no items.</div>
      )}
    </div>
  );
}

export default ToDoList;

src/ToDoList.css という新しいファイルを作成し、以下の内容を追加します:

.ToDoList .back {
    margin: 0 1em;
    padding: 1em;
    float: left;
}

.ToDoList .item {
    border: 1px solid lightgray;
    padding: 1em;
    margin: 1em;
    cursor: pointer;
    display: flex;
}

.ToDoList .label {
    margin-left: 1ex;
}

.ToDoList .checked .label {
    text-decoration: line-through;
    color: lightgray;
}

src/index.css の内容を以下のように置き換えます:

html, body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-size: 12pt;
}

input, button {
  font-size: 1em;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

.box {
    border: 1px solid lightgray;
    padding: 1em;
    margin: 1em;
}

.flex {
  flex: 1;
}

これで、FARMスタックのTodoアプリケーション用のReactフロントエンドのセットアップは完了です。メインの App コンポーネント、全てのTodoリストを表示する ListTodoLists コンポーネント、個別のTodoリスト用の ToDoList コンポーネントを作成しました。次に、アプリケーションを実行してみましょう。

アプリケーションの実行

ステップ18:Docker Composeを使ってアプリケーションを実行する

  1. システムにDockerとDocker Composeがインストールされていることを確認します
  2. プロジェクトのルートディレクトリ(farm-stack-todo)でターミナルを開きます
  3. コンテナをビルドして起動します:
docker-compose up --build
  1. コンテナが起動したら、ウェブブラウザを開き、http://localhost:8000 にアクセスします
  2. コンテナを停止して削除するには、以下のコマンドを実行します:
docker-compose down

終わり

このTodoアプリケーションを構築することで、現代のウェブ開発で最も強力かつ人気のある技術を実践的に学ぶことができました。FastAPIを使って堅牢なバックエンドAPIを作成し、Reactで動的でレスポンシブなフロントエンドを構築し、MongoDBでデータを永続化し、Dockerでアプリケーション全体をコンテナ化する方法を学びました。このプロジェクトを通じて、これらの技術がシームレスに連携し、フル機能でスケーラブルなウェブアプリケーションを作成できることを実感できました。