跳至主要内容

MongoDB 与 GraphQL:天作之合

了解如何使用 TypeScript 和 MongoDB 创建 GraphQL API,这种组合不仅高效,而且非常易用。
· 11分钟 阅读

使用 AI 探索

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** 文件中发起连接:

// Previous imports here...
import { connectToDatabase, closeDatabase } from './database/connection';
import { UserService } from './database/userService';

async function startServer() {
  try {

    await connectToDatabase();
    
    const userService = new UserService();
    await userService.createIndexes();
    
    // The rest of the file here...
}

在上述代码片段中,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 上的完成版项目。

FAQs

我为什么要使用 GraphQL?

GraphQL 的优势在于,您可以像查询数据库一样查询 API,并只请求您所需的内容。这让客户端端的通信更轻量、更高效。

Apollo 是 GraphQL 的唯一选择吗?

Apollo 能让创建 GraphQL API 变得非常简单,但它并非唯一选项。不过,其他选项不在本教程的讨论范围内。

为什么要将 MongoDB 与 GraphQL 搭配使用?

两者都使用类似 JSON 的数据格式,因此当信息从数据库到 API 再到客户端的流转过程中,您无需频繁在不同数据结构之间转换。

解析器的作用是什么?

解析器(resolver)是一个函数,用于告诉 GraphQL 如何为架构中的某个字段获取或修改数据。

查询与变更有何区别?

查询用于读取/获取数据(类似 GET 请求),而变更用于创建、更新或删除数据(类似 POST、PUT、DELETE)。

Nic Raboy

作者

Nic Raboy

Nic Raboy 是 MongoDB 的开发者关系主管,带领一支由 Python、Java、C# 与 PHP 开发者组成的团队,创作优质内容,帮助开发者在项目中成功使用 MongoDB。他具备 Golang 与 JavaScript 经验,并常常撰写自己在开发过程中的见闻与实践。

主题