本文带大家实现一个Todo App。Todo App想必大家都很熟悉了。这次的略有不同,是带后端的。
带不带后端的Todo完全是两回事。带了后端而是否支持多个用户的。有了多用户支持,在架构上又可以有很多不同的变化。我们会在后续的文章中逐步解说这些情况。
本文的后端是最简单的一种。单用户,还不用登录的那种。无论你是谁,在哪里都可以直接打开这个todo app进行操作。
前文中已经讲述过GraphQL的基本概念,和基于ExpressJs的实现。GraphQL的好处就在于一定程度的解绑前后端开发。在使用以前前后端的合作模式类似于在餐厅点餐,点什么做什么。使用了GraphQL之后就类似于自助餐厅,顾客(前端同学)想要什么可以自己拿。
本文的后端不再使用JS技术占的实现,转而使用Python的Flask和Graphql,前端使用Vue 3的composition API来实现。
后端
后端用的是一个小巧而方便的web框架Flask。它只关注核心,提供必要的扩展性。而且它本身简单易学。只要一个py文件就可以跑起来一个web服务,不需要特别的文件目录或者某些特别的代码才能运行起来。
Python需要一个虚拟环境来运行。这可以让一个App和各种依赖都运行在特定版本的Python环境下。
跳转到你刚刚建好的app目录下,比如本例的todo。
mkdir todo
cd todo
python3 -m venv venv
激活环境
. venv/bin/activate
安装Flask
pip install Flask
新建一个app.py的文件。
vim app.py
添加最少的可运行代码:
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello, World!'
@app.route
是一个py的装饰器,让hello()
方法可以接收http请求,返回response。在这个装饰器里的字符串是http的请求路径。这里的路径是/
,处理的请求路径是http://127.0.0.1:5000/
,之后返回字符串Hello, World!
。
在@app.route
装饰器里并没有制定处理的请求方法,默认处理的是GET请求。如果要制定请求的方法可以这样:@app.route('/', methods=('GET', 'POST'))
。目标方法要处理的http请求方法可以通过py的tuple传入。
在GraphQL
小节之后需要配置app的路径让Flask可以处理graphql相关的请求。
运行app
flask run
如果要运行的文件命名为app.py或者wsgi.py的话可以直接运行。否则需要这样:
export FLASK_APP=your_filename.py
flask run
* Running on http://127.0.0.1:5000/
如果要制定app运行的端口号可以加 -p
参数,这样:flask run -p 5001
。
数据库
数据库使用Postgresql。为什么使用postgresql,可以参考这篇文章。笔者对数据库没什么研究,是因为工作关系,本机安装了postgresql所以就直接用了。
在参考文章中描述了很多postgresql和mysql的对比,但在本例中目前也只用到了增删改查。
ORM
ORM使用的是Prisma。它号称是下一代ORM。主要包含三大部分:
- Prisma Client: 自动生成的,类型安全的ORM。
- Prisma Migrate:迁移系统。这部分类似于数据库库的版本管理工具。
- Prisma Studio:一个编辑数据库数据的可视化工具。
最主要的,我们会用到前两个。没有Primsa Client没办法查数据库。有了Prisma Migrate可以管理数据库更新的版本。
下面我们看看如何使用PrismaJS。这需要你首先安装一个合适的Node版本。
一、运行:
npx prisma init
会在根目录下生产prisma/schema.prisma和*.env文件。当然,你也可以自己在任意目录下建一个schema.prisma*文件。一个 .env文件。
在schema.prisma文件里你会看到这个:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // *
}
这里需要我们在.env
文件里加上数据库连接串:
DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public"
schema.prisma文件主要有三部分构成。
1. datasource也就是上面提到的。
2. data model,也就是下面即将要讲到的。
3. generator,生成client的配置。这个会在py的配置中讲到。
二、加入Model
在schema.prisma
里添加你需要的Model。在本例中需要的是Todo这个Model。
model Todo {
id Int @id @default(autoincrement())
title String @db.VarChar(150)
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
三、生成表
确认在.env
填写的数据库连接没有问题后可以执行migrate命令来生成表:
npx prisma migrate dev --name init
或者,你不想生成本地的数据库更新记录就要表都建好的话也可以使用npx prisma db push
四、生成Client
在PrismaJS下只需要安装client就会生成、更新与schema.prisma
文件定义的Model相对应的Client文件:
npm install @prisma/client
在install的同时会执行prisma generate
命令来生成client文件。这些文件都在安装的prisma目录下。
然后就可以对数据库增删改查了。
Prisma-Client-Python
Prisma本身是用TypeScript开发的,服务于JS技术栈的。在Py栈下可以使用Prisma-Client-Python。这个py库的核心是用Rust写的,会在开始运行的时候下载这些Rust的二进制文件。所以不用担心node或者typescript的事。只用Py可以完全实现上面的功能。
首先、安装Py的环境和依赖。
pip install prisma
然后、自己加schema.prisma
和.env
。或者用上面的node的方式生成。但是,在生成的schema文件里,重点关注一下generator:
generator client {
provider = "prisma-client-py"
interface = "sync"
}
在generator的interface里,可以配置为sync
也可以配置为asyncio
。使用asyncio
生成的client代码是在asyncio
环境下执行的,需要额外的关注event loop。本例中为了简单都配置为sync
。
最后、生成client代码
运行
prisma generate
可以生成强类型的python client代码。每次更新schema.prisma的Model之后都需要生成一次。
现在看看使用client查询数据库是什么样的:
def add_todo(self, title: str, completed: bool) -> typing.List[Todo]:
completedStr = "Y" if completed else "N"
print(f"Add todo {title} completed: {completedStr}")
prisma = Prisma()
prisma.connect()
todo = prisma.todo.create(
data={
"title": title,
"content": "",
"completed": completed,
"review": "",
}
)
todo_list = prisma.todo.find_many()
prisma.disconnect()
return todo_list
部署
如果是部署在长时间运行的进程中,比如虚拟机之类的。设置合适的大小的连接池,确保app只有一个Prisma
client实例。
一般,连接池大小是(CPU数量 * 2 + 1) 除以 app实例的数量。
确保全局只有一个prisma client的实例。整个app都是用整个client实例。而且,不需要每次的显示的调用disconnect()
方法。所以,上面的例子可以改写为:
def add_todo(self, title: str, completed: bool) -> typing.List[Todo]:
completedStr = "Y" if completed else "N"
print(f"Add todo {title} completed: {completedStr}")
# prisma = Prisma() '''使用全局实例
prisma.connect()
todo = prisma.todo.create(
data={
"title": title,
"content": "",
"completed": completed,
"review": "",
}
)
todo_list = prisma.todo.find_many()
# prisma.disconnect() ''' 不需要显示调用disconnect
return todo_list
如果是serverless的话,可以参考这里。
GraphQL
所以在众多的优秀的python GraphQL库中,使用Strawberry来实现Todo app的功能。
在开发中具体实现GraphQL的方式有两种:Schema优先和代码优先。用我们的todo作为例子是这样的:
Schema优先也就是Schema First的定义是指用Schema Definition Language来定义对象类型
type Todo {
id: ID!
title: String!
completed: Boolean
代码优先,Code First是用代码来定义GraphQL的对象类型。
@strawberry.type
class Todo:
id: int
title: str
completed: bool
Strawberry就是专门用来支持代码优先方式的GraphQL库。
GraphQL的根对象就是Query(必有),Mutation。所以Todo也要定义在Query里:
@strawberry.type
class Todo:
id: int
title: str
completed: bool
在Query里
@strawberry.type
class Query:
todos: typing.List[Todo] = strawberry.field(resolver=get_todo_list)
只是定义GraphQL的对象类型是没有数据的,所以需要定义resolver来提供数据。get_todo_list
会用prisma-client-python生成的代码去查询数据库。
把Query放进Schema里
schema = strawberry.Schema(query=query.Query)
运行后端
目前为止,我们还只看到了后端运行之后的“Hello,World!”。同时也已经定义好了ORM的Model和GraphQL的Schema。万事俱备只欠东风。
我们用从上到下的方式来完整的整合一下上面讲到的内容。安装依赖之类的就省略了。
首先就要从GraphQL的Schema开始。本例中Schema和类型的定义都放在了/gql
目录下。
--gql
-schema
-query
-mutation
-todo
mutation
是用来添加Todo的,可以先忽略。那么GraphQL要怎么才能可用呢。那就需要Flask把请求转给GraphQL库strawberry
来处理了。所以需要稍微配置一下:
在app.py
文件中添加如下代码:
app.add_url_rule(
"/graphql",
view_func=GraphQLView.as_view("graphql_view", schema=schema.schema),
)
添加之后,重新运行。访问地址http://127.0.0.1:5000/graphql 。你就会看到GraphiQL的界面了。
在其中输入如下查询代码:
{
todos {
id
title
completed
}
}
点击查询按钮是会报错的。因为,还需要把查询todo的resolver补上。
回到gql目录下,在query.py文件里,对应的todos
字段下添加resolver:
def get_todo_list_by_condition(all: bool, completed: bool):
# db = Prisma() import from another module
db.connect()
todo_list = []
if all:
todo_list = db.todo.find_many()
else:
todo_list = db.todo.find_many(where={"completed": completed})
return todo_list
@strawberry.type
class Query:
todos: typing.List[Todo] = strawberry.field(resolver=get_todo_list)
重新运行之后就可以看到查询的结果了。如果没有的话那是数据库为空。如果报错的话运行一次generate命令:prisma generate --schema path_to_your/schema.prisma
。 再运行就可以了。
最后要给Flask增加一个CORS的配置。安装flask-cors。在app.py里增加这些代码:
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
前端
前端使用Vue 3。Vue的文档非常丰富,而且它的各种教程也是可以互动的,包括options API和composition API可选的。对于学习Vue非常有帮助。
本例中我们使用Vue 3的composition API。Vue-router做路由,使用pinia来实现状态管理。
这个todo app的出处就在vue的examples里面,点击这里可以直达。本例中会对这个app做一些魔改,让这个app可以尽可能的使用到更多的知识点。
首先,更具quick-start安装和配置依赖,这个过程里不要忘记选择TypeScript
的支持。
Todo的布局
这个文件我们直接把TodoMVC的App.vue放在HomeView.vue里。
首先,我们要对这个示例从JavaScript改造成TypeScript。同时还需要删除部分对example有破坏影响的style,具体的细节就不在文中详细叙述,可以参考代码。
再次强调:这里的改造主要是为了使用到尽可能多的Vue 3的composition API的知识点。在具体的开发实践中,还是要选择合适的技术才对。
然后,我们要把其中的各个部分拆分成不同的组件。
主要拆分成三部分:主题部分,也就是外满的壳或者说叫做container。列表页部分,主要用来显示todo条目。也就是第一块红框。最后是Footer,显示多少条目还没有完成,全部、active等内容。
处理Footer
先来看看在重构前footer的代码:
<footer class="footer" v-show="todos.length"> //* 1
<span class="todo-count">
<strong>{{ remaining }}</strong> //* 2
<span>{{ remaining === 1 ? 'item' : 'items' }} left</span>
</span>
<ul class="filters">
<li>
<a href="#/all" :class="{ selected: visibility === 'all' }">All</a> //* 3
</li>
<li>
<a href="#/active" :class="{ selected: visibility === 'active' }">Active</a>
</li>
<li>
<a href="#/completed" :class="{ selected: visibility === 'completed' }">Completed</a>
</li>
</ul>
<button class="clear-completed" @click="removeCompleted" v-show="todos.length > remaining"> //* 4
Clear completed
</button>
</footer>
有四条需要我们处理的。
- todo的数量:
todos.length
- 剩余的todo的数量:
remaining
- 切换显示不同的todo类型列表:all、active和completed
- 清空所有已经完成的todo:
removeCompleted
Vue的传递的props就只是props,传递事件需要定义emits,这和React颇为不同。这里使用的remaining
和todos.length
可以作为作为props传入的抽象出来的Footer组件里。同时还需要处理removeCompleted
方法,它会被定义为一个event放在defineEmits
里。这里有个问题:按照文档的方法用单独的interface定义props的时候会报错,反而使用其他的方法没有问题。
如此的操作之后,你会在components目录下看到一个完成版的Footer
:
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
const props = defineProps<{
todoCount: number;
remaining: number;
}>();
const emit = defineEmits<{
(e: "removeCompleted"): void;
}>();
function handleClick() {
emit("removeCompleted");
}
</script>
<template>
<footer class="footer" v-show="todoCount > 0">
<span class="todo-count">
<strong>{{ remaining }}</strong>
<span>{{ remaining === 1 ? " item" : " items" }} left</span>
</span>
<!-- 无关代码,暂时删除。 -->
<button class="clear-completed" @click="handleClick" v-show="todoCount > 0">Clear completed</button>
</footer>
</template>
接下来的改造是使用Vue-Router来完成All, Active和Completed的导航。如图:
从已有的路由代码中可以看出,三个路径:all,active和completed都用的是hash的方式。后续的开发中我们也会用vue-router的hash模式。但是路由的处理不会在Footer内部,而是在HomeView。所以,Footer里接收的也是一个从HomeView传过来的事件:
const emit = defineEmits<{
(e: "removeCompleted"): void;
+ (e: "handleNav", visibility: VisibilityType): void;
}>();
在Footer的template里可以这样使用:
<a
@click="navigateTo('all')"
:class="{ selected: $route.params.type === 'all' || !$route.params.type }"
>All</a
>
<router-link to="/active" :class="{ selected: $route.params.type === 'active' }">Active</router-link>
其他还是老样子。在HomeView里只需要知道路由的参数是什么,至于是用了emit传递事件还是直接用router-link标签都可以。
路由
在处理路由的问题上,笔者选择了Hash Mode而不是文档推荐的模式。Hash模式正好也是todoMVC使用的方式。关于路由模式具体可以点击直通车查看。
在router/index.ts里,添加三个子路径的路由:
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
redirect: { // *
path: "/all",
},
children: [
{
path: "/:type", // *
component: TodoList,
},
],
}
],
});
- 如果用户直接访问根目录,则会跳转到
all
下 - 如果用户访问了具体的todo类型,这些类型会成为路径的hash路径
但是,路径只需要配置一个:path:"/:type"
。三个类型的区分是通过路径参数来实现的。比如,在TodoList
组件里可以看到传入的type
是什么。
<!-- TodoList -->
<template>Todo List Type: {{ route.params.type }}</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
const route = useRoute();
</script>
把组件放到HomeView
里,显示出每次点击的不同的类型:
<template>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input class="new-todo" autofocus placeholder="What needs to be done?" @keyup.enter="addTodo" />
</header>
<router-view></router-view> <!-- TodoList会在这里显示 -->
<section class="main" v-show="todos.length">
<!-- 无关代码,暂时删除 -->
看到这里,vue-router的基本套路已经都体现出来了。
- 首先配置router,选择好用什么模式:H5模式、Hash模式和内存模式。本例用的是Hash模式,所以创建的时候用的是
createWebHashHistory
。 - 在使用到route值的地方,如本例的
TodoList
组件里,先使用useRoute
之后直接使用route.params.type
。在Composition API里使用useRoute
才能用到route
对象 - 在需要显示路由的组件的地方使用
<router-view></router-view>
。 - 在需要跳转不同路由的地方使用
<router-link></router-link>
。
至于跳转,可以使用上文提到的<router-link></router-link>
也可以使用composition API。
import { useRouter } from "vue-router";
const router = useRouter();
function handleNav(visibility: VisibilityType) {
router.push({ path: `/${visibility ?? "all"}` });
}
接下来需要完成TodoList
组件。
在TodoList
组件里,用户可以:
- 显示all、active和completed三个类型的todo
- 编辑一条Todo:包括标记为完成、修改文本
- 删除一条Todo
所有的这些功能都会放在service里实现,在组件内部调用。现在就会出现一个问题,对todo和todo列表数量造成的更改需要体现在TodoList组件,同时需要同步到Footer里。这就需要用到下面需要讲到的状态管理工具。
Pinia
Pinia是Vue的新一代的,官方支持的状态管理库。它比较不同的是,可以拆分成多个store互相调用。本例用不到这个功能。
Pinia的使用也非常的简单:
import type { TodoItem, VisibilityType, TodoError } from "./../typings";
import { /* services */} from "@/service/todoService";
import { defineStore } from "pinia";
interface TodoState {
todos: TodoItem[];
loading: boolean;
error?: TodoError;
}
export const useTodoStore = defineStore("todos", {
state: () =>
({
todos: [],
loading: false,
error: undefined,
} as TodoState),
getters: {
filteredTodos(state): (visibility: VisibilityType) => TodoItem[] | undefined {
return (visibility: VisibilityType) => getFilter(visibility)(state.todos);
},
},
actions: {
async getTodos() {
try {
this.loading = true;
const res = await getTodos();
this.todos = [...res];
this.loading = false;
} catch (e) {
this.loading = false;
this.error = e as TodoError;
}
},
async removeTodo(todo: TodoItem) {
try {
this.loading = true;
await removeTodo(todo);
this.todos = this.todos.filter((el) => el.id !== todo.id);
this.loading = false;
} catch (e) {
this.error = e as TodoError;
this.loading = false;
}
},
async removeCompletedTodos() {
try {
this.loading = true;
const ret = await removeCompleted();
this.todos = [...(ret?.removeCompleted ?? [])];
this.loading = false;
} catch (e) {
this.error = e as TodoError;
this.loading = false;
}
},
async addTodo(todo: TodoItem) {
const ret = await addTodo(todo);
this.todos = [...ret];
},
async editTodo(todo: TodoItem) {
await editTodo(todo);
},
async toggleAll() {
await toggleAll();
},
},
});
使用defineStore
方法可以定义一个store,第一个参数是store的名字,第二个参数是store的结构。里面包括了:state、actions和getters。
一个store保存了它定义范围内的状态:state,每个action会对state做出修改。使用了store的state的组件会收到这些变化,最后在组件上绘制出变化后的状态。getters是store的计算属性,接收state为参数返回你想要的结果,或者可以返回一个函数来接收外部的参数。
在本例中,state包括todo的列表,和对todo或者todo列表操作中的状态和操作中出现的错误。
todos: [],
loading: false,
error: undefined,
这里都是TypeScript的,类型也已经提前定义好了。
对todo操作的actions都定义在actions子对象里。异步方法直接用async-await
定义即可。每个action都是对一个service的方法的调用。并在调用前设置loading
为true,调用后设置为false。
现在我们就要在组件里使用store了。分为如下步骤:
// 1
import { useTodoStore } from "@/stores/todo";
// 2
const todoStore = useTodoStore();
const { todos, filteredTodos, loading } = storeToRefs(todoStore);
// 3
const remaining = computed(() => filteredTodos.value("active")?.length ?? 0);
const todoCount = computed(() => filteredTodos.value("all")?.length ?? 0);
// 4-1
onMounted(() => {
todoStore.getTodos();
});
// 4-2
function toggleAll() {
todoStore.toggleAll();
}
解析:
- 在组件里import已经定义好的store
useTodoStore
方法生成todoStore
实例- 用
filteredTodos
getter来接收todo类型参数返回remaining
和todoCount
。然后把他们传入Footer。<FooterVue @remove-completed="removeCompleted" @handle-nav="handleNav" :remaining="remaining" :todo-count="todoCount" />
4-1. 在组件加载完成后获取所有的todo
4-2. 定义一个方,调用store的action:toggleAll
。并在template里调用
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
:checked="remaining === 0"
@change="toggleAll"
/>
依葫芦画瓢,也可以完成TodoList
组件的这部分代码。在开发这些组件的过程中始终保持“Lift State Up”,把state和action的处理放在尽量靠近顶层的组件里(container),数据和action作为props和emits传入到子组件里。
前端的GraphQL
使用graphql-request来发出请求。同时,还有一个代码生成工具可以做为补充。只是这个生成代码的工具有点有意思。
比如在service里有删除一个todo的方法:
import request, { gql } from "graphql-request";
export async function removeTodo(todo: TodoItem) {
const query = gql`
mutation deleteTodo($todoId: Int!) {
removeTodo(id: $todoId) {
id
}
}
`;
const ret = await request(import.meta.env.VITE_SERVER_URL, query, {
todoId: todo.id,
});
}
写出需要执行的gql的mutation。然后传入request
方法里,同时第三个参数传入gql里需要用到的参数即可。
import.meta.env.VITE_SERVER_URL
是vite里配置的环境变量。
在开发的过程中,大家可以打开server的graphiQL来调整前端需要有用的gql语句。同时,也可以用来对比测试。
全文完
以上每一个内容Flask、PSQL、GraphQL、Prisma、Vue等都可以单独写一个详细的介绍。本例只是为了完成TodoMVC的改造适度使用了这些技术的某些常用的功能。笔者技术所限,