
FARM スタックとは?
こんにちは。クリスと申します。
私はジュニアエンジニアとして、さまざまな技術にいつも興味があり、その概念をできるだけ理解しようとしています。最近はフルスタックエンジニアを目指して勉強しているのですが、その中で、複数の技術やツールを組み合わせてフルスタックアプリケーションを構築しながら、開発の一連の流れを理解するのに役立つ技術に出会いました
FARM スタックとは、以下の3つの強力な技術を組み合わせた、現代的な Web アプリケーション開発スタックです。
- F: FastAPI(バックエンド)
- R: React(フロントエンド)
- M: MongoDB(データベース)
これらの技術を組み合わせることで、高速・スケーラブル・柔軟性のあるフルスタックアプリケーションを効率よく構築できます。
FARM スタックの構成要素
1. FastAPI — バックエンド
FastAPI は Python 製の最新 Web フレームワークで、以下の特徴があります。
信頼性の高い API を素早く構築できる点が魅力です。
2. React — フロントエンド
React は UI を構築するための JavaScript ライブラリで、以下が強みです:
- コンポーネントベースの設計
- 仮想DOMによる効率的な描画
- 大規模アプリでも扱いやすい
- 豊富なエコシステム
3. MongoDB — データベース
MongoDB はドキュメント指向の NoSQL データベースです。
Todo アプリのような頻繁に構造が変わるデータには特に相性が良いです。
FARM スタックを使うメリット
高いパフォーマンス
FastAPI + React + MongoDB の組み合わせは、API 応答と UI 更新が非常に高速。
スケーラビリティが高い
各コンポーネントが分離されているため、個別にスケール可能。
大きなコミュニティと豊富なライブラリ
問題解決や機能拡張に困らない。
柔軟性
小規模なCRUDアプリから大規模システムまで対応可能。
これらの技術を組み合わせることで、FARMスタックは最新なWebアプリケーションを構築するための包括的なソリューションとなります。FastAPI を使って高速でスケーラブルなバックエンドを構築し、React で直感的かつレスポンシブなフロントエンドを実現し、MongoDB で柔軟かつ効率的なデータ保存を行うことができます。 このスタックは、リアルタイムな更新が必要なアプリケーションや、複雑なデータモデル、高いパフォーマンスが求められるアプリケーションに特に適しています。
今回のプロジェクト:Todo アプリ構築
この FARM スタックを利用し、オンラインの Todo アプリを参考に、機能を再現した フルスタック Todo アプリを構築しました。
Todo アプリの機能
- 複数の Todo リスト
- 作成・表示・更新・削除が可能
- 各リストには複数の Todo アイテムを保持
- Todo アイテム
- 追加・更新・削除
- "チェック済み / 未チェック" 状態の切り替え
- リアルタイムでUI更新
- レスポンシブデザイン対応
システムアーキテクチャ
アプリは典型的な FARM 構成を採用:
- フロントエンド(React)
- ユーザーインターフェース
- FastAPI の REST API と通信
- バックエンド(FastAPI)
- データベース(MongoDB)
- 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: <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: <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: <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を使ってアプリケーションを実行する
- システムにDockerとDocker Composeがインストールされていることを確認します
- プロジェクトのルートディレクトリ(
farm-stack-todo)でターミナルを開きます - コンテナをビルドして起動します:
docker-compose up --build
- コンテナが起動したら、ウェブブラウザを開き、http://localhost:8000 にアクセスします
- コンテナを停止して削除するには、以下のコマンドを実行します:
docker-compose down
終わり
このTodoアプリケーションを構築することで、現代のウェブ開発で最も強力かつ人気のある技術を実践的に学ぶことができました。FastAPIを使って堅牢なバックエンドAPIを作成し、Reactで動的でレスポンシブなフロントエンドを構築し、MongoDBでデータを永続化し、Dockerでアプリケーション全体をコンテナ化する方法を学びました。このプロジェクトを通じて、これらの技術がシームレスに連携し、フル機能でスケーラブルなウェブアプリケーションを作成できることを実感できました。