Flask+GraphQL+Vue+Composition API+Pinia实现一个Todo App

304 阅读13分钟

本文带大家实现一个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的界面了。

image.png

在其中输入如下查询代码:

{
  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的知识点。在具体的开发实践中,还是要选择合适的技术才对。

然后,我们要把其中的各个部分拆分成不同的组件。

image.png

主要拆分成三部分:主题部分,也就是外满的壳或者说叫做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>

有四条需要我们处理的。

  1. todo的数量:todos.length
  2. 剩余的todo的数量:remaining
  3. 切换显示不同的todo类型列表:all、active和completed
  4. 清空所有已经完成的todo:removeCompleted

Vue的传递的props就只是props,传递事件需要定义emits,这和React颇为不同。这里使用的remainingtodos.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, ActiveCompleted的导航。如图:

image.png

从已有的路由代码中可以看出,三个路径:allactivecompleted都用的是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组件里,用户可以:

  1. 显示all、active和completed三个类型的todo
  2. 编辑一条Todo:包括标记为完成修改文本
  3. 删除一条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的结构。里面包括了:stateactionsgetters

一个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();
}

解析:

  1. 在组件里import已经定义好的store
  2. useTodoStore方法生成todoStore实例
  3. filteredTodos getter来接收todo类型参数返回remainingtodoCount。然后把他们传入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语句。同时,也可以用来对比测试。

image.png

image.png

全文完

以上每一个内容Flask、PSQL、GraphQL、Prisma、Vue等都可以单独写一个详细的介绍。本例只是为了完成TodoMVC的改造适度使用了这些技术的某些常用的功能。笔者技术所限,