最终,每个以Express.js作为网络应用程序运行的Node.js项目都需要一个数据库。由于大多数服务器应用程序都是无状态的,为了用多个服务器实例进行横向扩展,如果没有另一个第三方(如数据库),就没有办法持久化数据。这就是为什么开发一个带有样本数据的初始应用程序是没问题的,在这里可以在没有数据库的情况下读写数据,但在某个时候你想引入一个数据库来管理数据。该数据库将保持跨服务器的数据持久性,或者即使你的一个服务器不运行。
下面的章节将告诉你如何用Mongoose作为ORM将你的Express应用程序连接到MongoDB数据库。如果你还没有在你的机器上安装MongoDB,请去看这个关于如何为你的机器安装MongoDB的指南。它带有一个MacOS和一个Windows的安装指南。之后,请回到本指南的下一节,了解更多关于在Express中使用MongoDB的信息。
在Express中安装带有Mongoose的MongoDB
为了将 MongoDB 连接到你的 Express 应用程序,我们将使用一个ORM来将信息从数据库转换到一个没有 SQL 语句的 JavaScript 应用程序。ORM是Object Related Mapping的缩写,是程序员用来在不兼容的类型之间转换数据的一种技术。更具体地说,ORM模拟了实际的数据库,所以开发者可以在编程语言(如JavaScript)中操作,而不使用数据库查询语言(如SQL)来与数据库进行交互。缺点是额外的代码抽象,这就是为什么有些开发者主张反对ORM,但对于许多没有复杂数据库查询的JavaScript应用程序来说,这不应该是一个问题。
对于这个应用程序,我们将使用Mongoose作为ORM。Mongoose提供了一个舒适的API,从设置到执行都可以与MongoDB数据库一起工作。在你能在你的Node.js应用程序中实现数据库使用之前,在命令行上为你的Node.js应用程序安装mongoose。
npm install mongoose --save
在你将该库安装为node包之后,我们将用模型和模式来规划和实现我们的数据库实体。
数据库模型、模式和实体
下面的案例为你的应用程序实现了一个有两个数据库实体的数据库。用户和消息。通常一个数据库实体也被称为数据库模式或数据库模型。你可以用以下方式来区分它们。
-
数据库模式。数据库模式接近于实现细节,它告诉数据库(和开发者)一个实体(例如用户实体)在数据库表中的样子,而实体的每个实例都由表行表示。例如,模式定义了一个实体的字段(如用户名)和关系(如一个用户有消息)。每个字段在数据库中被表示为一个列。基本上,模式是一个实体的蓝图。
-
数据库模型。数据库模型是对模式的一种更抽象的看法。它为开发者提供了一个概念框架,说明有哪些模型可用,以及如何使用模型作为接口,将应用程序连接到数据库,与实体进行交互。通常,模型是用ORM来实现的。
-
数据库实体。一个数据库实体是数据库中一个存储项目的实际实例,它是用数据库模式创建的。每个数据库实体在数据库表中使用一行,而实体的每个字段由一个列来定义。与另一个实体的关系通常用另一个实体的标识符来描述,最终也会成为数据库中的字段。
在深入研究你的应用程序的代码之前,绘制实体之间的关系以及如何处理它们之间必须传递的数据,总是一个好主意。UML(统一建模语言)图是一种直接表达实体间关系的方式,可以在你打字的时候快速引用。这对于为一个应用程序打基础的人以及任何想在数据库模式中增加信息的人来说都很有用。一个UML图可以这样显示。

用户和消息实体都有字段,定义了它们在结构中的身份和它们之间的关系。让我们回到我们的Express应用程序。通常,在你的Node.js应用程序中有一个叫做src/models/的文件夹,它包含了数据库中每个模型的文件(例如src/models/user.js和src/models/message.js)。每个模型都是以定义字段和关系的模式来实现的。通常还有一个文件(例如src/models/index.js)将所有模型结合起来,并将所有模型作为数据库接口导出到 Express 应用程序中。我们可以从src/models/[modelname].js文件中的两个模型开始,为了简单起见,可以像下面这样表达,不涵盖 UML 图中的所有字段。首先,是src/models/user.js文件中的用户模型。
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema( { username: { type: String, unique: true, required: true, }, }, { timestamps: true },);
const User = mongoose.model('User', userSchema);
export default User;
你可以看到,用户有一个用户名字段,它被表示为字符串类型。此外,我们还为我们的用户实体增加了一些验证。首先,我们不希望在数据库中出现重复的用户名,因此我们为该字段添加了唯一属性。其次,我们想让用户名字符串成为必填项,这样就不会出现没有用户名的用户。最后但并非最不重要,我们为这个数据库实体定义了时间戳,这将导致额外的createdAt 和updatedAt 字段。
我们还可以在我们的模型上实现额外的方法。让我们假设我们的用户实体在未来最终会有一个电子邮件字段。那么我们可以添加一个方法,通过他们的一个抽象的 "login "术语,也就是最后的用户名或电子邮件,在数据库中找到一个用户。当用户能够通过用户名或电子邮件地址登录到你的应用程序时,这很有帮助。你可以把它作为你的模型的方法来实现。之后,这个方法会在你选择的ORM中的所有其他内置方法旁边可用。
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema( { username: { type: String, unique: true, required: true, }, }, { timestamps: true },);
userSchema.statics.findByLogin = async function (login) { let user = await this.findOne({ username: login, });
if (!user) { user = await this.findOne({ email: login }); }
return user;};
const User = mongoose.model('User', userSchema);
export default User;
消息模型看起来很相似,尽管我们没有给它添加任何自定义方法,而且字段也很简单,只有一个文本字段。
import mongoose from 'mongoose';
const messageSchema = new mongoose.Schema( { text: { type: String, required: true, }, }, { timestamps: true },);
const Message = mongoose.model('Message', messageSchema);
export default Message;
然而,我们可能希望将消息与用户联系起来。
import mongoose from 'mongoose';
const messageSchema = new mongoose.Schema( { text: { type: String, required: true, }, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, }, { timestamps: true },);
const Message = mongoose.model('Message', messageSchema);
export default Message;
现在,如果一个用户被删除,我们可能想对所有与该用户有关的消息进行所谓的级联删除。这就是为什么你可以用钩子来扩展模式。在这种情况下,我们在我们的用户模式中添加一个预先的钩子,以便在该用户被删除时删除它的所有消息。
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema( { username: { type: String, unique: true, required: true, }, }, { timestamps: true },);
userSchema.statics.findByLogin = async function (login) { let user = await this.findOne({ username: login, });
if (!user) { user = await this.findOne({ email: login }); }
return user;};
userSchema.pre('remove', function(next) { this.model('Message').deleteMany({ user: this._id }, next);});
const User = mongoose.model('User', userSchema);
export default User;
Mongoose被用来定义带有内容的模式(由类型和可选配置组成)。此外,还可以添加额外的方法来塑造数据库接口,并且可以使用引用来创建模型之间的关系。一个用户可以有多个消息,但一个消息只属于一个用户。你可以在Mongoose文档中更深入地了解这些概念。接下来,在你的src/models/index.js文件中,导入并组合这些模型,并将它们导出为统一的模型接口。
import mongoose from 'mongoose';
import User from './user';import Message from './message';
const connectDb = () => { return mongoose.connect(process.env.DATABASE_URL);};
const models = { User, Message };
export { connectDb };
export default models;
在文件的顶部,你通过将数据库URL作为强制性参数传递给它来创建一个连接函数。在我们的案例中,我们使用的是环境变量,但你也可以在源代码中把参数作为字符串传递。例如,环境变量在*.env*文件中可以是下面的样子。
DATABASE_URL=mongodb://localhost:27017/node-express-mongodb-server
注意:当你在命令行上启动你的MongoDB时可以看到数据库的URL。你只需要为URL定义一个子路径来定义一个特定的数据库。如果该数据库还不存在,MongoDB将为你创建一个。
最后,在你的Express应用程序中使用该函数。它以异步方式连接到数据库,一旦完成,你就可以启动你的Express应用程序。
import express from 'express';...
import models, { connectDb } from './models';
const app = express();
...
connectDb().then(async () => { app.listen(process.env.PORT, () => console.log(`Example app listening on port ${process.env.PORT}!`), );});
如果你想在每次启动Express服务器时重新初始化你的数据库,你可以给你的函数添加一个条件。
...
const eraseDatabaseOnSync = true;
connectDb().then(async () => { if (eraseDatabaseOnSync) { await Promise.all([ models.User.deleteMany({}), models.Message.deleteMany({}), ]); }
app.listen(process.env.PORT, () => console.log(`Example app listening on port ${process.env.PORT}!`), );});
这就是为你的Express应用程序定义数据库模型,以及在你启动你的应用程序时将所有东西连接到数据库。一旦你再次启动你的应用程序,命令行的结果将显示你的数据库中的表是如何被创建的。
练习。
如何为MongoDB数据库播种?
最后但并非最不重要的是,你可能想用初始数据来启动你的MongoDB数据库的种子。否则,在每次应用程序启动时清除数据库(如 eraseDatabaseOnSync)时,你将总是从一张白纸开始。
在我们的案例中,我们的数据库里有用户和消息实体。每条消息都与一个用户相关联。现在,每次你启动你的应用程序时,你的数据库都会连接到你的物理数据库。这就是你决定在你的源代码中用一个布尔标志清除你所有的数据。同时这也可能是为你的数据库播种初始数据的地方。
...
const eraseDatabaseOnSync = true;
connectDb().then(async () => { if (eraseDatabaseOnSync) { await Promise.all([ models.User.deleteMany({}), models.Message.deleteMany({}), ]);
createUsersWithMessages(); }
app.listen(process.env.PORT, () => console.log(`Example app listening on port ${process.env.PORT}!`), );});
const createUsersWithMessages = async () => { ...};
createUsersWithMessages() 函数将被用来为我们的数据库播种。播种是异步进行的,因为在数据库中创建数据不是一个同步的任务。让我们看看如何用Mongoose在MongoDB中创建我们的第一个用户。
...
const createUsersWithMessages = async () => { const user1 = new models.User({ username: 'rwieruch', });
await user1.save();};
我们的每个用户实体都只有一个用户名作为属性。但是这个用户的消息呢?我们可以在另一个函数中创建它们,该函数通过引用(如用户标识符)将消息与用户联系起来。
...
const createUsersWithMessages = async () => { const user1 = new models.User({ username: 'rwieruch', });
const message1 = new models.Message({ text: 'Published the Road to learn React', user: user1.id, });
await message1.save();
await user1.save();};
我们可以单独创建每个实体,但将它们与必要的信息相互关联。然后我们可以将所有实体保存到实际的数据库中。让我们创建第二个用户,但这次有两个信息。
...
const createUsersWithMessages = async () => { const user1 = new models.User({ username: 'rwieruch', });
const user2 = new models.User({ username: 'ddavids', });
const message1 = new models.Message({ text: 'Published the Road to learn React', user: user1.id, });
const message2 = new models.Message({ text: 'Happy to release ...', user: user2.id, });
const message3 = new models.Message({ text: 'Published a complete ...', user: user2.id, });
await message1.save(); await message2.save(); await message3.save();
await user1.save(); await user2.save();};
就这样了。在我们的案例中,我们使用我们的模型来创建带有相关信息的用户。这发生在应用程序启动时,我们想从一个干净的地方开始;这被称为数据库播种。然而,我们的模型的API在我们的应用程序中以同样的方式用于创建用户和消息。最后,我们已经在一个带有Express的Node.js应用程序中设置了MongoDB。缺少的是将数据库连接到Express,以使用户能够用API对数据库进行操作,而不是对样本数据进行操作。
练习
- 确认最后一节的源代码。请注意,该项目无法在沙盒中正常运行,因为没有数据库。
- 确认你在上一节的改动。
- 探索。
- 还有什么可以代替 Mongoose 作为 ORM 的替代品?
- 还有什么可以代替MongoDB作为数据库的替代品?
- 将你的源代码与PostgreSQL + Sequelize替代方案的源代码进行比较。
- 问问自己。
- 你什么时候会在准备生产的环境中播种一个应用程序?
- 像Mongoose这样的ORM对于连接你的应用程序和数据库是必不可少的吗?