使用Next.js和StepZen进行实战,开发GraphQL的现代化应用

首先

通过使用Next.js和StepZen来开发待办事项应用程序,本资料旨在提供GraphQL在构建现代Web应用程序时的方法。希望对选择使用GraphQL时的技术选择提供参考。即使不使用StepZen,也可以参考使用Apollo Client和GraphQL Code Generator开发Next.js应用程序的方法。

目标人群

    • Web サービスを迅速に立ち上げたい方

 

    • GraphQL によるモダンなアプリ開発に関心がある方

 

    Web サービスの技術選定を検討中の方

先决条件

参与者需要具备基本的Web应用开发知识。

这个实操的内容

做的事情 (zuò de

    • Next.js と StepZen での todo アプリ作成

 

    • StepZen を利用しての GraphQL API サーバーの高速構築

 

    • GraphQL Code Generator でのクライアントコードの自動生成

 

    Apollo Client を使用した GraphQL のクライアント実装

不做的事情

    • React/Next.js や StepZen の詳細説明

 

    • 認証・認可やセキュリティ関連の実装

 

    • エラーハンドリングの実装

 

    Vercel を利用した Web アプリのデプロイ

请参考我之前写的文章关于StepZen的介绍(GraphQL/StepZen 手把手教程 #1 – 知乎)。

创建应用程序

完成此实践后,您将创建一个具备以下功能的“待办事项”应用程序。

    • Todo 一覧表示: すべての todo アイテムを一覧できるページ。

 

    • Todo 追加機能: 新しい todo アイテムを追加できるフォーム。

 

    • Todo 編集機能: 既存の todo アイテムの完了・未完了を変更する機能。

 

    • Todo 削除機能: 任意の todo アイテムを削除することができるボタン。

 

    データベースとの連携: ユーザーが入力した todo アイテムは PostgreSQL に保存され、アプリケーションの再起動後もデータは維持されます。

完成的应用程序的形象

image.png

此外,您可以通过以下的 GitHub 链接访问源代码,请参考:optimisuke/todoapp-nextjs-stepzen:使用 Next.js 和 StepZen 的待办事项应用程序。

应用程序的配置

Screenshot 2023-08-21 at 22.03.43.png

使用的技术和服务

    • 開発言語・フレームワーク: Node.js, Next.js, React, TypeScript

 

    • データベース: PostgreSQL

 

    • ツール & ライブラリ: GraphQL Code Generator, Apollo Client, MUI

 

    マネージドサービス: StepZen (RDB や REST API を GraphQL API に変換), Neon (PostgreSQL のマネージドサービス)

简要流程

我們將按照以下步驟進行實作。

    1. 用Node.js安装

 

    1. 使用Next.js/React构建todo应用界面

 

    1. 使用PostgreSQL/Neon构建todo数据库

 

    1. 使用StepZen构建GraphQL API服务器

 

    1. 使用GraphQL Code Generator自动生成客户端代码

 

    从Next.js/React调用GraphQL API

详细说明的步骤

1. 安装 Node.js

请从以下网址下载安装程序并安装。

下载 | Node.js

还有其他的方法可以使用Volta或Homebrew进行安装。请根据您自己的环境进行安装。

使用Next.js/React构建待办事项应用程序界面

接下来,我们将使用Next.js/React来构建应用程序界面。在这里,尚未调用后端API。

首先,在工作文件夹中使用create-next-app工具来建立环境。

npx create-next-app@latest --typescript

会有一些互动问题,但在默认设置下没有问题。
在工作目录中创建一个名为my-app的文件夹,当进行前端(Next.js / React)工作时,请在my-app文件夹中运行命令。
为了进行操作确认,请运行以下命令。

cd my-app
npm run dev

请在浏览器中访问 http://localhost:3000/,您将看到以下样式的 Next.js 初始界面,请进行确认。如有需要,请使用 npm run dev 命令并在需要时按下 Ctrl-C 终止。

image.png

然后,安装我们本次要使用的软件包。
主要选择了与 MUI 相关的内容。

npm install @mui/material @emotion/react @emotion/styled @mui/icons-material

然后,删除app文件夹,创建pages文件夹和components文件夹。接下来,创建以下4个文件。

    • pages/index.tsx

 

    • components/TodoList.tsx

 

    • components/TodoInput.tsx

 

    components/TodoItem.tsx

就四个文件的内容来说,我们从这里开始解释。
首先,作为顶层页面,在下面的文件中显示标题和 TodoList 组件。
我们使用了 MUI 组件来调整外观。

import { Typography, Container } from "@mui/material";
import React from "react";
import TodoList from "../components/TodoList";

const IndexPage: React.FC = () => {
  return (
    <Container maxWidth="sm">
      <Typography variant="h4" align="center" gutterBottom>
        ToDo List
      </Typography>
      <TodoList />
    </Container>
  );
};

export default IndexPage;

接下来,在TodoList组件中,我们将展示TodoInput组件和TodoItem组件的列表。另外,还需使用state来管理任务,并编写添加、复选框切换和删除的函数。
添加函数将传递给TodoInput组件,而切换和删除的函数将传递给TodoItem组件。

import { List } from "@mui/material";
import React, { useState } from "react";
import TodoItem from "./TodoItem";
import TodoInput from "./TodoInput";

type Task = {
  task: string;
  completed: boolean;
};

const TodoList: React.FC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  const addTask = (task: string) => {
    setTasks([...tasks, { task, completed: false }]);
  };

  const toggleTask = (index: number) => {
    const newTasks = [...tasks];
    newTasks[index].completed = !newTasks[index].completed;
    setTasks(newTasks);
  };

  const deleteTask = (index: number) => {
    const newTasks = [...tasks];
    newTasks.splice(index, 1);
    setTasks(newTasks);
  };

  return (
    <div>
      <TodoInput addTask={addTask} />
      <List>
        {tasks.map((task, index) => (
          <TodoItem
            key={index}
            task={task.task}
            completed={task.completed}
            toggleTask={() => toggleTask(index)}
            deleteTask={() => deleteTask(index)}
          />
        ))}
      </List>
    </div>
  );
};

export default TodoList;

TodoInput 组件使用表单来执行添加操作。同时,它将输入的字符串作为状态进行管理,并进行显示。

import { TextField, Button, Box } from "@mui/material";
import React, { useState } from "react";

type TodoInputProps = {
  addTask: (task: string) => void;
};

const TodoInput: React.FC<TodoInputProps> = ({ addTask }) => {
  const [task, setTask] = useState("");

  const submitTask = (e: React.FormEvent) => {
    e.preventDefault();
    if (task === "") return;
    addTask(task);
    setTask("");
  };

  return (
    <form onSubmit={submitTask}>
      <Box display="flex" justifyContent="center" gap="10px" p={2}>
        <TextField
          label="New task"
          variant="outlined"
          value={task}
          onChange={(e) => setTask(e.target.value)}
        />
        <Button type="submit" variant="contained" color="primary">
          Add
        </Button>
      </Box>
    </form>
  );
};

export default TodoInput;

在 TodoItem 组件中,我们展示了复选框、todo 任务的标题和删除图标。

import {
  ListItem,
  ListItemText,
  IconButton,
  ListItemSecondaryAction,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";

type TodoItemProps = {
  task: string;
  completed: boolean;
  toggleTask: () => void;
  deleteTask: () => void;
};

const TodoItem: React.FC<TodoItemProps> = ({
  task,
  completed,
  toggleTask,
  deleteTask,
}) => {
  return (
    <ListItem>
      <IconButton edge="start" color="inherit" onClick={toggleTask}>
        {completed ? <CheckCircleOutlineIcon /> : <RadioButtonUncheckedIcon />}
      </IconButton>
      <ListItemText
        primary={task}
        style={{ textDecoration: completed ? "line-through" : "none" }}
      />
      <ListItemSecondaryAction>
        <IconButton edge="end" color="inherit" onClick={deleteTask}>
          <DeleteIcon />
        </IconButton>
      </ListItemSecondaryAction>
    </ListItem>
  );
};

export default TodoItem;

请使用以下命令进行操作确认。

npm run dev

当连接到 http://localhost:3000 时,我认为会显示以下屏幕。

image.png
image.png

使用PostgreSQL/Neon构建todo数据库。

到目前为止,我已经只用前端创建了一个 Todo 应用程序。从下一步开始,我将准备数据库并设置以便能够永久保存数据。

Neon 是一个针对 PostgreSQL 的托管服务。虽然有一些限制,但在免费额度下非常容易使用。

请按照以下步骤逐步创建数据库。

image.png
image.png
image.png
image.png
image.png
image.png
image.png

使用下面的 SQL 创建表。将 SQL 复制到之前的 SQL 编辑器画面上,并按下 Run 按钮执行 SQL。
这次,表只包含基本信息。同时,我们决定使用 UUID 作为 id。

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE todos (
  id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  completed BOOLEAN DEFAULT false NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

稍后,为了确认表中的内容,也会将数据存入。与之前一样,将使用SQL编辑器执行以下SQL语句。

INSERT INTO todos (title, completed)
VALUES ('新しいToDo', false);

另外,我们将注册触发器,以便自动更新 update_at。
虽然有几个选项可以在何处生成 update_at 的值,但由于后端服务器(API服务器)在 StepZen 上自动生成逻辑不太容易,而且前端(客户端)可能依赖于用户环境,因此我们决定在数据库中进行更新。

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_name_before_update
BEFORE UPDATE ON todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
image.png

现在,数据库的构建已经完成。接下来,我们将使用StepZen来将数据库转化为GraphQL API。

使用StepZen构建GraphQL API服务器。

StepZen是一项能够将数据库等简单部署为GraphQL API服务器的云服务。我们可以通过浏览器上的仪表板和命令行界面来进行操作。首先需要创建账号并安装命令行工具,然后使用命令行来构建GraphQL API服务器。

首先,让我们访问StepZen的官方网站并创建一个账户。

image.png

接下来,我们将安装StepZen CLI。
然后,在Next.js/React的my-app文件夹旁边创建一个名为api的文件夹,并在其中进行操作。

mkdir api
cd api

使用StepZen CLI,您可以直接从本地计算机访问StepZen服务。要安装CLI,请运行以下npm命令(假设已安装Node.js)。添加-g参数以进行全局安装,安装命令可在任何位置运行,不会有问题。

npm install -g stepzen

如果发生包含”permission denied”错误的情况,可以考虑是文件夹权限问题。请尝试使用”sudo npm install -g stepzen”命令来解决。

安装完成后,您可以使用以下命令登录到StepZen。登录命令可以在任何位置执行,不会有任何问题。

stepzen login

请在下面的提示框中输入StepZen仪表板的密钥图标中获取的账户名称和管理员密钥。

What is your account name?: ACCOUNT_NAME
What is your admin key?: ADMIN_KEY
image.png

通过这一步骤,StepZen的设置已经完成。

接下来,执行以下命令以生成GraphQL模式和用于StepZen构建的配置文件。请在创建的名为api的StepZen文件夹内执行该命令。

stepzen import postgresql

我们要按照以下对话要求输入必要的项目。
我们将输入StepZen的用户信息和终端点名称,以及在Neon中确认的连接信息。在Neon中,连接信息被记载为postgres://username:password@host/databasename,所以请将username、password、host、databasename复制到问题中,并填写每个信息。
另外,将问题有关架构的回答设置为默认的”default”,最后一个问题的回答设置为”Yes”,我们输入”y”。

$ stepzen import postgresql
? What would you like your endpoint to be called? api/todos

stepzen import postgresql - introspect a PostgreSQL database and extend your GraphQL schema with the types, queries and mutations for accessing it through a StepZen API.

? What is your host? xxx.example.com
? What is the username? optimisuke
? What is the password? [hidden]
? What is your database name? neondb
? What is your database schema (leave blank to use defaults)?
? Automatically link types based on foreign key relationships using @materializer
 (https://stepzen.com/docs/features/linking-types) Yes
Starting... done
Successfully imported postgresql data source into your GraphQL schema

接下来,我们将对postgresql/index.graphql进行修改。
我们将对插入操作进行修改,使id和update_at不再必需。
title项被设为必需,而completed项则是可选的。
我们考虑了completed项不存在的情况,并使用了SQL函数COALESCE。
以下是修改前后的摘录部分。

在原有的变异周围(第54行,类型为Mutation的中间部分)

  insertTodos(
    id: ID!
    completed: Boolean
    created_at: DateTime
    title: String!
    updated_at: DateTime
  ): Todos
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "todos"
      dml: INSERT
      configuration: "postgresql_config"
    )

改动后

  insertTodos(title: String!, completed: Boolean): Todos
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      INSERT INTO todos (title, completed) VALUES (
        $1,
        COALESCE($2, false)
      )
      RETURNING *
      """
      configuration: "postgresql_config"
    )

当准备好后,请在api文件夹中使用以下命令进行部署。

stepzen start

每次在修改保存模式下执行部署的命令。如有需要,请使用Ctrl-C停止。您可以在StepZen仪表板的资源管理器界面上确认已创建指定模式的API。

image.png

现在,GraphQL API已经构建完成。接下来,我们将通过修改客户端的Next.js/React代码来实现调用GraphQL API的功能。

使用GraphQL Code Generator生成客户端代码。

准备获取所需数据的查询,并使用GraphQL Code Generator生成客户端代码。

在接下来的步骤中,我们将进入第二章中创建的my-app文件夹,并开始进行操作。

cd my-app

首先,您在StepZen仪表板上进行操作并准备查询。
创建一个执行CRUD操作的查询,将文件放在在第2章中创建的my-app文件夹的components文件夹中。

query getTodos {
  todosList {
    id
    title
    completed
    created_at
    updated_at
  }
}

mutation updateTodo($id: ID!, $completed: Boolean, $title: String) {
  updateTodos(id: $id, completed: $completed, title: $title) {
    completed
    created_at
    id
    title
    updated_at
  }
}

mutation insertTodo($title: String!, $completed: Boolean) {
  insertTodos(title: $title, completed: $completed) {
    completed
    created_at
    id
    title
    updated_at
  }
}

mutation deleteTodo($id: ID!) {
  deleteTodos(id: $id) {
    completed
    created_at
    id
    title
    updated_at
  }
}

下一步是安装GraphQL Code Generator所需的东西。

npm install graphql @graphql-codegen/client-preset
npm i -D @graphql-codegen/cli

准备设置文件。根据查询的.graphql文件和StepZen上的模式文件,生成包含类型信息等代码的./src/gql/文件夹。

import { CodegenConfig } from "@graphql-codegen/cli";

import * as dotenv from "dotenv";
dotenv.config();
const apiKey = process.env.NEXT_PUBLIC_API_KEY || "invalid-api-key";
const uri = process.env.NEXT_PUBLIC_API_URI || "invalid-api-uri";

const config: CodegenConfig = {
  schema: [
    {
      [uri]: {
        headers: {
          Authorization: `Apikey ${apiKey}`,
        },
      },
    },
  ],
  documents: ["./**/*.graphql"],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    "./src/gql/": {
      preset: "client",
    },
  },
};

export default config;

将以下内容写入.env文件中,包括的是模式的URL和API密钥。

NEXT_PUBLIC_API_URI=https://xxx.stepzen.net/api/todos/__graphql
NEXT_PUBLIC_API_KEY=xxx::stepzen.net+1000::yyy

您可以从StepZen的下面界面获取API密钥。

image.png
image.png

将自动生成命令的快捷方式添加到package.json的scripts中。

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "codegen": "graphql-codegen-esm --config codegen.ts"
  },

使用以下命令进行自动生成操作。

npm run codegen

确认在my-app/src/gql/中生成了代码。

6. 使用 Next.js/React 发起 GraphQL API 调用

使用从 Next.js/React 代码自动生成的客户端,调用使用 StepZen 构建的 GraphQL API。

先安装所需的软件包。

npm install @apollo/client

首先,我们将添加一个pages/_app.tsx文件,并在其中添加Apollo Client,以便能够使用它。
API密钥和GraphQL API的模式URI将从.env文件中获取,与congen.ts相同。

import { ApolloProvider } from "@apollo/client";
import { AppProps } from "next/app";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const apiKey = process.env.NEXT_PUBLIC_API_KEY || "invalid-api-key";
const uri = process.env.NEXT_PUBLIC_API_URI || "invalid-api-uri";

const httpLink = new HttpLink({
  uri: uri,
});
const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      authorization: `Apikey ${apiKey}`,
    },
  };
});

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: authLink.concat(httpLink),
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

另外,我们将对 TodoList.tsx 进行如下更改。使用 @apollo/client 的 useQuery 和 useMutation 来访问 GraphQL API,同时指定由 GraphQL Code Generator 生成的对象作为参数。此外,在添加、修改和删除时,我们调用 update 函数来更新从查询中获取的 todo 数组的缓存。这段代码可能看起来有点复杂,但请参考 Apollo 的官方文档(Apollo GraphQL Docs)进行确认。

import { List } from "@mui/material";
import React from "react";
import TodoItem from "./TodoItem";
import TodoInput from "./TodoInput";
import { useMutation, useQuery } from "@apollo/client";
import {
  DeleteTodoDocument,
  GetTodosDocument,
  InsertTodoDocument,
  UpdateTodoDocument,
} from "@/src/gql/graphql";

const TodoList: React.FC = () => {
  const { data, loading, error } = useQuery(GetTodosDocument);

  const [insertTodo] = useMutation(InsertTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      cache.writeQuery({
        query: GetTodosDocument,
        data: {
          todosList: [...existingData?.todosList!, data?.insertTodos!],
        },
      });
    },
  });
  const addTask = (task: string) => {
    insertTodo({ variables: { title: task } });
  };

  const [updateTodo] = useMutation(UpdateTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      const updatedTodos = existingData?.todosList?.map((todo) =>
        todo?.id === data?.updateTodos?.id ? data?.updateTodos! : todo
      );
      cache.writeQuery({
        query: GetTodosDocument,
        data: { todosList: updatedTodos },
      });
    },
  });
  const toggleTask = (index: string, completed: boolean) => {
    updateTodo({ variables: { id: index, completed: !completed } });
  };

  const [deleteTodo] = useMutation(DeleteTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      const updatedTodos = existingData?.todosList?.filter(
        (todo) => todo?.id !== data?.deleteTodos?.id
      );
      cache.writeQuery({
        query: GetTodosDocument,
        data: { todosList: updatedTodos },
      });
    },
  });
  const deleteTask = (index: string) => {
    deleteTodo({ variables: { id: index } });
  };

  return (
    <div>
      <TodoInput addTask={addTask} />
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      <List>
        {data?.todosList?.map((task, index) => (
          <TodoItem
            key={index}
            task={task?.title!}
            completed={task?.completed!}
            toggleTask={() => toggleTask(task?.id!, task?.completed!)}
            deleteTask={() => deleteTask(task?.id!)}
          />
        ))}
      </List>
    </div>
  );
};

export default TodoList;

以上是,Next.js/React 代碼的修改已完成。
請使用下面的命令執行並進行操作確認。

npm run dev
image.png

尽管首次版本与未调用GraphQL API的版本相同,并且在从Neon的Tables中查看数据时,可以看到数据确实被持久保存了。总之,本次工作坊已经结束了。

最后

我相信通过这个实践项目,我们可以学习到使用最先进的技术,如Next.js、GraphQL和StepZen等,来开发现代化的Web应用程序的方法。通过按照实际步骤构建应用程序并体验整个流程,我们将能够在未来的技术选择和项目启动中受益。如果有任何问题或建议,请在评论栏中提出。谢谢!

广告
将在 10 秒后关闭
bannerAds