The FARM stack is a modern web development stack that combines three powerful technologies: FastAPI, React, and MongoDB. This full-stack solution provides developers with a robust set of tools to build scalable, efficient, and high-performance web applications.
在這篇文章中,我將為您介紹這些關鍵技術的每一個,然後我們將使用FARM stack和Docker來建立一個專案,讓您了解所有事物是如何一起運作的。
這篇文章基於我在freeCodeCamp.org YouTube頻道上創建的課程。在這裡觀看:
FARM Stack簡介
FARM stack中的FARM代表:
-
F: FastAPI(後端)
-
R: React(前端)
-
M: MongoDB(數據庫)
FARM stack旨在利用每個組件的優勢,使開發人員能夠創建有豐富功能的應用程序並且開發體驗順暢。
FARM Stack的組件
-
FastAPI: FastAPI是一個現代化、高性能的Python網頁開發框架,用於構建API。它設計用於易於使用、快速編碼,並且適用於生產環境。FastAPI建立在Starlette的網頁部分和Pydantic的數據部分之上,使它成為建立健壯後端服務的強大選擇。
-
React:React是一個流行的JavaScript庫,用於構建使用者介面。由Facebook開發和維護,React讓開發者能夠創建可重用的UI組件,這些組件能夠有效地更新和渲染數據變化。其基於組件的架構和虛擬DOM使它成為構建動態和響應式前端應用程序的最佳選擇。
-
MongoDB: MongoDB是一個文件導向的NoSQL數據庫。它以靈活的、類似JSON的文件格式存儲數據,意味著每個文件中的字段可以不同,數據結構也可以隨時間變化。這種靈活性使得MongoDB成為需要快速發展和處理多種數據類型的應用程序的理想選擇。
使用 FARM Stack 的優勢
-
高效能:FastAPI 是目前最快的 Python 框架之一,而 React 的虛擬 DOM 確保了高效的 UI 更新。MongoDB 的文件模型能夠實現快速的讀寫。
-
可擴展性:FARM Stack 的所有組件都設計為可擴展。FastAPI 能夠有效地處理並發請求,React 應用程式能夠管理複雜的 UI,MongoDB 能夠在多台服務器之間分配數據。
-
社群與生態系:這三種技術都有龐大、活躍的社群和豐富的庫與工具生態系。
-
靈活性:FARM 堆積栈足夠靈活,可以適應各種類型的网際网路應用程式,從簡單的 CRUD 應用程式到複雜的、數據密集型系統。
透過結合這些技術,FARM 堆積栈為建立現代网際网路應用程式提供全面的解決方案。它讓開發者能夠使用 FastAPI 創建快速、可擴展的后端,使用 React 創建直觀、反應灵敏的前端,以及使用 MongoDB 創建靈活、高效的數據存儲。此堆積栈特別適合需要實時更新、複雜數據模型和高性能的應用程式。
項目概述:待办應用程式
在影片課程中,我介绍了 FARM 堆積栈中每种个别技术的更多内容。但在这篇文章中,我们将直接进入一个项目,将所有内容整合在一起。
我们将创建一个待办应用来帮助我们了解 FARM 堆积栈。在开始创建应用之前,让我们更多地讨论一下功能和软件架构。
待办应用的功能
我们的 FARM 堆积栈待办应用将包括以下功能:
-
多个待办列表:
-
用户可以创建、查看、更新和删除多个待办列表。
-
每个列表都有一个名称,并包含多个待办项目。
-
-
待辦事項:
-
在每個列表中,使用者可以添加、查看、更新和刪除待辦事項。
-
每個事項都有標籤、勾選/未勾選狀態,並且屬於特定的列表。
-
-
實時更新:
- 當對列表或事項進行更改時,UI將實時更新。
-
響應式設計:
- 該應用將是響應式的,並且在桌面和移動設備上都能良好運作。
系統架構
我們的待辦事項應用將遵循典型的FARM堆疊架構:
-
前端(React):
-
提供與待辦事項清單和項目互動的使用者介面。
-
透過RESTful API呼叫與後端通訊。
-
-
後端(FastAPI):
-
處理來自前端的API請求。
-
實現管理待辦事項清單和項目的業務邏輯。
-
與MongoDB資料庫互動以進行資料持久化。
-
-
資料庫(MongoDB):
-
儲存待辦事項清單和項目。
-
提供高效的待辦資料查詢和更新。
-
-
Docker:
- 將每個組件(前端、後端、數據庫)容器化,以便於開發和部署。
數據模型設計
我們的MongoDB數據模型將包含兩個主要結構:
- Todo列表:
{
"_id": ObjectId,
"name": String,
"items": [
{
"id": String,
"label": String,
"checked": Boolean
}
]
}
- 列表摘要(用於顯示在所有todo列表中):
{
"_id": ObjectId,
"name": String,
"item_count": Integer
}
API端點設計
我們的FastAPI後端將暴露以下RESTful端點:
-
Todo列表:
-
GET /api/lists: 獲取所有todo列表(摘要視圖)
-
POST /api/lists: 創建新的todo列表
-
GET /api/lists/{list_id}: 獲取具有所有項目的特定todo列表
-
DELETE /api/lists/{list_id}: 刪除特定的todo列表
-
-
待辦項目:
-
POST /api/lists/{list_id}/items: 將新項目添加到特定清單中
-
PATCH /api/lists/{list_id}/checked_state: 更新項目的勾選狀態
-
DELETE /api/lists/{list_id}/items/{item_id}: 從清單中刪除特定的項目
-
這個專案將提供FARM堆疊開發和Docker容器化的堅實基礎,之後您可以擴展到未來更複雜的應用程序。
那麼讓我們開始這個專案。
專案教程
專案設置和後端開發
步驟1:設置專案結構
為您的專案創建一個新的目錄:
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 stack todo 应用實現了數據訪問層。在下一部分中,我們將實現 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):
# 開始:
client = AsyncIOMotorClient(MONGODB_URI)
database = client.get_default_database()
# 確保數據庫可用:
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)
# 返回 FastAPI 应用程序:
yield
# 關閉:
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()
這個實現設置了帶有 CORS 中間件的 FastAPI 伺服器,連接到 MongoDB,並為我們的 todo 应用定義了 API 端點。
步驟 7:設置環境變量
在根目錄中創建一個 .env 檔案,並加入以下內容。請確保在 “.mongodb.net/” 結尾處添加數據庫名稱(”todo”)。
MONGODB_URI='mongodb+srv://beau:codecamp@cluster0.ji7hu.mongodb.net/todo?retryWrites=true&w=majority&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 堆疊待办應用程序設定 React 前端。
設定 React 前端
步驟 10:創建 React 應用程序
转到 frontend 目录:
cd ../frontend
使用 Create React App 创建新的 React 应用:
npx create-react-app .
安装其他依赖项:
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%;
}
步驟 13:创建 ToDoList 组件
创建一个新的文件 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;
}
步驟 14:更新主 CSS 文件
将 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 堆疊待办應用程序設定 React 前端。我們已經創建了主 App 组件、用於顯示所有待办列表的 ListTodoLists 组件,以及用於單個待办列表的 ToDoList 组件。在下一部分,我們將集中於運行和測試應用程序。
運行及測試應用程式
步驟18:使用Docker Compose運行應用程式
-
確保您的系統上已安裝Docker和Docker Compose
-
在您的專案(farm-stack-todo)的根目錄中打開終端機
-
建立並啟動容器:
docker-compose up --build
- 當容器運行起來後,打開您的網絡瀏覽器,前往http://localhost:8000
步驟19:停止應用程式
-
如果您未使用Docker運行應用程式:
-
按下Ctrl+C停止React開發伺服器
-
按下Ctrl+C停止FastAPI伺服器
-
按下Ctrl+C停止MongoDB伺服器
-
-
如果你是以 Docker Compose 來運行應用程式:
-
在運行 docker-compose up 的終端機中按下 Ctrl+C
-
執行以下命令以停止並移除容器:
-
docker-compose down
“`
恭喜你!你已經成功地構建並測試了一個 FARM stack todo 應用程式。這個應用程式展示了 FastAPI、React 和 MongoDB 在全棧網頁應用程式中的整合。
以下是一些增強你應用程式的潛在下一步:
-
添加用戶身份驗證和授權
-
實現數據驗證和錯誤處理
-
為 todo 項目添加更多功能,如截止日期、優先級或分類
-
透過更精緻的設計改善 UI/UX
-
為前端和後端寫單位測試和集成測試
-
為您的應用程序設置持續集成和部署 (CI/CD)
在繼續開發您的應用程序時,請記得更新您的依賴關係,並遵循安全和性能的最佳實踐。
結論和下一步
恭喜您完成這個全面的FARM堆栈教程!通過建立這個待辦應用程序,您获得了現代web開發中一些最強大和受欢迎技術的實戰經驗。您學習了如何使用FastAPI創建一個健壯的後端API,使用React建造一个動態和響應式的frontend,使用MongoDB保存數據,並使用Docker容器化您的整個應用程序。這個項目 demonstrate 了這些技術如何無縫地一起工作,以創造一個具有全套功能的可擴展web應用程序。
Source:
https://www.freecodecamp.org/news/use-the-farm-stack-to-develop-full-stack-apps/