让我们构建并部署一个 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
现在让我们打开 requirements.txt,并输入以下依赖项:
fastapi == 0.65.1
uvicorn == 0.14.0
motor == 2.4.0
gunicorn == 20.1.0
pymongo[srv] == 3.12.0
我们需要 uvicorn 来运行 ASGI 服务器,motor 和 pymongo[srv] 来连接 MongoDB atlas 数据库,以及 gunicorn 来部署应用。
我们初始化两个 git 仓库(加上一个由 CRA 自动初始化的仓库)是为了使用子模块。相比于一个大型仓库,我更喜欢这种设置,主要是因为它更容易部署。在本教程中,我将向您展示如何使用子模块进行部署,但我相信如果您深入研究,您一定能找到不使用子模块的部署方法。
安装依赖项
如果你使用 pipenv(我推荐使用),安装 pip 依赖项实际上非常简单。只需导航到 backend 文件夹并输入:
$ pipenv install -r requirements.txt
模板设置
这很容易做到,因为我已经通过 CLI 设置了大部分东西,但你仍然需要设置 git 子模块。
$ yarn create farm-app --name=farm-stack-tut
无论如何,您可能会看到一个名称弹出窗口,我正在努力修复它,但如果您输入相同的名称,它应该可以正常工作。
Git 设置
现在就开始设置这些子模块,这样以后的工作就少了:
创建三个新的远程仓库,一个用于前端,一个用于后端,一个用于完整的应用程序。
在前端和后端的本地仓库中,运行以下命令:
$ git remote add origin <url>
$ git add *
$ git commit -m "first commit"
$ git branch -M main
$ git push -u origin main
在主仓库中,一旦推送了这些命令,就执行这些命令。
$ git submodule add <frontend-url> frontend
$ git submodule add <backend-url> backend
然后提交并将更改推送到主远程存储库。
制作后端 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"}
这是最基本的 API,仅用于测试,以确保我们已正确设置所有设置。
在此处运行 uvicorn 命令:
$ uvicorn main:app --reload
如果您导航到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
接下来我们需要做的就是连接到我们的数据库,使用 motor 和 pymongo 很容易,但是,为了保护我们的应用程序,我们将使用数据库 URI 的环境变量,这意味着我们现在需要使用 python-dotenv:
$ pipenv install python-dotenv
在后端的根目录创建一个 .env 文件,并在其中填写数据库 URI(您可以通过单击 MongoDB Atlas 上的“连接”找到它):
DATABASE_URI = "<URI>"
从技术上讲,这只是为了让我们的应用程序在本地机器上运行,因为 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)
现在我们可以为我们的数据库和集合创建变量,然后创建一堆函数来修改集合的数据。
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
以上就是我们需要的所有函数,但您也可以添加自己的函数。让我们开始执行一些 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
现在让我们尝试一下这些操作来测试一下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;
在进行任何样式设置之前,我们先设置一下接下来需要的另外三个组件,分别是TodoList.tsx
、Todo.tsx
和AddTodo.tsx
。它们现在看起来应该基本相同,只是一个 div,根据用途指定一个 className,就像 todo 组件那样:
import React from "react";
function Todo() {
return(
<div className="todo-container">
</div>
);
}
export default Todo;
现在我们有了这些组件,让我们来为我们的应用定义一些样式。我将使用 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;
}
}
}
}
这应该是我们需要做的唯一样式,但如果您愿意,您可以做一些额外的事情。
现在让我们开始处理组件。
完成的应用程序如下所示:
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;
这执行了一些非常基本的功能,通过反应钩子将道具传递到树下。
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;
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;
最后,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;
现在是时候使用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;
现在已经完成,我们可以准备部署应用程序了。
部署
我将使用 Heroku 部署后端,并使用 GitHub Pages 部署前端。我使用 Heroku 唯一遇到的缺点是,如果后端处于空闲状态,则必须在后端恢复空闲状态后重新启动,因此在使用应用程序期间,您可能会遇到较长的加载时间。而使用 GitHub Pages 则从未遇到过任何问题。
后端部署
如果您还没有 Heroku 帐户,请创建一个新帐户,然后创建一个新应用。我发现通过 GitHub 部署最简单,但如果使用 Heroku CLI,您将拥有更多控制权。无论如何,以下是您必须遵循的基本步骤。在后端的根目录下
创建一个新文件,并将以下内容放入其中:Procfile
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app
另外,请确保将 添加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
然后您可以打开package.json
,并将这些行添加到"scripts"
:
"predeploy": "yarn build",
"deploy": "REACT_APP_BACKEND_URL=<backend-url> gh-pages -d build"
另添加:
"homepage": "https://<username>.github.io/<project-name>-frontend/"
在 github 中,添加一个与后端 url 相同的环境变量的 secret,确保其名称相同。
$ yarn start
^C
$ yarn deploy
如果一切顺利,你应该会得到一个 100% 可以运行的应用程序。
源代码在 GitHub 上:
https://github.com/jackmaster110/farm-stack-tut