如何用React、TypeScript和CockroachDB构建一个完整的Web应用程序

200 阅读10分钟

作为云原生网络开发人员,我们希望建立易于扩展的应用程序。不过,数据库往往是一个痛点。我们想要PostgreSQL等关系型数据库的结构和数据完整性,但我们也想要MongoDB等NoSQL数据库的易扩展性。直到最近,我们还不得不在其中选择一个。

幸运的是,CockroachDB提供了两个世界的最佳选择。它是一个强大的关系型数据库,完全支持SQL,并具有你所期望的企业级数据库的所有功能。它支持PostgreSQL的线程协议,所以你可以用大多数支持Postgres的工具和库来使用它。但这还不是全部:它提供了出色的可扩展性,所以你不再需要在关系型数据完整性和NoSQL可扩展性之间做出选择。而且,如果你使用CockroachDB Serverless,你就不必担心自己托管和扩展数据库的问题--这样你就可以把精力放在构建优秀的应用上。

本教程将告诉你如何从零开始,使用TypeScript、React、Prisma、Netlify无服务器函数和CockroachDB构建一个现代云原生网络应用。

CockroachDB & Typescript应用

我们将建立一个模拟的游戏排行榜。以下是最终应用程序的外观。

Table
Description automatically generated

我们将用TypeScript创建整个应用程序--包括后端和前端。

我们将使用React构建用户界面,并使用Create React App生成项目。该应用程序将有两个主要屏幕:上图所示的排行榜,以及一个让我们添加新的排行榜项目的管理屏幕。

Graphical user interface, application
Description automatically generated

我们将首先在CockroachDB无服务器数据库中存储所有游戏者和排行榜数据。我们将把整个应用托管在Netlify上,并使用Netlify的无服务器函数来读取和写入我们的数据库。最后,我们将使用出色的Prisma ORM,从TypeScript中为我们的数据提供类型安全的访问。

让我们潜心研究并开始吧如果你想看看这个应用程序的完整代码,你可以在GitHub上找到它。在本教程中,我们不会展示每一行代码,而是解释创建应用程序和数据库所需的步骤,然后强调你需要从资源库中理解的代码的关键部分。

应用开发的先决条件

在进行下一步之前,请确保你已经安装了Node.js的最新LTS版本。你可以在Node.js官方下载页面上找到Windows、macOS和Linux的安装程序。

在整个教程中,我假设你之前已经使用过React,并且你对构建你的React组件感到满意。如果你是React的新手,我推荐官方的React入门教程

最后,你应该知道并理解现代JavaScript。如果你已经知道TypeScript,也会有帮助,但如果你不知道也没关系。TypeScript是JavaScript的超集,所以你可以跟着它走,边走边学。但这不是一个TypeScript教程,所以如果你想深入了解TypeScript,我推荐你去看官方的TypeScript语言教程

创建项目

Netlify内置支持由Create React App生成的应用程序,所以我们将用它来生成我们的项目结构。打开一个终端或命令提示符,切换到你通常保存项目的目录,并运行以下命令。

npx create-react-app cockroachdb-typescript --template typescript

Create React App将需要几分钟的时间来运行。当它完成后,你会得到以下目录结构。

cockroachdb-typescript
├── node_modules
├── public
└── src

cockroachdb-typescript 将是我们的主项目目录--所以在本教程的剩余部分,当你运行终端命令时,确保你在 目录中。cockroachdb-typescript

Create React App还在publicsrc 子目录中生成了几个文件。我们将在稍后建立我们的应用程序的React用户界面时重新审视这些文件。但首先,让我们创建一个数据库和无服务器函数,让我们的用户界面可以与之互动。

创建一个CockroachDB无服务器数据库

如果我们想创建一个排行榜,我们就需要一个地方来存储我们的数据。我们将使用CockroachDB Serverless来保持简单。CockroachDB Serverless在云端启动了一个CockroachDB集群,所以我们不需要担心安装、维护或扩展我们的数据库。最重要的是,安装工作非常快速和简单。

  1. 如果你还没有,请注册一个CockroachDB云账户
  2. 登录到你的CockroachDB账户。
  3. 在集群页面上,选择创建集群。
  4. 在创建集群页面上,选择无服务器。
  5. 选择创建你的免费集群。

一旦你完成了账户注册并创建了一个新的集群,CockroachDB就会向你展示一个像下面这样的连接字符串。

Complete Webapp with React, TypeScript & CockroachDB

注意,CockroachDB只提供一次密码,所以在选择REVEAL_PASSWORD后要记下它。复制密码并将其保存在一个安全的地方,因为我们以后会用到它。

接下来,选择CockroachDB无服务器仪表板右上方的连接按钮。

Connect to CockroachDB

你会看到三组说明:一组是安装CockroachDB客户端,一组是安装CA证书,还有一组是连接到你的CockroachDB集群。确切的说明会根据你所运行的操作系统而有所不同。在Mac上,你会看到类似这样的内容。

Complete Webapp with React, TypeScript & CockroachDB

运行前两组命令,下载CockroachDB客户端和CA证书。保持第三条命令的可访问性--我们稍后会需要它,还有我们之前透露的密码。

创建数据库表和设置Prisma

创建了集群后,现在是时候设置我们的排行榜所需的表,并配置Prisma ORM。

Prisma带有一个迁移工具,可以自动创建和更新你的数据库表--但它还不兼容CockroachDB。不过没关系!我们可以自己创建我们需要的表。我们可以通过一个SQL脚本来创建我们需要的表。

你可以在已经完成的github项目中找到它,网址是database/schema.sql 。 下面是它的样子。

DROP DATABASE IF EXISTS leaderboard;
CREATE DATABASE leaderboard;
USE leaderboard;

CREATE TABLE players (
    id SERIAL,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    CONSTRAINT "players_pkey" PRIMARY KEY (id ASC)
);

CREATE TABLE player_scores (
    id SERIAL,
    player_id INTEGER NOT NULL,
    score INTEGER NOT NULL,
    CONSTRAINT "player_scores_pkey" PRIMARY KEY (id ASC),
    CONSTRAINT fk_player FOREIGN KEY (player_id) REFERENCES players(id)
);

CREATE INDEX player_scores_player_id on player_scores(player_id);
CREATE INDEX player_scores_score on player_scores(score);

INSERT INTO players (name, email) VALUES ('Test Player 1', 'test_player_1@example.com');
INSERT INTO players (name, email) VALUES ('Test Player 2', 'test_player_2@example.com');
INSERT INTO players (name, email) VALUES ('Test Player 3', 'test_player_3@example.com');
INSERT INTO players (name, email) VALUES ('Test Player 4', 'test_player_4@example.com');
INSERT INTO players (name, email) VALUES ('Test Player 5', 'test_player_5@example.com');

该脚本首先创建了一个新的数据库,然后创建了存储球员和比赛分数的表,接着创建了索引,以确保我们创建的应用程序能够快速查询和排序球员的分数。

请注意,除了创建表格外,我还添加了一些球员样本。我这样做是因为创建球员已经超出了典型排行榜应用程序的范围。大多数排行榜的重点是创建和显示现有球员的条目。

我们将使用以下命令来运行Cockroach SQL客户端并执行我们的脚本。我们以前见过它。这是我们之前在CockroachDB仪表板上查看我们的连接信息时看到的第三条命令。你所需要做的就是在结尾处添加-f schema.sql 。它看起来会像这样。

cockroach sql --url 'postgresql://**<your-db-username>:<your-db-password>**@free-tier.gcp-us-central1.cockroachlabs.cloud:26257/defaultdb?sslmode=verify-full&sslrootcert='$HOME'/.postgresql/root.crt&options=--cluster%3D**<your-database-name>**' -f schema.sql

注意,你需要在运行该命令之前插入你的数据库的实际用户名/密码/名称。

输出结果应该是这样的。

CREATE TABLE
Time: 98ms

CREATE TABLE
Time: 402ms

CREATE INDEX
Time: 1.164s

CREATE INDEX
Time: 819ms

这就是用CockroachDB创建一个游戏排行榜数据库的全部内容。

在我们使用Prisma之前,我们必须要求Prisma根据我们的数据库表生成自己的模式文件。

Prisma希望在你的项目的prisma 子目录下有一个叫做schema.prisma 的文件。你可以GitHub上找到这个完整的文件。如果你从头开始,你不需要整个文件。相反,你可以从前两个部分开始。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

这里,你可以看到Prisma期望在DATABASE_URL 环境变量中找到一个连接字符串。在我们项目的根目录下创建一个.env 文件是向Prisma提供它所期望的连接字符串的最简单的方法。.env 文件应该有一个单行。

DATABASE_URL= postgresql://<username>:<password>@<host>:<port>/<database>.leaderboard

你可以在CockroachDB无服务器仪表盘中选择 "连接"按钮,然后打开 "连接参数"标签,找到用户名、主机、端口和数据库值。

Graphical user interface, application
Description automatically generated

请注意,仪表板中的数据库名称将以.defaultdb 结尾。当在你的.env 文件中(以及在Netlify上)输入时,将.defaultdb 改为.leaderboard ,以确保我们的应用程序连接到我们之前运行schema.sql 时创建的排行榜数据库。

虽然一般来说,将.env 文件提交到源代码控制中不是一个好主意,但你可以在我们部署到Netlify后,通过在Netlify仪表板上设置一个环境变量,使连接字符串对Prisma可用。我们会在讨论这个问题时介绍。

一旦你把连接字符串添加到.env 文件中,在项目的根目录下运行以下命令。

npm install --save @prisma/client@3.6.0

npx prisma db pull

然后,Prisma将对数据库进行反省,并生成其模式的其余部分。

MacBook-Air-2018
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "angry-porcupine-3367.defaultdb", schema "public" at "free-tier.gcp-us-central1.cockroachlabs.cloud:26257"
Introspecting based on datasource defined in prisma/schema.prisma …

✔ Introspected 2 models and wrote them into prisma/schema.prisma in 3.30s

Run prisma generate to generate Prisma Client.

如果你现在看一下schema.prisma ,你会看到Prisma已经添加了它在内省过程中了解的模式信息。

model player_scores {
  id        BigInt  @id(map: "primary") @default(autoincrement())
  player_id BigInt
  score     BigInt
  players   players @relation(fields: [player_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_player")

  @@index([player_id], map: "player_scores_player_id")
  @@index([score], map: "player_scores_score")
}

model players {
  id            BigInt          @id(map: "primary") @default(autoincrement())
  name          String
  email         String
  player_scores player_scores[]
}

接下来,运行npx prisma generate 。Prisma将创建并提供针对你的数据库模式的定制TypeScript客户端的使用说明。

至此,我们已经创建了一个游戏排行榜数据库,并生成了一个完全类型安全的客户端来访问它。

创建无服务器函数并部署到Netlify

我们剩下最后一步:创建Netlify函数,使我们的React前端能够与CockroachDB数据库通信,然后将应用程序部署到Netlify。

让我们从安装TypeScript的Netlify函数辅助库开始。在项目的根目录下运行以下命令。

npm install --save @netlify/functions

如果我们把函数放在主项目目录的netlify/functions 子目录下,Netlify会自动识别我们的函数,所以我们就这样做吧。

如果你要从头开始创建你的函数,而不是使用GitHub上的代码,请在项目的根目录下创建一个netlify 子目录,然后在其中创建一个functions 目录。为排行榜应用程序创建三个TypeScript Netlify函数。getScores.ts,addScore.ts, 和getPlayers.ts, 如图所示。

cockroachdb-typescript

 ├── .netlify
 ├── database
 ├── netlify
 │   └── functions
 │       ├── addScore.ts
 │       ├── getPlayer.ts
 │       └── getScores.ts
 ├── node_modules
 └── prisma

让我们看看它们是如何工作的,首先是getScores.ts

import { Handler } from '@netlify/functions'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient();

const handler: Handler = async (event, context) => {
  // load all scores from the database and include player name
  // from the players table.
  const allScores = await prisma.player_scores.findMany({
    include: {
      players: {
        select: { 
          name: true
        }
      }
    },
    orderBy: {
      score: 'desc'
    }
  });

  return {
    statusCode: 200,
    body: JSON.stringify(allScores.map(score => (
      // flatten player name into score entry 
      { id: score.id, name: score.players.name, score: score.score }
    ))
    , (key, value) =>
      // need to add a custom serializer because CockroachDB IDs map to
      // JavaScript BigInts, which JSON.stringify has trouble serializing.
      typeof value === 'bigint'
        ? value.toString()
        : value 
    )
  }
}

export { handler }

这是我们的Netlify函数中最复杂的,所以让我们关注几个关键领域。首先,注意对prisma.scores.findMany 的调用,在这里我们使用 Prisma 为我们生成的类型安全的客户端。

我们还传递了一个对象作为参数,它有两个属性:includeorderByorderBy 的工作是预期的:它确保我们将收到一个按分数降序排序的顶级分数列表。include 属性指示Prisma将player_scores 表与球员表连接起来,这样我们就可以在排行榜上包括球员名字。

一旦getScores.ts ,通过JSON返回分数,我们就平铺该对象。否则,球员的名字会出现在一个嵌套对象中,像这样。

{
  id: "70354",
  score: 250
  "players": {
    "name": "Player 1"
  }
} 

扁平化数据后,该对象看起来像这样。

{
  id: "70354",
  score: 250, 
  name: "Player 1"
}

我们可以在我们的React前端轻松地处理这个嵌套对象。但在服务器端重新格式化数据是一个很好的做法,因为在生产应用中,服务器端和客户端的代码往往是由不同的团队编写的。我们的前端代码不应该需要知道像这样的低级实现细节。我们还提供了一个自定义的JSON序列化函数。这很重要,因为许多CockroachDB的ID字段足够大,JavaScript将其翻译成BigInt,这打破了JSON.strinfigy'的默认序列化行为。

接下来,让我们看一下addScore.ts 函数。

import { Handler } from '@netlify/functions'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient();

interface ScoreEntry {
  playerId: string,
  score: string
}

const handler: Handler = async (event, context) => {
  if(event.body) {
    const newScore = JSON.parse(event.body) as ScoreEntry;
    await prisma.player_scores.create({
      data: {
        player_id: BigInt(newScore.playerId),
        score: parseInt(newScore.score)
      },
    });

    return {
      statusCode: 200,
      body: JSON.stringify(newScore)
    };
  }

  return {
    statusCode: 500
  };
}

export { handler }

我们从我们的React前端将发送的POST 请求的正文中提取球员的ID和得分。注意,我们已经创建了一个名为ScoreEntry 的TypeScript接口,让编译器知道我们在解析请求体时将得到的数据的形状。当我们调用prisma.player_scores.create 时,我们也将播放器的ID转换回BigInt。

最后,我们有getPlayers.ts 。它读取了数据库中的所有球员,所以我们的React前端将能够生成一个球员列表,让我们选择在管理页面上为哪个球员添加新的排行榜分数。

import { Handler } from '@netlify/functions'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient();

const handler: Handler = async (_event, _context) => {
  // load all players from the database
  const allPlayers = await prisma.players.findMany();
  return {
    statusCode: 200,
    body: JSON.stringify(allPlayers, (_key, value) =>
      // need to add a custom serializer because CockroachDB IDs map to
      // JavaScript BigInts, which JSON.stringify has trouble serializing.
      typeof value === 'bigint'
        ? value.toString()
        : value 
    )
  }
}

export { handler }

使用React和Typescript创建一个前端

我们的排行榜需要一个用户界面。早些时候,我们使用Create React App来生成我们的项目目录,所以我们需要的许多部分都已经到位了。

我们将使用React Router在我们的应用程序中的页面之间进行导航。现在通过在你的项目根目录下运行以下程序来安装React Router和其TypeScript类型定义。

npm install --save react-router-dom@6.1.1

在GitHub仓库中,我在项目的public/index.html 文件中加入了Bootstrap 5,以使UI更具视觉吸引力,在public/index.html<head> 部分加入了以下一行。

<link 
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">

当然,如果你愿意,你也可以使用Tailwind或其他任何CSS框架。

设置React Router已经超出了本文的范围,如果你对它的工作原理感到好奇,可以看看GitHub上的成品应用程序的src/App.tsx 文件。

打开src 子文件夹,你会看到以下结构。

├── src
   ├── App.css
   ├── App.test.tsx
   ├── App.tsx
   ├── index.css
   ├── index.tsx
   ├── logo.svg
   ├── react-app-env.d.ts
   ├── reportWebVitals.ts
   └── setupTests.ts

我们的Leaderboard的两个关键部分在src目录中的两个新组件文件中:Leaderboard.tsxAdmin.tsx

让我们先看一下Leaderboard.tsx

import React, { useState, useEffect } from 'react';
import { Link } from "react-router-dom";

interface Leader {
    id: number,
    name: string,
    score: number
}

function renderLeader(leader: Leader) {
    return <tr key={leader.id}>
        <td>{leader.name}</td>
        <td>{leader.score}</td>
    </tr>
}

export default function Leaderboard() {

    const [leaders, setLeaders] = useState([] as Leader[]);

    useEffect(() => {
        fetch('/.netlify/functions/getScores')
            .then(response => response.json() as Promise<Leader[]>)
            .then(data => setLeaders(data));
    }, []);

    return <>
        <h2>Leaderboard</h2>
        {leaders.length === 0 ? 
            <div>No leader scores to display. Would you like to <Link to="admin">add one</Link>?</div>
        :
            <table className="table leader-table">
                <thead>
                    <tr>
                        <th>Name</th>
                        <th>Score</th>
                    </tr>
                </thead>
                <tbody>
                    {leaders.map(l => renderLeader(l))}
                </tbody>
            </table>
        }
    </>
}

我们首先创建一个Leader 接口,让TypeScript知道排行榜条目是什么样子的。然后,renderLeader 辅助函数为排行榜表生成行。

排行榜的其余部分是一个标准的React函数组件。注意useEffect 钩子,因为它是使该组件工作的部分。在这种情况下,该钩子使用浏览器内置的Fetch API来调用我们之前创建的Netlify函数,加载排行榜的分数,并通过调用setLeaders 来更新排行榜的状态。这反过来又导致React重新渲染组件并显示排行榜。

接下来,让我们看看Admin.tsx 组件,它使添加新的排行榜条目成为可能。

import React, {useEffect, useState} from 'react';

interface Player {
    id: string,
    name: string,
}

export default function Admin() {
    const [players, setPlayers] = useState([] as Player[]);
    const [score, setScore] = useState("");
    const [message, setMessage] = useState("");
    const [selectedPlayer, setSelectedPlayer] = useState<Player | undefined>(undefined);
    
    useEffect(() => {
        fetch('/.netlify/functions/getPlayers')
            .then(response => response.json() as Promise<Player[]>)
            .then(data => {
                setPlayers(data);
                if(data && data.length > 0) {
                    setSelectedPlayer(data[0]);
                }
            });
    }, []);

    const selectPlayer = (players: HTMLSelectElement) => {
        setSelectedPlayer({
            id: players.value,
            name: players.options[players.selectedIndex].text
        });
    }

    const addScore = () => {
        const scoreEntry = {
            playerId: selectedPlayer?.id,
            score: score
        }

        fetch("/.netlify/functions/addScore",
            {
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json'
                },
                method: "POST",
                body: JSON.stringify(scoreEntry)
            })
            .then(function(res){
                setMessage(`Leaderboard score of ${score} added for player ${selectedPlayer?.name}`);
            })
            .catch(function(res){
                setMessage(`Unable to add score for player ${selectedPlayer?.name}`);
            })
    }

    return <>
        <h2>Admin</h2>
        <h4>Add a Leaderboard Entry</h4>
        {players.length === 0 ?
            <div>Loading Players...</div>
            :
            <>
            <div className="mb-3 score-entry">
                <label>Player</label>
                <select
                    className="form-select"
                    aria-label="player selection"
                    value={selectedPlayer?.id}
                    onChange={e => selectPlayer(e.target)}>
                    {players.map(p => <option key={p.id} value={p.id}>
                        {p.name}
                    </option>)}
                </select>
            </div>
            <div className="mb-3 score-entry">
                <label>Score</label>
                <input type="text"
                       className="form-control"
                       aria-label="score entry"
                       value={score}
                       onChange={e => setScore(e.target.value)}/>
            </div>
            <div>
                <button className="btn btn-primary" onClick={addScore}>Add Score</button>
            </div>
            </>
        }
        <div className="admin-message">{message}</div>
    </>
}

这个组件有点复杂,但没有什么是我们不能处理的。它的开始与排行榜组件类似:通过调用一个Netlify函数,在一个效果钩中加载数据并更新React组件。

一旦玩家列表加载完毕,该组件将显示一个选择框和一个文本框,允许应用程序的用户选择一个玩家,输入一个分数,然后按下一个按钮,通过addScore 函数将该玩家的排行榜分数条目添加到数据库中。这个函数接收页面上输入的数据,并使用Fetch API来调用我们之前检查过的后端Netlify函数。

我们唯一缺少的是认证。在这里,我们省略了它以保持简单。但在生产应用中,你会希望使用类似Netlify Identity的东西来防止陌生人在你的排行榜上添加虚假的条目。

将应用程序部署到Netlify

现在剩下的就是部署到Netlify了。虽然本教程中没有一个完整的Netlify指南,但有几个简单的步骤可以让你快速启动和运行。

首先,把你写的所有代码--或者如果你愿意,就把这个项目的完整代码分叉--推送到你自己的GitHub仓库。然后,使用GitHub登录到Netlify

登录后,你会在Netlify的仪表板上看到一个从Git上新建网站的按钮。

选择这个按钮可以直接从你的GitHub仓库创建一个网站。它甚至会运行Create React App来构建你的前端,然后自动部署你的Netlify功能。

一旦你的应用程序启动并运行,Netlify将向你显示访问它的URL。

Graphical user interface, website
Description automatically generated

还有一些我们需要做的事情--还记得我之前让你为Prisma设置的DATABASE_URL 环境变量吗?我们需要一种方法来为我们部署在Netlify上的函数提供这个变量。

Netlify让这一切变得简单。在你的应用程序的仪表板上,选择网站设置,然后在左边的菜单中,选择构建和部署,然后选择环境。这可以让你设置DATABASE_URL 环境变量。

Graphical user interface, text, application
Description automatically generated

输入你先前在设置Prisma模式时使用的相同连接字符串。

还有最后一步。Netlify在部署我们的应用程序时,将环境变量捆绑到每个函数中。然而,Netlify在我们设置DATABASE_URL 环境变量之前就部署了我们的应用程序,所以如果我们现在尝试运行我们的应用程序,我们会得到一个难看的错误信息。幸运的是,我们所要做的就是触发一个新的部署。

在我们Netlify应用程序的仪表板上,点击生产部署。然后,当下一个屏幕加载时,点击触发部署的下拉菜单,选择部署网站。

等待部署完成,然后导航到你的应用程序的Netlify URL。你还不会看到任何球员的分数,所以点击导航栏中的管理链接并添加一些。

一旦你部署到Netlify,你也可以使用Netlify CLI的netlify dev 命令在本地运行你的整个应用程序,这样你就不需要部署来测试无服务器功能的变化。

总结

就这样吧!虽然有很多模板需要完成,但我们现在已经可以在CockroachDB的支持下构建出色的React应用了,而且最重要的是,我们使用的是无服务器函数和无服务器数据库,所以我们不必担心管理或扩展服务器的问题。

如果你还没有,为什么不注册一个免费的CockroachDB无服务器账户,用它来开始构建你的下一个伟大的应用呢?