GraphQL 是构建 API 的一种强大且高效的方式。客户端以类似查询数据库的方式向 API 发起查询,该 API 仅返回他们请求的数据,常常能减少响应负载并改善响应时间。在当今环境下,低延迟至关重要。
当 GraphQL API 与 MongoDB 搭配使用时,您不仅能获得快速的响应时间,还能从始至终保持一致的数据格式。
想象一下:您的客户端正在执行一个 GraphQL 查询,而这个查询看起来与 JSON 相似。当数据到达您的应用程序——比如本文示例使用的是 TypeScript——您在应用中处理的数据格式也与 JSON 类似。再进一步,当使用 MongoDB 时,您与 MongoDB 之间传输的数据同样与 JSON 相似。因此,在保证性能的同时,您还能获得一致的数据体验。无需过多担心数据的处理与格式化,您可以将更多精力放在应用的用户体验上,而不是数据库和工具本身。
在本教程中,我们将看看在 GraphQL API 中使用 MongoDB(本次使用 TypeScript 构建)是多么简单。
前置条件
针对本教程,在开始之前请准备好以下内容:
- 一个 MongoDB Atlas 集群
- Node.js 22+
- MongoDB Node.js 驱动 6.x
本教程不会进行复杂的数据库操作,因此任何层级的 MongoDB 集群都可以使用,甚至免费层也可。不过,我们不会在本教程中讲解 MongoDB Atlas 集群的开通配置。我们默认您已准备好用户规则和网络规则。如果需要帮助,请参阅主题相关的Atlas 入门文档。
您需要先创建一个 Node.js 应用程序。如果愿意,可以执行以下命令:
mkdir graphql_example
cd graphql_example
npm init -y技术支持
上述命令会创建一个新的项目目录和基础的 package.json 文件。我们将在本项目中使用 TypeScript、Express 框架、MongoDB 和 Apollo。可以通过以下命令安装它们:
npm install @apollo/server@4 body-parser cors dotenv express@4 graphql mongodb
npm install @types/cors @types/express@4 @types/node tsx typescript --save-dev技术支持
上述命令将安装依赖以及 TypeScript 中要使用的类型定义。
作为前置条件的最后一步,您需要一个 TypeScript 的配置文件。在项目根目录下创建 tsconfig.json 文件,内容如下:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}技术支持
如果您想随时查看完整项目或亲自试用,可以在 GitHub 上查看。
使用 Apollo、Express 和 MongoDB 依赖搭建基础
从项目结构的角度,我们将使用以下内容:
- src/database/connection.ts
- src/database/userService.ts
- src/resolvers/index.ts
- src/schema/typeDefs.ts
- src/types/index.ts
- src/index.ts
- .env
src/index.ts 文件将包含所有 Express 框架与 Apollo 的启动配置。也就是启动服务器、定义 GraphQL 端点等。src/types/index.ts 文件将包含在应用内与数据库数据模型配合使用的 TypeScript 定义。不要与 src/schema/typeDefs.ts 文件混淆,尽管类似,但后者专注于 GraphQL 类型定义。
接下来是 database 和 resolvers 目录中的文件。我们的大部分应用与数据库逻辑都会在这些文件中,很快就会展开。
现在先专注于在 src/index.ts 文件中完成服务器的设置:
import 'dotenv/config';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import http from 'http';
import cors from 'cors';
import bodyParser from 'body-parser';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './resolvers';
async function startServer() {
try {
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
bodyParser.json(),
expressMiddleware(server)
);
const PORT = process.env.PORT || 3000;
await new Promise<void>((resolve) => httpServer.listen({ port: PORT }, resolve));
console.log(Server ready at http://localhost:${PORT}/graphql);
} catch (error) {
console.error('Error starting server:', error);
process.exit(1);
}
}
startServer();技术支持
随着进度推进,我们会继续扩展上述文件。
首先,我们导入了所有项目依赖。接着配置了 Express 框架服务器与 Apollo 中间件。
const server = new ApolloServer({
typeDefs,
resolvers,
});技术支持
我们尚未向 src/schema/typeDefs.ts 和 src/resolvers/index.ts 文件添加内容,但此阶段先把框架搭好。在补全其他文件之前,服务器还无法无错运行。
值得一提的是,这个示例允许来自所有来源的请求,原因在于以下设置:
cors<cors.CorsRequest>(),技术支持
这在开发环境中没问题,但在生产部署时请仅允许必要的来源。
在 Node.js 的 TypeScript 应用中与 MongoDB 交互
虽然我们已经搭好了 Express 框架与 Apollo 服务器的基础,但应用中还未配置 MongoDB 数据库。在编写代码前,我们需要把连接信息添加到 .env 文件:
MONGODB_URI="mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/?appName=devrel-graphql"
DB_NAME="graphql_example"技术支持
<USERNAME>、<PASSWORD> 和 <HOST> 应替换为您在 MongoDB Atlas 控制台中找到的 Atlas 集群信息。
接下来,为应用建立数据库连接逻辑。在 **src/database/connection.ts** 文件中添加以下代码:
import { MongoClient, Db } from 'mongodb';
let db: Db | null = null;
let client: MongoClient | null = null;
export async function connectToDatabase(): Promise<Db> {
if (db) {
return db;
}
try {
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error('MONGODB_URI is not set');
}
const DB_NAME = process.env.DB_NAME || 'graphql_example';
const APP_NAME = process.env.APP_NAME || 'devrel-graphql';
console.log('Connecting to MongoDB...');
client = new MongoClient(MONGODB_URI, { appName: APP_NAME });
await client.connect();
db = client.db(DB_NAME);
console.log(Successfully connected to MongoDB database: ${DB_NAME});
return db;
} catch (error) {
console.error('MongoDB connection error:', error);
throw error;
}
}
export function getDatabase(): Db {
if (!db) {
throw new Error('Database not initialized. Call connectToDatabase() first.');
}
return db;
}
export async function closeDatabase(): Promise<void> {
if (client) {
await client.close();
db = null;
client = null;
console.log('MongoDB connection closed');
}
}技术支持
这里的设计思路是创建一个单例连接。让 MongoDB 在您的应用中工作的方法有很多,但本示例选择这种方式。
connectToDatabase 函数会从 **.env** 文件读取连接信息,建立连接并返回您定义的数据库。如果失败,将捕获并抛出错误。通常您只需要在像前文所示的服务器设置阶段调用一次 connectToDatabase。
getDatabase 函数将在每次请求到达应用时使用,用于获取当前的连接实例。
最后,为了保持整洁,我们提供了 closeDatabase 函数,用于在服务器终止时关闭与数据库的连接。是否使用此函数取决于您,但保留它是个好习惯。
在进入数据库逻辑之前,先为数据传输对象(DTO)准备 TypeScript 类型:
export interface User {
id: string;
name: string;
email: string;
age?: number;
createdAt: string;
}
export interface CreateUserInput {
name: string;
email: string;
age?: number;
}
export interface UpdateUserInput {
id: string;
name?: string;
email?: string;
age?: number;
}技术支持
上述接口定义位于 **src/types/index.ts** 文件中,由我们的 API 管理,但数据库逻辑也需要知晓这些类型。稍后您就会看到。
接下来就是实际的数据库交互:创建、读取、更新、删除(CRUD)。相关逻辑写在 src/database/userService.ts 文件中。我们的想法是让 GraphQL 解析器处理应用逻辑并调用该服务文件中的函数,而服务负责数据库逻辑。
在 src/database/userService.ts 文件中,我们有如下代码:
import { Collection, ObjectId } from 'mongodb';
import { getDatabase } from './connection';
import { User, CreateUserInput, UpdateUserInput } from '../types';
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}
export class UserService {
private collection: Collection<UserDocument>;
constructor() {
const db = getDatabase();
this.collection = db.collection<UserDocument>('users');
}
private toUser(doc: UserDocument): User {
return {
id: doc._id.toString(),
name: doc.name,
email: doc.email,
age: doc.age,
createdAt: doc.createdAt.toISOString(),
};
}
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}
async getUserById(id: string): Promise<User | null> {
try {
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
return doc ? this.toUser(doc) : null;
} catch (error) {
return null;
}
}
async createUser(input: CreateUserInput): Promise<User> {
const newUser: Omit<UserDocument, '_id'> = {
name: input.name,
email: input.email,
age: input.age,
createdAt: new Date(),
};
const result = await this.collection.insertOne(newUser as UserDocument);
const createdUser = await this.collection.findOne({ _id: result.insertedId });
if (!createdUser) {
throw new Error('Failed to create user');
}
return this.toUser(createdUser);
}
async updateUser(input: UpdateUserInput): Promise<User | null> {
try {
const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
if (input.name !== undefined) updateFields.name = input.name;
if (input.email !== undefined) updateFields.email = input.email;
if (input.age !== undefined) updateFields.age = input.age;
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(input.id) },
{ $set: updateFields },
{ returnDocument: 'after' }
);
return result ? this.toUser(result) : null;
} catch (error) {
return null;
}
}
async deleteUser(id: string): Promise<boolean> {
try {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount > 0;
} catch (error) {
return false;
}
}
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}
}技术支持
您首先会注意到上面的这个接口:
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}技术支持
它看起来与我们的 User 接口相似,但并不完全相同。UserDocument 更严格地映射为数据库中的数据结构。数据库中我们使用 ObjectId,而在应用中使用字符串哈希。在 MongoDB 中,id 表示为 _id,而不是 id,这与 User 接口不同。
下面我们来看 UserService 类。
通过 constructor 方法从数据库实例中获取集合句柄后,我们有以下一系列函数:
- toUser
- getAllUsers
- getUserById
- createUser
- updateUser
- deleteUser
- createIndexes
toUser 函数本质上是一个帮助函数,用于在 User 与 UserDocument 之间转换。我们与数据库之间传递的是 UserDocument,而在应用中需要转换为 User 来收发数据。
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}技术支持
createIndexes 函数会创建一些可能有用的示例索引。默认情况下,仅在 _id 字段上存在索引,这对只有少量文档的演示来说可以,但随着应用增长,请务必根据您的数据与查询需求创建合适的索引。上述两个索引仅是可能的示例。
现在,我们从 getAllUsers 函数开始逐一查看服务函数:
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}技术支持
上述函数因为在 find 函数中未指定过滤条件,因此会查找集合中的所有文档。结果会通过 toUser 函数转换并返回给应用,稍后将由我们的 Apollo 解析器使用。
async getUserById(id: string): Promise<User | null> {
try {
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
return doc ? this.toUser(doc) : null;
} catch (error) {
return null;
}
}技术支持
getByUserId 函数与其类似,但这次我们使用 findOne 并指定过滤条件——这里的条件是基于唯一的 _id 字段查询单个用户。由于 User 接口中的 id 字段是字符串,我们需要将其包裹在 ObjectId 对象中,以便在 MongoDB 中进行合适的查询。
接着是通过 createUser 函数在 MongoDB 中创建数据:
async createUser(input: CreateUserInput): Promise<User> {
const newUser: Omit<UserDocument, '_id'> = {
name: input.name,
email: input.email,
age: input.age,
createdAt: new Date(),
};
const result = await this.collection.insertOne(newUser as UserDocument);
const createdUser = await this.collection.findOne({ _id: result.insertedId });
if (!createdUser) {
throw new Error('Failed to create user');
}
return this.toUser(createdUser);
}技术支持
使用 insertOne 插入数据后,我们利用返回的 insertedId 再次查询该数据。这样做是因为在 GraphQL API 中,通常期望在同一个 mutation 中即可查询到所创建的数据。
updateUser 函数稍有不同:
async updateUser(input: UpdateUserInput): Promise<User | null> {
try {
const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
if (input.name !== undefined) updateFields.name = input.name;
if (input.email !== undefined) updateFields.email = input.email;
if (input.age !== undefined) updateFields.age = input.age;
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(input.id) },
{ $set: updateFields },
{ returnDocument: 'after' }
);
return result ? this.toUser(result) : null;
} catch (error) {
return null;
}
}技术支持
在这个应用中,我们只允许更新数据的 name、email 和 age 字段。由于这些字段都是可选的,因此如果它们在输入中出现,就需要在更新条件中设置。
这就引出了 findOneAndUpdate 函数。
该函数的第一个对象参数是过滤条件。我们只在 _id 匹配时执行更新。对于匹配到的文档,使用 $set 运算符替换或创建 updateFields 对象中的任何字段。不在对象中的字段不会被更改。最后,由于我们要“查找并更新”而不仅仅是“更新”,因此将 returnDocument 设为 after,以便返回变更后的文档而非原始文档。如果使用 updateOne 而不是 findOneAndUpdate,则只能得到关于更新操作的元信息,而不是数据本身。
本 API 的最后一个 CRUD 操作为从 MongoDB 中删除数据,通过 deleteUser 函数完成:
async deleteUser(id: string): Promise<boolean> {
try {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount > 0;
} catch (error) {
return false;
}
}技术支持
上述 deleteOne 函数看起来与 findOne 类似,因为我们同样基于 _id 指定过滤条件。不同之处在于,一旦匹配(最多删除一个文档),即会执行删除。
此时,数据库逻辑已就绪。为了完成它,我们需要在 **src/index.ts** 文件中发起连接:
import { connectToDatabase, closeDatabase } from './database/connection';
import { UserService } from './database/userService';
async function startServer() {
try {
await connectToDatabase();
const userService = new UserService();
await userService.createIndexes();
}技术支持
在上述代码片段中,src/index.ts 文件的大部分被省略了。目的是展示如何调用 connectToDatabase 函数以及创建索引。明确来说,只需把这三行代码放在 startServer 函数靠前的位置即可。
现在我们可以把重心转移到 API 逻辑上了。
定义 GraphQL 类型、解析器与自定义应用逻辑
我们已经为与数据库交互以及在应用内传递数据定义好了 TypeScript 类型。不过,目前还没有创建 GraphQL 类型定义,它用于定义用户在使用我们的 API 时可以与哪些内容交互。
在 src/schema/typeDefs.ts 文件中,添加如下内容:
export const typeDefs = #graphql
type User {
id: ID!
name: String!
email: String!
age: Int
createdAt: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!, age: Int): User!
updateUser(id: ID!, name: String, email: String, age: Int): User
deleteUser(id: ID!): Boolean!
}
;技术支持
User 类型定义了客户端在任意查询中允许请求的内容。在本示例中,它与 User 接口一致,但并非必须一致。比如,您的 User 接口可能包含永远不应返回给客户端的数据,如密码。
Query 类型定义了我们 API 中可执行的查询类型。每个查询会返回一个 User 或 User 数组,但我们可以在这些查询中加入特定的逻辑。
Mutation 类型与 Query 类似,但用于操作数据的查询。
有了 GraphQL 类型定义,我们就可以专注于应用的解析器逻辑了。在项目的 src/resolvers/index.ts 文件中,加入以下代码:
import { UserService } from '../database/userService';
import { User, CreateUserInput, UpdateUserInput } from '../types';
export const resolvers = {
Query: {
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},
user: async (_: unknown, { id }: { id: string }): Promise<User | null> => {
const userService = new UserService();
return await userService.getUserById(id);
},
},
Mutation: {
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},
updateUser: async (
_: unknown,
{ id, name, email, age }: UpdateUserInput
): Promise<User | null> => {
const userService = new UserService();
return await userService.updateUser({ id, name, email, age });
},
deleteUser: async (_: unknown, { id }: { id: string }): Promise<boolean> => {
const userService = new UserService();
return await userService.deleteUser(id);
},
},
};技术支持
上述解析器只是对类型定义文件中查询与变更的扩展实现。
来看一下 users 查询:
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},技术支持
该 GraphQL 查询会使用属于数据库逻辑部分的 UserService 来获取所有用户。User[] 响应将返回给用户,但只会返回与 GraphQL 类型定义匹配且被请求的字段。
例如,假设客户端发送如下查询:
query {
users {
name
email
}
}技术支持
在上述示例中,我们可用的字段不止这些,但客户端只需要 name 和 email 字段。这是根据客户端所用的 GraphQL 类型定义来处理的。Apollo 会负责相关细节。
同样的规则适用于 **src/resolvers/index.ts** 文件中的每个查询与变更。
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},技术支持
在 createUser 类型定义中,我们看到客户端可以传入 name、email 和 age 字段。在 createUser 解析器函数中,我们也按此接收并调用数据库服务中的 createUser 函数。最终的响应会根据客户端请求的字段返回。
构建并运行 GraphQL API
至此,GraphQL API 应该已相当完善。构建和运行该应用有多种方式。
例如,您可以在 **package.json** 文件中添加如下内容:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"watch": "tsc -w"
},技术支持
如果您选择此方式,可以运行 npm run build 来构建项目,运行 npm run start 来启动服务。默认端口为 3000,您可以通过 cURL 或任意偏好的工具进行测试。
结语
您刚刚学习了如何使用 TypeScript 和 MongoDB 作为数据库来创建 GraphQL API。虽然本示例旨在保持简洁,但我们已经演示了如何针对数据库构建用于 CRUD 操作的查询与变更。当在 GraphQL API 中使用 MongoDB 时,两者是绝佳搭配,因为您所处理的数据从头到尾的格式都非常相似。例如,客户端运行的查询看起来与 JSON 相似。通过查询传递的数据几乎无需格式化和调整即可使用,发送到数据库以及从数据库返回时亦然。整体体验流畅,让您能更专注于用户体验,而不必为满足数据库而频繁调整数据。
如果您在本教程的任何环节遇到困难,请务必查看GitHub 上的完成版项目。
GraphQL 是构建 API 的一种强大且高效的方式。客户端以类似查询数据库的方式向 API 发起查询,该 API 仅返回他们请求的数据,常常能减少响应负载并改善响应时间。在当今环境下,低延迟至关重要。
当 GraphQL API 与 MongoDB 搭配使用时,您不仅能获得快速的响应时间,还能从始至终保持一致的数据格式。
想象一下:您的客户端正在执行一个 GraphQL 查询,而这个查询看起来与 JSON 相似。当数据到达您的应用程序——比如本文示例使用的是 TypeScript——您在应用中处理的数据格式也与 JSON 类似。再进一步,当使用 MongoDB 时,您与 MongoDB 之间传输的数据同样与 JSON 相似。因此,在保证性能的同时,您还能获得一致的数据体验。无需过多担心数据的处理与格式化,您可以将更多精力放在应用的用户体验上,而不是数据库和工具本身。
在本教程中,我们将看看在 GraphQL API 中使用 MongoDB(本次使用 TypeScript 构建)是多么简单。
前置条件
针对本教程,在开始之前请准备好以下内容:
本教程不会进行复杂的数据库操作,因此任何层级的 MongoDB 集群都可以使用,甚至免费层也可。不过,我们不会在本教程中讲解 MongoDB Atlas 集群的开通配置。我们默认您已准备好用户规则和网络规则。如果需要帮助,请参阅主题相关的Atlas 入门文档。
您需要先创建一个 Node.js 应用程序。如果愿意,可以执行以下命令:
上述命令会创建一个新的项目目录和基础的 package.json 文件。我们将在本项目中使用 TypeScript、Express 框架、MongoDB 和 Apollo。可以通过以下命令安装它们:
上述命令将安装依赖以及 TypeScript 中要使用的类型定义。
作为前置条件的最后一步,您需要一个 TypeScript 的配置文件。在项目根目录下创建 tsconfig.json 文件,内容如下:
如果您想随时查看完整项目或亲自试用,可以在 GitHub 上查看。
使用 Apollo、Express 和 MongoDB 依赖搭建基础
从项目结构的角度,我们将使用以下内容:
src/index.ts 文件将包含所有 Express 框架与 Apollo 的启动配置。也就是启动服务器、定义 GraphQL 端点等。src/types/index.ts 文件将包含在应用内与数据库数据模型配合使用的 TypeScript 定义。不要与 src/schema/typeDefs.ts 文件混淆,尽管类似,但后者专注于 GraphQL 类型定义。
接下来是 database 和 resolvers 目录中的文件。我们的大部分应用与数据库逻辑都会在这些文件中,很快就会展开。
现在先专注于在 src/index.ts 文件中完成服务器的设置:
随着进度推进,我们会继续扩展上述文件。
首先,我们导入了所有项目依赖。接着配置了 Express 框架服务器与 Apollo 中间件。
我们尚未向 src/schema/typeDefs.ts 和 src/resolvers/index.ts 文件添加内容,但此阶段先把框架搭好。在补全其他文件之前,服务器还无法无错运行。
值得一提的是,这个示例允许来自所有来源的请求,原因在于以下设置:
这在开发环境中没问题,但在生产部署时请仅允许必要的来源。
在 Node.js 的 TypeScript 应用中与 MongoDB 交互
虽然我们已经搭好了 Express 框架与 Apollo 服务器的基础,但应用中还未配置 MongoDB 数据库。在编写代码前,我们需要把连接信息添加到 .env 文件:
<USERNAME>、<PASSWORD>和<HOST>应替换为您在 MongoDB Atlas 控制台中找到的 Atlas 集群信息。接下来,为应用建立数据库连接逻辑。在 **src/database/connection.ts** 文件中添加以下代码:
这里的设计思路是创建一个单例连接。让 MongoDB 在您的应用中工作的方法有很多,但本示例选择这种方式。
connectToDatabase函数会从 **.env** 文件读取连接信息,建立连接并返回您定义的数据库。如果失败,将捕获并抛出错误。通常您只需要在像前文所示的服务器设置阶段调用一次connectToDatabase。getDatabase函数将在每次请求到达应用时使用,用于获取当前的连接实例。最后,为了保持整洁,我们提供了
closeDatabase函数,用于在服务器终止时关闭与数据库的连接。是否使用此函数取决于您,但保留它是个好习惯。在进入数据库逻辑之前,先为数据传输对象(DTO)准备 TypeScript 类型:
上述接口定义位于 **src/types/index.ts** 文件中,由我们的 API 管理,但数据库逻辑也需要知晓这些类型。稍后您就会看到。
接下来就是实际的数据库交互:创建、读取、更新、删除(CRUD)。相关逻辑写在 src/database/userService.ts 文件中。我们的想法是让 GraphQL 解析器处理应用逻辑并调用该服务文件中的函数,而服务负责数据库逻辑。
在 src/database/userService.ts 文件中,我们有如下代码:
您首先会注意到上面的这个接口:
它看起来与我们的
User接口相似,但并不完全相同。UserDocument更严格地映射为数据库中的数据结构。数据库中我们使用ObjectId,而在应用中使用字符串哈希。在 MongoDB 中,id 表示为_id,而不是id,这与User接口不同。下面我们来看
UserService类。通过
constructor方法从数据库实例中获取集合句柄后,我们有以下一系列函数:toUser函数本质上是一个帮助函数,用于在User与UserDocument之间转换。我们与数据库之间传递的是UserDocument,而在应用中需要转换为User来收发数据。createIndexes函数会创建一些可能有用的示例索引。默认情况下,仅在_id字段上存在索引,这对只有少量文档的演示来说可以,但随着应用增长,请务必根据您的数据与查询需求创建合适的索引。上述两个索引仅是可能的示例。现在,我们从
getAllUsers函数开始逐一查看服务函数:上述函数因为在
find函数中未指定过滤条件,因此会查找集合中的所有文档。结果会通过toUser函数转换并返回给应用,稍后将由我们的 Apollo 解析器使用。getByUserId函数与其类似,但这次我们使用findOne并指定过滤条件——这里的条件是基于唯一的_id字段查询单个用户。由于User接口中的id字段是字符串,我们需要将其包裹在ObjectId对象中,以便在 MongoDB 中进行合适的查询。接着是通过
createUser函数在 MongoDB 中创建数据:使用
insertOne插入数据后,我们利用返回的insertedId再次查询该数据。这样做是因为在 GraphQL API 中,通常期望在同一个 mutation 中即可查询到所创建的数据。updateUser函数稍有不同:在这个应用中,我们只允许更新数据的
name、email和age字段。由于这些字段都是可选的,因此如果它们在输入中出现,就需要在更新条件中设置。这就引出了
findOneAndUpdate函数。该函数的第一个对象参数是过滤条件。我们只在
_id匹配时执行更新。对于匹配到的文档,使用$set运算符替换或创建updateFields对象中的任何字段。不在对象中的字段不会被更改。最后,由于我们要“查找并更新”而不仅仅是“更新”,因此将returnDocument设为after,以便返回变更后的文档而非原始文档。如果使用updateOne而不是findOneAndUpdate,则只能得到关于更新操作的元信息,而不是数据本身。本 API 的最后一个 CRUD 操作为从 MongoDB 中删除数据,通过
deleteUser函数完成:上述
deleteOne函数看起来与findOne类似,因为我们同样基于_id指定过滤条件。不同之处在于,一旦匹配(最多删除一个文档),即会执行删除。此时,数据库逻辑已就绪。为了完成它,我们需要在 **src/index.ts** 文件中发起连接:
在上述代码片段中,src/index.ts 文件的大部分被省略了。目的是展示如何调用
connectToDatabase函数以及创建索引。明确来说,只需把这三行代码放在startServer函数靠前的位置即可。现在我们可以把重心转移到 API 逻辑上了。
定义 GraphQL 类型、解析器与自定义应用逻辑
我们已经为与数据库交互以及在应用内传递数据定义好了 TypeScript 类型。不过,目前还没有创建 GraphQL 类型定义,它用于定义用户在使用我们的 API 时可以与哪些内容交互。
在 src/schema/typeDefs.ts 文件中,添加如下内容:
User类型定义了客户端在任意查询中允许请求的内容。在本示例中,它与User接口一致,但并非必须一致。比如,您的User接口可能包含永远不应返回给客户端的数据,如密码。Query类型定义了我们 API 中可执行的查询类型。每个查询会返回一个User或User数组,但我们可以在这些查询中加入特定的逻辑。Mutation类型与Query类似,但用于操作数据的查询。有了 GraphQL 类型定义,我们就可以专注于应用的解析器逻辑了。在项目的 src/resolvers/index.ts 文件中,加入以下代码:
上述解析器只是对类型定义文件中查询与变更的扩展实现。
来看一下
users查询:该 GraphQL 查询会使用属于数据库逻辑部分的
UserService来获取所有用户。User[]响应将返回给用户,但只会返回与 GraphQL 类型定义匹配且被请求的字段。例如,假设客户端发送如下查询:
在上述示例中,我们可用的字段不止这些,但客户端只需要
name和email字段。这是根据客户端所用的 GraphQL 类型定义来处理的。Apollo 会负责相关细节。同样的规则适用于 **src/resolvers/index.ts** 文件中的每个查询与变更。
在
createUser类型定义中,我们看到客户端可以传入name、email和age字段。在createUser解析器函数中,我们也按此接收并调用数据库服务中的createUser函数。最终的响应会根据客户端请求的字段返回。构建并运行 GraphQL API
至此,GraphQL API 应该已相当完善。构建和运行该应用有多种方式。
例如,您可以在 **package.json** 文件中添加如下内容:
如果您选择此方式,可以运行
npm run build来构建项目,运行npm run start来启动服务。默认端口为 3000,您可以通过 cURL 或任意偏好的工具进行测试。结语
您刚刚学习了如何使用 TypeScript 和 MongoDB 作为数据库来创建 GraphQL API。虽然本示例旨在保持简洁,但我们已经演示了如何针对数据库构建用于 CRUD 操作的查询与变更。当在 GraphQL API 中使用 MongoDB 时,两者是绝佳搭配,因为您所处理的数据从头到尾的格式都非常相似。例如,客户端运行的查询看起来与 JSON 相似。通过查询传递的数据几乎无需格式化和调整即可使用,发送到数据库以及从数据库返回时亦然。整体体验流畅,让您能更专注于用户体验,而不必为满足数据库而频繁调整数据。
如果您在本教程的任何环节遇到困难,请务必查看GitHub 上的完成版项目。