JavaScript长期以来一直是开发Web应用程序时最常用的语言之一。它可以用在前端,也可以在Node.js后端使用。
然而,JavaScript并非没有局限性,比如缺乏静态类型,这使得在编译时很难发现问题,并在运行时产生更难调试的错误。随着项目规模的增加,代码的可维护性和可读性也会降低。
这就是TypeScript发挥作用的地方。它是在JavaScript之上的一个额外层,但添加了静态类型。因为它是一个额外层,而不是一个单独的框架,所以它实际上在构建时使用一个转译器将TypeScript代码转换为JavaScript。因此,您可以在项目中继续使用任何JavaScript库。
但在应用层,当编写代码时,开发者会得到类型和类型检查。这意味着可以知道可以使用哪些数据类型而不会出现意外的变化。此外,由于受类型的限制,错误将在编码时或构建时引发,从而减少错误数量。
知道您有类型安全性的优势意味着可以专注于编写代码,并且通常会更加高效。
在这篇文章中,您将学习如何使用MongoDB Atlas(MongoDB的数据库即服务),通过使用Express创建的Web API来列出游戏,并利用TypeScript的面向对象功能。
为了跟随本教程,您需要在计算机上安装Node。它自带npm,可以方便地在项目中管理包。
您还需要创建一个MongoDB数据库。要在MongoDB中开始使用,最简单的方法是在MongoDB Atlas中创建一个免费的集群,这是MongoDB的完全托管、多云文档数据库服务。
本文重点介绍如何添加MongoDB并享受TypeScript的强大功能。为了帮助您更快地开始编码,我们已经在GitHub上创建了一个配套仓库。
默认的‘main’分支提供了跟随本教程所需的基本样板代码。但是,如果您想运行完成的版本,仓库中还有一个名为‘finish’的分支。
此项目已经配置了Express和TypeScript。在运行时,它将打印“Hello world!”到页面上。每个标题下的步骤将引导您添加MongoDB访问权限和创建模型。然后,在数据库级别添加模式验证之前,使用创建、读取、更新和删除(CRUD)操作测试您新创建的端点。
为了稍后连接到数据库,请按照以下步骤操作。
您需要做的第一件事是添加MongoDB npm包。在您的终端中选择项目根目录,使用以下命令安装MongoDB NodeJS驱动程序
npm install mongodb
配套仓库已经安装了dotenv包。此包允许从.env文件加载配置。将连接字符串与.env文件结合使用可以将用户密钥、连接字符串和其他私有配置设置与功能分离。将.env文件添加到.gitignore文件中是良好的实践,以避免泄露API密钥、连接字符串和其他私有配置设置。项目已经完成了这项工作,所以您不需要做。
在项目根目录下添加一个.env文件,并添加以下内容,用Atlas中的详细信息填充值字符串
DB_CONN_STRING=""
DB_NAME=""
GAMES_COLLECTION_NAME=""
您应该已经创建了数据库和MongoDB集群。但是,如果您需要帮助获取连接字符串,请参阅MongoDB文档。
完成后的.env文件应类似于以下内容。
DB_CONN_STRING="mongodb+srv://<username>:<password>@sandbox.jadwj.mongodb.net"
DB_NAME="gamesDB"
GAMES_COLLECTION_NAME="games"
请确保您的连接字符串已将任何模板值(如<password>
)替换为您在创建用户时设置的密码。
在TypeScript中,可以使用类或接口来创建模型,以表示我们的文档将看起来是什么样子。类可以定义对象应具有哪些属性以及这些属性的相应数据类型。这就像应用层面的模式。类还提供了创建该类的实例并利用面向对象编程的好处的能力。
为了使代码更清晰,我们将在src/目录下创建文件夹来存放相关文件。在src文件夹内创建一个新的“models”文件夹。
在这个文件夹内,创建一个名为game.ts的文件,并将以下大纲粘贴进去
// External dependencies
// Class Implementation
在这个阶段,您的src文件夹应该像以下图片所示
接下来,在“外部依赖”部分下添加
import { ObjectId } from "mongodb";
ObjectId 是一种唯一的 MongoDB 数据类型,用于每个文档的 '_id' 字段,作为唯一标识符并充当主键。
现在是我们创建类的时候了。在“类实现”标题下粘贴以下代码
export default class Game {
constructor(public name: string, public price: number, public category: string, public id?: ObjectId) {}
}
在这里,我们为游戏模型添加属性及其数据类型,以便利用 TypeScript 作为构造函数的一部分。这允许在定义属性的同时创建对象。id 属性后面有一个 ? 表示它是可选的。尽管 MongoDB 中的每个文档都有一个 id,但在代码级别并不总是存在,例如在创建文档时。在这种情况下,'_id' 字段在创建时自动生成。
我们现在在代码中表示了我们的数据模型,以便开发人员可以利用自动完成和类型检查。
现在我们需要创建一个与数据库通信的服务。这个类将负责配置连接。
在 src/ 下创建一个名为“services”的新文件夹,并在其中创建一个 database.service.ts 文件,并粘贴以下大纲
// External Dependencies
// Global Variables
// Initialize Connection
由于此服务将连接到数据库,因此需要使用 MongoDB NodeJS 驱动程序和 .env 配置。在“外部依赖”标题下粘贴以下内容
import * as mongoDB from "mongodb";
import * as dotenv from "dotenv";
我们想要从服务外部访问我们的集合,所以,在“全局变量”标题下,添加以下内容
export const collections: { games?: mongoDB.Collection } = {}
现在我们准备开始编写此服务中的关键函数。我们希望有一个可以调用的函数来初始化数据库连接,以便在代码中稍后与数据库通信时准备就绪。在“初始化连接”下粘贴以下内容
export async function connectToDatabase () {
dotenv.config();
const client: mongoDB.MongoClient = new mongoDB.MongoClient(process.env.DB_CONN_STRING);
await client.connect();
const db: mongoDB.Db = client.db(process.env.DB_NAME);
const gamesCollection: mongoDB.Collection = db.collection(process.env.GAMES_COLLECTION_NAME);
collections.games = gamesCollection;
console.log(`Successfully connected to database: ${db.databaseName} and collection: ${gamesCollection.collectionName}`);
}
这里发生了很多事情,所以让我们逐一分析。 dotenv.config();
读取 .env 文件,以便在调用 process.env 时可以访问值。.config() 调用为空,因为我们使用默认位置进行 .env 文件,即项目的根目录。
然后它创建一个新的 MongoDB 客户端,传递连接字符串,包括有效的用户凭据。然后它尝试连接到 MongoDB 数据库和 集合,这些名称在 .env 中指定,并将这些持久化到全局集合变量,以便外部访问。
现在我们有了与数据库通信的功能,是时候提供客户端使用 Express 进行通信的端点了,并执行 CRUD 操作。
为了保持代码整洁,我们将创建一个 路由器,它将处理对同一端点的所有调用,在本例中为 '/game'。这些端点也将与我们的数据库服务通信。
在 ‘/src’ 下创建一个名为 ‘routes’ 的新文件夹,并在该文件夹中创建一个名为 games.router.ts 的文件,并粘贴以下大纲
// External Dependencies
// Global Config
// GET
// POST
// PUT
// DELETE
在“外部依赖”下粘贴以下导入语句
import express, { Request, Response } from "express";
import { ObjectId } from "mongodb";
import { collections } from "../services/database.service";
import Game from "../models/game";
然后我们需要在编写端点之前设置我们的路由器,所以在“全局配置”下粘贴以下内容
export const gamesRouter = express.Router();
gamesRouter.use(express.json());
MongoDB 中,信息以 BSON 文档 的形式存储。BSON 是一种类似于二进制 JSON 的结构。它支持与 JSON 相同的数据类型,还有一些额外的数据类型,例如日期和原始二进制,以及更多数字类型,例如整数、长整数和浮点数。
因此,当创建或更新文档时,我们的应用程序能够接受JSON输入。然而,我们必须告诉我们的路由器使用Express内置的json解析中间件,这就是我们调用use(express.json());
的原因。
接下来,我们将开始为API上每个端点添加处理程序。
我们将首先添加的端点是我们的默认GET路由。
gamesRouter.get("/", async (_req: Request, res: Response) => {
try {
const games = (await collections.games.find({}).toArray()) as Game[];
res.status(200).send(games);
} catch (error) {
res.status(500).send(error.message);
}
});
稍后,您将看到我们如何配置应用程序将所有‘/games’流量发送到我们的路由器。但就目前而言,请知道因为我们在这个路由器内部,所以我们只需要指定‘/’来处理对其的调用。
在这里,我们在集合上调用find。find函数接收一个对象作为第一个参数,这是我们想要应用于搜索的过滤器。在这种情况下,我们想要返回集合中的所有文档,因此我们传递一个空对象。
实际上,find函数返回一个特殊类型,称为游标,它管理查询的结果。因此,我们将它转换为数组,这是一个基本的TypeScript数据类型,在代码库中更容易使用。由于我们知道它将匹配我们的Games模型,我们在该行还添加了额外的as Game[];
,这样我们就有了一个特定为Game对象的数组。
然后,我们将这个数组发送回前端以在屏幕上显示。这是Express内置的‘res’响应对象被使用的地方。我们发送一个状态码200,表示成功,以及游戏文档数组。当使用Postman等API客户端时,这非常有用。
接下来,我们将添加一个端点以GET特定的文档。
gamesRouter.get("/:id", async (req: Request, res: Response) => {
const id = req?.params?.id;
try {
const query = { _id: new ObjectId(id) };
const game = (await collections.games.findOne(query)) as Game;
if (game) {
res.status(200).send(game);
}
} catch (error) {
res.status(404).send(`Unable to find matching document with id: ${req.params.id}`);
}
});
这个端点看起来略有不同。‘:id’是一个路由参数,它在URL的该位置提供了一个命名参数。例如,我们在这里指定的路由看起来像'/game/<your document id>
',其中方括号中的模板id字符串将被替换为文档id。这使得它比查询参数更容易使用。
它接收id并使用它在一个我们构建的查询对象中。由于_id是ObjectId类型,我们创建一个新的ObjectId,传入字符串id以进行转换。然后我们调用findOne,传入该查询,这样我们就可以通过匹配该id的第一个结果来过滤结果,并将其转换为我们的Game模型。
然后,如果找到游戏对象,我们返回状态码200,否则返回404,即“未找到”,并向客户端返回错误信息。
Express和TypeScript使得处理向集合创建新文档的POST请求变得非常简单。在“POST”标题下粘贴以下内容
gamesRouter.post("/", async (req: Request, res: Response) => {
try {
const newGame = req.body as Game;
const result = await collections.games.insertOne(newGame);
result
? res.status(201).send(`Successfully created a new game with id ${result.insertedId}`)
: res.status(500).send("Failed to create a new game.");
} catch (error) {
console.error(error);
res.status(400).send(error.message);
}
});
在这里,我们通过解析请求体来创建我们的新游戏对象。然后我们使用insertOne方法在集合中创建单个文档,传入新的游戏。如果一个集合不存在,第一次写操作会隐式创建它。当我们在数据库中创建数据库时,也会发生相同的情况。数据库中的第一个结构会隐式创建。
然后我们进行一些简单的错误处理,根据插入的结果返回状态码和消息。
使用InsertMany一次性插入多个文档。
当请求对现有文档进行更新时,使用PUT方法。将代码粘贴在“PUT”标题下
gamesRouter.put("/:id", async (req: Request, res: Response) => {
const id = req?.params?.id;
try {
const updatedGame: Game = req.body as Game;
const query = { _id: new ObjectId(id) };
const result = await collections.games.updateOne(query, { $set: updatedGame });
result
? res.status(200).send(`Successfully updated game with id ${id}`)
: res.status(304).send(`Game with id: ${id} not updated`);
} catch (error) {
console.error(error.message);
res.status(400).send(error.message);
}
});
这与上面的POST方法非常相似。然而,我们还有你在GET中学习到的‘:id’请求参数。
与findOne函数一样,updateOne函数将查询作为第一个参数。第二个参数是另一个对象,在这个例子中,是更新过滤器。因为我们有一个完整的对象,我们不需要关心什么被更新了,什么没有被更新,所以我们传递了‘$set’,这是一个在文档中添加或更新所有字段的属性。
然而,如果这次失败,我们不是传递500错误,而是传递304,这意味着没有修改,以反映文档没有发生变化。
尽管在这里我们没有使用它,因为默认设置已经足够好,但该函数接受一个可选的第三个参数,即可选参数的对象。一个例子是upsert,如果设置为true,当请求更新不存在时,将创建一个新文档。您可以在我们的文档中了解更多关于updateOne和可选参数的信息。
最后,我们来到了删除。将以下代码粘贴在“Delete”标题下
gamesRouter.delete("/:id", async (req: Request, res: Response) => {
const id = req?.params?.id;
try {
const query = { _id: new ObjectId(id) };
const result = await collections.games.deleteOne(query);
if (result && result.deletedCount) {
res.status(202).send(`Successfully removed game with id ${id}`);
} else if (!result) {
res.status(400).send(`Failed to remove game with id ${id}`);
} else if (!result.deletedCount) {
res.status(404).send(`Game with id ${id} does not exist`);
}
} catch (error) {
console.error(error.message);
res.status(400).send(error.message);
}
});
与之前的读取函数没有太大不同。我们从一个id创建一个查询,并将该查询传递给deleteOne函数。查看我们的参考文档了解更多关于删除多个文档的信息。
如果能够被删除,则返回202状态。202表示已接受,因为我们只知道它已接受删除。否则,如果未删除,则返回400,如果文档找不到,则返回404。
哇!现在您已经有一个连接到数据库的服务,以及一个处理客户端请求并将它们传递到您的服务的路由器。但是,还需要最后一步来整合所有内容,那就是更新index.ts以反映我们的新服务和路由器。
将当前的导入语句替换为以下内容
import express from "express";
import { connectToDatabase } from "./services/database.service"
import { gamesRouter } from "./routes/games.router";
接下来,我们需要将app.get和app.listen调用替换为
connectToDatabase()
.then(() => {
app.use("/games", gamesRouter);
app.listen(port, () => {
console.log(`Server started at http://localhost:${port}`);
});
})
.catch((error: Error) => {
console.error("Database connection failed", error);
process.exit();
});
这首先调用connectToDatabase函数以初始化连接。然后,当完成时,只要它成功,它就会告诉应用将所有“/games”流量路由到我们的gamesRouter类,并启动服务器。
现在,是时候测试我们的方法了!首先,我们需要启动应用程序,所以请在您的CLI中输入以下内容以构建和运行应用程序
npm run start
这将在http://localhost:8080
启动应用程序,您可以使用您选择的API客户端(例如Postman)通过端点测试您的应用程序。
向http://localhost:8080/games
发送一个POST请求,在请求体中传递一个JSON对象,它定义了新游戏的字段。
您还需要在请求头中指定‘Content-Type’为‘application/json’。一旦您按下发送,您应该会收到我们之前在代码中设置的‘201 Created’状态。
您可以使用以下JSON片段或创建自己的
{
"name": "Fable Anniversary",
"price": 4.99,
"category": "Video Game"
}
向http://localhost:8080/games
发送一个GET请求。您不需要指定任何头或体。它将返回您的集合中的文档数组。该集合将只包含您在上一步骤中创建的文档。
从该列表中复制您文档的‘_id’值,我们现在将使用这个值来测试特定文档的 GET 请求。
向 http://localhost:8080/games/<您的文档id>
发送一个 GET 请求,以查看您的文档是否成功返回。
接下来,我们可以更新现有的文档以测试更新端点,因为我们已经从上一步中获得了文档id。
向 http://localhost:8080/games/<您的文档id>
发送一个 PUT 请求,确保您仍然在头部设置了内容类型为 application/json。在正文中,使用与创建文档时相同的详细信息,但更改某些内容,例如价格。
向 http://localhost:8080/games/<您的文档id>
发送一个 DELETE 请求。您应该得到一个 202 状态码。
您已经使用 TypeScript 编写了一个可工作的 API,该 API 与 MongoDB Atlas 和您在云中的数据库通信。太棒了!然而,TypeScript 及其优势,如静态类型,仅适用于开发者的应用层。
过去,开发者们使用一个名为 Mongoose 的库来帮助通过应用层模式解决这个问题。然而,这只会影响应用,而不会影响数据库,这意味着如果另一个项目或用户决定插入一个文档或使用不同的一组字段或数据类型更新现有的文档,您的代码将会出错。
因此,考虑在数据库级别添加验证很重要,这样就不会对外部更改数据造成影响,从而破坏您的 TypeScript 代码。
这就是 MongoDB 模式验证发挥作用的地方。这将允许我们将数据库限制为只接受我们模型中预期的字段和数据类型。
详细讨论模式验证超出了本文的范围,因为这是一个强大且广泛的话题。
然而,如果您想了解更多,有一篇关于 模式验证 的优秀文章。如果您想获得更实际的示例,我们有一个 JSON 模式教程 您可以遵循。
还有一篇关于为什么 您不再需要 Mongoose 的优秀文章,如果您想了解更多。
现在,我们将简单地应用一些 JSON 模式验证到现有的集合中,以确保所有未来的文档都符合我们预期的模型。
在 database.service.ts 中,在 const db: mongoDB.Db = client.db(process.env.DB_NAME; 之后添加以下内容:
await db.command({
"collMod": process.env.GAMES_COLLECTION_NAME,
"validator": {
$jsonSchema: {
bsonType: "object",
required: ["name", "price", "category"],
additionalProperties: false,
properties: {
_id: {},
name: {
bsonType: "string",
description: "'name' is required and is a string"
},
price: {
bsonType: "number",
description: "'price' is required and is a number"
},
category: {
bsonType: "string",
description: "'category' is required and is a string"
}
}
}
}
});
如果您以前从未见过 JSON 模式,这可能看起来有点令人畏惧,但请放心——让我们来谈谈正在发生的事情。
我们向数据库发送一个命令,告诉它使用在 process.env 中定义的名称来管理集合。然后,我们将一个模式对象传递给 validator 属性。此模式指定名称、价格和分类是必填字段。它还指定了它们在我们的 BSON 文档中的数据类型和描述。
我们发送的集合修改命令专门用于现有集合。但是,您也可以在创建集合时应用模式验证,通过将一个模式对象传递给 createCollection 命令的 validator 属性来实现。
新增了新命令后,下次您运行项目时,它将对您的集合应用此验证。如果您尝试创建或更新与此期望形状不同的文档,您将收到一条消息,表明文档验证失败。非常有用!
在本教程中,您学习了如何使用TypeScript与MongoDB Atlas结合使用,将强大的NoSQL文档数据库添加到您的应用程序中,并享受在开发者级别使用静态类型语言的好处。
我们还使用了Express来创建Web API,以便通过RESTful调用与我们的数据库进行通信。
然后我们在数据库级别对集合添加了模式验证,以便将模型应用于使用我们数据库的所有应用程序,而不仅仅是我们的应用程序。在企业级,多个项目使用同一个数据库是常见的,因此将此模式应用于您的集合可以节省很多错误和代码更新,以防有人尝试更改某些内容。
从您的TypeScript应用程序连接到MongoDB是通过MongoDB NodeJS Driver完成的。