让我们构建并部署一个 FARM Stack 应用程序

2025-06-10

让我们构建并部署一个 FARM Stack 应用程序

你曾经用 React、Angular 或 Vue 编写过全栈应用程序吗?本教程将介绍 FARM 技术栈,即 FastAPI、React 和 MongoDB。FastAPI 是一个用于快速构建 API 的 Python 框架。本教程是一个待办事项列表项目,用 React 实现起来相当简单。本教程通常也适用于 Vue 和 Angular 等其他框架,但我将使用 React 进行讲解。

项目设置

启动一个项目非常简单。我将展示两种方法,一种是使用我的 CLI create-farm-app,另一种是手动设置。这两种方法都很简单,但如果您不想自己进行太多设置,也可以使用 CLI。我建议您在第一个项目中手动设置应用程序。

手动设置

让我们开始手动设置:



$ mkdir farm-stack-tut
$ cd farm-stack-tut
$ mkdir backend
$ code .
$ git init
$ yarn create react-app frontend --template typescript
$ cd backend
$ git init
$ touch requirements.txt main.py model.py database.py


Enter fullscreen mode Exit fullscreen mode

现在让我们打开 requirements.txt,并输入以下依赖项:



fastapi == 0.65.1

uvicorn == 0.14.0

motor == 2.4.0

gunicorn == 20.1.0

pymongo[srv] == 3.12.0


Enter fullscreen mode Exit fullscreen mode

我们需要 uvicorn 来运行 ASGI 服务器,motor 和 pymongo[srv] 来连接 MongoDB atlas 数据库,以及 gunicorn 来部署应用。
我们初始化两个 git 仓库(加上一个由 CRA 自动初始化的仓库)是为了使用子模块。相比于一个大型仓库,我更喜欢这种设置,主要是因为它更容易部署。在本教程中,我将向您展示如何使用子模块进行部署,但我相信如果您深入研究,您一定能找到不使用子模块的部署方法。

安装依赖项

如果你使用 pipenv(我推荐使用),安装 pip 依赖项实际上非常简单。只需导航到 backend 文件夹并输入:



$ pipenv install -r requirements.txt


Enter fullscreen mode Exit fullscreen mode

模板设置

这很容易做到,因为我已经通过 CLI 设置了大部分东西,但你仍然需要设置 git 子模块。



$ yarn create farm-app --name=farm-stack-tut


Enter fullscreen mode Exit fullscreen mode

无论如何,您可能会看到一个名称弹出窗口,我正在努力修复它,但如果您输入相同的名称,它应该可以正常工作。

Git 设置

现在就开始设置这些子模块,这样以后的工作就少了:
创建三个新的远程仓库,一个用于前端,一个用于后端,一个用于完整的应用程序。
在前端和后端的本地仓库中,运行以下命令:



$ git remote add origin <url>
$ git add *
$ git commit -m "first commit"
$ git branch -M main
$ git push -u origin main


Enter fullscreen mode Exit fullscreen mode

在主仓库中,一旦推送了这些命令,就执行这些命令。



$ git submodule add <frontend-url> frontend
$ git submodule add <backend-url> backend


Enter fullscreen mode Exit fullscreen mode

然后提交并将更改推送到主远程存储库。

制作后端 API

我们将从开始main.py,我们需要以下代码来开始:



from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

origins = ["*"] # This will eventually be changed to only the origins you will use once it's deployed, to secure the app a bit more.

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

@app.get('/')
def get_root():
    return {"Ping": "Pong"}


Enter fullscreen mode Exit fullscreen mode

这是最基本的 API,仅用于测试,以确保我们已正确设置所有设置。
在此处运行 uvicorn 命令:



$ uvicorn main:app --reload


Enter fullscreen mode Exit fullscreen mode

如果您导航到http://localhost:8000,您应该会收到我们设置返回的消息 { "Ping": "Pong" }。如果收到,我们就可以开始构建后端的其余部分了。

MongoDB Atlas

让我们稍事休息,继续创建数据库。如果您不打算部署此应用,可以直接使用本地 MongoDB 数据库。但由于我将部署我的应用,因此我将使用他们的云托管服务。导航到MongoDB Atlas,并设置一个新帐户;如果您以前使用过,也可以创建一个新项目。创建项目后,您可以使用“添加数据库”按钮免费添加集群。为集群命名并允许创建。完成后,点击“浏览集合”按钮,分别插入名为“TodoDatabase”和“todos”的新数据库和集合。这就是我们现在需要做的全部工作。

建立模型并连接到数据库

要将数据推送到数据库,我们需要做两件事。首先,我们需要创建一个数据模型,我们可以在 中完成model.py。我们将包含 3 个字符串:一个 nanoid、一个标题和一个描述,以及一个布尔值来检查它是否已完成。模型如下所示:



from pydantic import BaseModel

class Todo(BaseModel):
    nanoid: str
    title: str
    desc: str
    checked: bool


Enter fullscreen mode Exit fullscreen mode

接下来我们需要做的就是连接到我们的数据库,使用 motor 和 pymongo 很容易,但是,为了保护我们的应用程序,我们将使用数据库 URI 的环境变量,这意味着我们现在需要使用 python-dotenv:



$ pipenv install python-dotenv


Enter fullscreen mode Exit fullscreen mode

在后端的根目录创建一个 .env 文件,并在其中填写数据库 URI(您可以通过单击 MongoDB Atlas 上的“连接”找到它):



DATABASE_URI = "<URI>" 


Enter fullscreen mode Exit fullscreen mode

从技术上讲,这只是为了让我们的应用程序在本地机器上运行,因为 Heroku 允许我们在部署时插入环境变量,但最好还是隐藏敏感数据。如果您还没有这样做,请创建一个.gitignore文件并将其放入.env其中。
现在让我们连接到数据库。
为此,我们首先使用 dotenv 从文件中获取 URI。



from model import *
import motor.motor_asyncio
from dotenv import dotenv_values
import os

config = dotenv_values(".env")
DATABASE_URI = config.get("DATABASE_URI")
if os.getenv("DATABASE_URI"): DATABASE_URI = os.getenv("DATABASE_URI") #ensures that if we have a system environment variable, it uses that instead

client = motor.motor_asyncio.AsyncIOMotorClient(DATABASE_URI)


Enter fullscreen mode Exit fullscreen mode

现在我们可以为我们的数据库和集合创建变量,然后创建一堆函数来修改集合的数据。



database = client.TodoDatabase
collection = database.todos

async def fetch_all_todos():
    todos = []
    cursor = collection.find()
    async for doc in cursor:
        todos.append(Todo(**doc))
    return todos

async def fetch_one_todo(nanoid):
    doc = await collection.find_one({"nanoid": nanoid}, {"_id": 0})
    return doc

async def create_todo(todo):
    doc = todo.dict()
    await collection.insert_one(doc)
    result = await fetch_one_todo(todo.nanoid)
    return result

async def change_todo(nanoid, title, desc, checked):
    await collection.update_one({"nanoid": nanoid}, {"$set": {"title": title, "desc": desc, "checked": checked}})
    result = await fetch_one_todo(nanoid)
    return result

async def remove_todo(nanoid):
    await collection.delete_one({"nanoid": nanoid})
    return True


Enter fullscreen mode Exit fullscreen mode

以上就是我们需要的所有函数,但您也可以添加自己的函数。让我们开始执行一些 http 操作main.py



@app.get("/api/get-todo/{nanoid}", response_model=Todo)
async def get_one_todo(nanoid):
    todo = await fetch_one_todo(nanoid)
    if not todo: raise HTTPException(404)
    return todo

@app.get("/api/get-todo")
async def get_todos():
    todos = await fetch_all_todos()
    if not todos: raise HTTPException(404)
    return todos

@app.post("/api/add-todo", response_model=Todo)
async def add_todo(todo: Todo):
    result = await create_todo(todo)
    if not result: raise HTTPException(400)
    return result

@app.put("/api/update-todo/{nanoid}", response_model=Todo)
async def update_todo(todo: Todo):
    result = await change_todo(nanoid, title, desc, checked)
    if not result: raise HTTPException(400)
    return result

@app.delete("/api/delete-todo/{nanoid}")
async def delete_todo(nanoid):
    result = await remove_todo(nanoid)
    if not result: raise HTTPException(400)
    return result


Enter fullscreen mode Exit fullscreen mode

现在让我们尝试一下这些操作来测试一下http:localhost:8000/docs
你应该会看到一个包含所有操作的屏幕,如果你点击其中任何一个操作,它都会弹出如下内容:
图像

点击任意一个按钮上的“试用”,最好先从“添加待办事项”开始,然后就可以执行操作了。暂时忽略响应,并在“查看集合”部分检查你的 MongoDB 数据库。你应该会看到一个新项目,但如果没有,你可以返回响应并进行调试(如果你已经打开了页面,可能需要刷新数据库)。你也应该尝试一下其他操作,如果一切顺利,你应该就可以开始处理你的前端了。

前端

如果您知道 React 的工作原理,并且知道如何通过 axios 发送 http 请求,我建议您跳过本节,但对于其他人来说,这是我的前端版本。

图书馆

我正在使用node@16.4.2

  • node-sass@6.0.0(您可以根据您的节点版本使用不同版本的 node-sass 和 sass-loader,我不使用 dart sass 的唯一原因是编译时间慢)
  • sass-loader@12.1.0
  • 纳米类
  • axios
  • 这就是我实际要使用的库,我的模板也添加了 react-router

应用程序

让我们首先设置一个好的文件夹结构(我的模板 sammy-libraries 为我完成了这个,但这是我喜欢的设置方式): 现在我们可以开始使用我们的应用程序了。
图像

我们先不管 index.tsx,直接转到 App.tsx,它看起来应该是这样的:



import React from "react";
import TodoList from "./components/TodoList";

function App() {
    return (
        <div className="app-container">
            <header className="app-header">
                <h1>To-Do List</h1>
            </header>
            <div className="content">
                <TodoList />
            </div>
        </div>
    );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

在进行任何样式设置之前,我们先设置一下接下来需要的另外三个组件,分别是TodoList.tsxTodo.tsxAddTodo.tsx。它们现在看起来应该基本相同,只是一个 div,根据用途指定一个 className,就像 todo 组件那样:



import React from "react";

function Todo() {
    return(
        <div className="todo-container">

        </div>
    );
}

export default Todo;


Enter fullscreen mode Exit fullscreen mode

现在我们有了这些组件,让我们来为我们的应用定义一些样式。我将使用 SCSS 而不是 SASS,但这应该很容易适应 SASS(或者如果你想做一些额外的工作,也可以使用 CSS)。
这是我使用的样式表index.scss



$primary: #146286;
$secondary: #641486;
$accent: #3066b8;

.app-header {
    background-color: $primary;
    color: white;
    padding: 5px;
    border-radius: 10px;
    margin-bottom: 5px;
}

.content {
    .todo-list-container {
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        grid-template-rows: repeat(5, 1fr);
        grid-gap: 10px;

        .todo-container {
            display: flex;
            flex-direction: column;
            justify-content: space-evenly;

            border-radius: 6px;
            padding: 10px 6px;
            background-color: $secondary;
            color: white;

            h1 {
                font-size: 20px;
            }

            span {
                font-size: 14px;
            }

            footer {
                display: flex;
                flex-direction: row-reverse;
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

这应该是我们需要做的唯一样式,但如果您愿意,您可以做一些额外的事情。

现在让我们开始处理组件。

完成的应用程序如下所示:



import { nanoid } from "nanoid";
import React, { useState } from "react";
import { TodoType } from "./components/Todo";
import TodoList from "./components/TodoList";

function App() {
    const [todoList, setTodoList] =  useState<TodoType[]>([]);

    const [title, setTitle] = useState<string>("");
    const [desc, setDesc] = useState<string>("");

    const changeTitle = (event: React.ChangeEvent<HTMLInputElement>) => {
        setTitle(event.currentTarget.value);
    };

    const changeDesc = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
        setDesc(event.currentTarget.value);
    }

    const changeChecked = (event: React.MouseEvent<HTMLInputElement>, id: string) => {
        let temp = [...todoList];
        temp.forEach((item) => {
            if (item.nanoid === id) {
                item.checked = !item.checked;
            }
        });
        setTodoList(temp);
    };

    const addTodo = (event: React.MouseEvent<HTMLButtonElement>) => {
        let newTodo: TodoType = {
            nanoid: nanoid(),
            title: title,
            desc: desc,
            checked: false
        };
        setTodoList([...todoList, newTodo]);
    }

    return (
        <div className="app-container">
            <header className="app-header">
                <h1>To-Do List</h1>
            </header>
            <div className="content">
                <TodoList submit={addTodo} changeDesc={changeDesc} changeTitle={changeTitle} list={todoList} changeChecked={changeChecked} />
            </div>
        </div>
    );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

这执行了一些非常基本的功能,通过反应钩子将道具传递到树下。

TodoList 看起来如下:



import React from "react";
import AddTodo from "./AddTodo";
import Todo, { TodoType } from "./Todo";

interface TodoListProps {
    list: TodoType[]
    changeChecked: (event: React.MouseEvent<HTMLInputElement>, nanoid: string) => void;
    changeTitle: (event: React.ChangeEvent<HTMLInputElement>) => void;
    changeDesc: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
    submit: (event: React.MouseEvent<HTMLButtonElement>) => void;
}

function TodoList(props: TodoListProps) {
    return(
        <div className="todo-list-container">
            {props.list.map((item) => {
                return(
                    <Todo nanoid={item.nanoid} title={item.title} desc={item.desc} checked={item.checked} changeChecked={props.changeChecked} /> 
                );
            })}
            <AddTodo changeTitle={props.changeTitle} changeDesc={props.changeDesc} submit={props.submit} />
        </div>
    );
}

export default TodoList;


Enter fullscreen mode Exit fullscreen mode

Todo 看起来应该是这样的:



import React from "react";

export type TodoType = {
    nanoid: string;
    title: string;
    desc: string;
    checked: boolean;
}

interface TodoProps extends TodoType {
    changeChecked: (event: React.MouseEvent<HTMLInputElement>, nanoid: string) => void;
}

function Todo(props: TodoProps) {
    return(
        <div className="todo-container">
            <h1>{props.title}</h1>
            <span>{props.desc}</span>
            <footer>
                <input type="checkbox" checked={props.checked} onClick={(e) => props.changeChecked(e, props.nanoid)} />
            </footer>
        </div>
    );
}

export default Todo;


Enter fullscreen mode Exit fullscreen mode

最后,AddTodo 应该如下所示:



import React from "react";

interface AddTodoProps {
    submit: (event: React.MouseEvent<HTMLButtonElement>) => void;
    changeTitle: (event: React.ChangeEvent<HTMLInputElement>) => void;
    changeDesc: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
}

function AddTodo(props: AddTodoProps) {
    return(
        <div className="todo-container add-todo-container">
            <input type="text" className="title" placeholder="Title..." onChange={props.changeTitle} />
            <textarea className="desc" placeholder="Description..." onChange={props.changeDesc}>
            </textarea>
            <button className="submit" onClick={props.submit}>Add Todo</button>
        </div>
    );
}

export default AddTodo;


Enter fullscreen mode Exit fullscreen mode

现在是时候使用useEffect()axios 将所有数据存储到数据库中了。
这是我们的最终版本App.tsx



import axios from "axios";
import { nanoid } from "nanoid";
import React, { useEffect, useState } from "react";
import { TodoType } from "./components/Todo";
import TodoList from "./components/TodoList";

function App() {
    const [todoList, setTodoList] = useState<TodoType[]>([]);

    const [title, setTitle] = useState<string>("");
    const [desc, setDesc] = useState<string>("");

    useEffect(() => {
        axios
            .get(process.env.REACT_APP_BACKEND_URL + "/api/get-todo")
            .then((res) => {
                setTodoList(res.data);
            });
    }, []);

    const changeTitle = (event: React.ChangeEvent<HTMLInputElement>) => {
        setTitle(event.currentTarget.value);
    };

    const changeDesc = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
        setDesc(event.currentTarget.value);
    };

    const changeChecked = (
        event: React.MouseEvent<HTMLInputElement>,
        id: string
    ) => {
        let temp = [...todoList];
        let tempIndex = 0;
        temp.forEach((item, i) => {
            if (item.nanoid === id) {
                item.checked = !item.checked;
                tempIndex = i;
            }
        });
        setTodoList(temp);
        let item = todoList[tempIndex];
        axios.put(
            process.env.REACT_APP_BACKEND_URL +
                `/api/update-todo/${item.nanoid}`,
                { nanoid: item.nanoid, title: item.title, desc: item.desc, checked: item.checked}
        );
    };

    const addTodo = (event: React.MouseEvent<HTMLButtonElement>) => {
        let newTodo: TodoType = {
            nanoid: nanoid(),
            title: title,
            desc: desc,
            checked: false,
        };
        setTodoList([...todoList, newTodo]);
        axios.post(
            process.env.REACT_APP_BACKEND_URL + "/api/add-todo",
            JSON.stringify(newTodo)
        );
    };

    return (
        <div className="app-container">
            <header className="app-header">
                <h1>To-Do List</h1>
            </header>
            <div className="content">
                <TodoList
                    submit={addTodo}
                    changeDesc={changeDesc}
                    changeTitle={changeTitle}
                    list={todoList}
                    changeChecked={changeChecked}
                />
            </div>
        </div>
    );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

现在已经完成,我们可以准备部署应用程序了。

部署

我将使用 Heroku 部署后端,并使用 GitHub Pages 部署前端。我使用 Heroku 唯一遇到的缺点是,如果后端处于空闲状态,则必须在后端恢复空闲状态后重新启动,因此在使用应用程序期间,您可能会遇到较长的加载时间。而使用 GitHub Pages 则从未遇到过任何问题。

后端部署

如果您还没有 Heroku 帐户,请创建一个新帐户,然后创建一个新应用。我发现通过 GitHub 部署最简单,但如果使用 Heroku CLI,您将拥有更多控制权。无论如何,以下是您必须遵循的基本步骤。在后端的根目录下
创建一个新文件,并将以下内容放入其中:Procfile



web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app


Enter fullscreen mode Exit fullscreen mode

另外,请确保将 添加python-dotenv == 0.19.0到您的requirements.txt文件中并重新安装依赖项,以确保一切正常启动。
然后返回,并将origins 数组中的main.py替换为 推送到 GitHub,部署并运行。如果一切正常,您应该能够看到我们之前查看过的相同根页面。 转到应用程序设置,显示配置变量,然后将作为一个配置变量输入。"*""https://<username>.github.io"

DATABASE_URI

前端部署

这稍微复杂一些,因为我们必须安装依赖项并进行编辑package.json,但它仍然非常简单。将 url
编辑.env为 heroku 应用程序 url,提交并推送,然后执行以下操作:



$ yarn add --dev gh-pages


Enter fullscreen mode Exit fullscreen mode

然后您可以打开package.json,并将这些行添加到"scripts"



"predeploy": "yarn build",
"deploy": "REACT_APP_BACKEND_URL=<backend-url> gh-pages -d build"


Enter fullscreen mode Exit fullscreen mode

另添加:



"homepage": "https://<username>.github.io/<project-name>-frontend/"


Enter fullscreen mode Exit fullscreen mode

在 github 中,添加一个与后端 url 相同的环境变量的 secret,确保其名称相同。



$ yarn start
^C
$ yarn deploy


Enter fullscreen mode Exit fullscreen mode

如果一切顺利,你应该会得到一个 100% 可以运行的应用程序。
源代码在 GitHub 上:
https://github.com/jackmaster110/farm-stack-tut

鏂囩珷鏉ユ簮锛�https://dev.to/sammyshear/let-s-build-and-deploy-a-farm-stack-app-epo
PREV
了解 Docker
NEXT
极度害羞的开发者指南:如何通过人脉关系获得更好的工作(不要显得令人毛骨悚然)