+2

Cách viết các đoạn code gọn hơn với Mongoose Schemas

Nếu bạn đã quen với việc xây dựng các ứng dụng NodeJS bằng Mongoose ORM, thì có lẽ bạn nên đọc bài viết này. Trong đó, chúng ta sẽ thảo luận về một số tính năng thú vị của Mongoose Schemas có thể giúp bạn viết mã sao cho có tổ chức và dễ bảo trì hơn.

Mongoose Schema là gì?

Mongoose schema cung cấp một cách có cấu trúc để mô hình hóa dữ liệu trong cơ sở dữ liệu MongoDB, cho phép bạn xác định các thuộc tính và hành vi của các tài liệu. Sơ đồ đóng vai trò như bản vẽ cho một tài liệu được lưu trữ trong cơ sở dữ liệu. Chúng giúp các nhà phát triển duy trì tính toàn vẹn của dữ liệu và làm việc với MongoDB một cách trực quan và có tổ chức hơn.

Trong một bộ sưu tập MongoDB, schema mô tả các trường của tài liệu, kiểu dữ liệu của chúng, quy tắc xác thực, giá trị mặc định, ràng buộc và nhiều hơn nữa.

Về mặt lập trình, Mongoose Schemas là một đối tượng JavaScript. Thực tế, đó là một thể hiện của một lớp được tích hợp sẵn có tên là Schema bên trong mô-đun mongoose. Chính vì lý do này, bạn có thể thêm nhiều phương thức vào nguyên mẫu của nó. Điều này sẽ giúp bạn triển khai nhiều tính năng như middleware, phương thức, statics, và nhiều hơn nữa. Bạn sẽ tìm hiểu về một số trong số chúng trong hướng dẫn này.

Discriminator

Discriminator là một tính năng cho phép bạn tạo nhiều mô hình (subtypes) thừa kế từ một mô hình cơ sở (parent). Điều này được thực hiện bằng cách định nghĩa một sơ đồ cơ bản và sau đó mở rộng nó với các trường bổ sung cụ thể cho mỗi subtype hoặc mỗi child schema.

Tất cả tài liệu, bất kể mô hình cụ thể của chúng, được lưu trữ trong cùng một bộ sưu tập MongoDB. Điều này giữ cho dữ liệu của bạn được tổ chức trong một bộ sưu tập đơn nhất trong khi cho phép truy vấn linh hoạt và quản lý dữ liệu. Ngoài ra, mỗi tài liệu bao gồm một trường đặc biệt chỉ ra loại mô hình cụ thể của nó, cho phép Mongoose phân biệt giữa các subtype khác nhau.

Cách sử dụng discriminator:

  1. Bắt đầu bằng cách định nghĩa một sơ đồ cơ bản, có chứa các trường chung giữa các subtype. Sau đó, tạo một mô hình từ nó.
import mongoose from 'mongoose';

 const baseSchema = new mongoose.Schema({
     name: { type: String, required: true },
 }, { discriminatorKey: 'kind' }; // defaults to '__t');

 const BaseModel = mongoose.model('Base', baseSchema);
  1. Tạo các subtype mở rộng sơ đồ cơ bản bằng cách định nghĩa discriminator cho từng loại.
const catSchema = new mongoose.Schema({
     meow: { type: Boolean, default: true }
 });
 // subtype
 const Cat = BaseModel.discriminator('Cat', catSchema);

 const dogSchema = new mongoose.Schema({
     bark: { type: Boolean, default: true }
 });
 // subtype
 const Dog = BaseModel.discriminator('Dog', dogSchema);
  1. Bạn có thể sau đó tạo tài liệu theo cách thông thường. Tất cả tài liệu sẽ được lưu trữ trong cùng một bộ sưu tập, nhưng mỗi tài liệu có loại riêng tùy thuộc vào mô hình subtype của nó.
const fluffy = await Cat.create({ name: 'Fluffy' });
 const rover = await Dog.create({ name: 'Rover' });

Trường hợp sử dụng discriminator:

Giả sử bạn đang xây dựng một ứng dụng web thương mại điện tử đa người dùng, chứa ba vai trò người dùng chính: quản trị viên, khách hàng và người bán. Mỗi vai trò này đều đóng một phần quan trọng trong hệ sinh thái của mua sắm trực tuyến.

Nếu bạn cố gắng xây dựng một lớp cho mỗi vai trò, bạn sẽ thấy rằng tất cả ba vai trò đều có các trường và phương thức chung. Bạn có thể quyết định tạo một sơ đồ cha (người dùng) và một số sơ đồ con (khách hàng, người bán, quản trị viên) thừa kế từ nó.

Bạn có thể sử dụng discriminator để thực hiện điều này.

Trong tệp user.model.js của bạn, thêm đoạn mã sau:

import mongoose from "mongoose";

const userSchema = mongoose.Schema(
  {
    name: String,
    profilePic: String,
    email: String,
    password: String,
    birthDate: Date,
    accountAcctivated: { type: Boolean, default: false },
  },
  {
    timestamps: true,
    discriminatorKey: "role",
  }
);

const User = mongoose.model("User", userSchema);
export default User;

Bây giờ bạn có mô hình cơ bản (User) mà các subtype khác sẽ thừa kế. Trong sơ đồ cha này, bạn định nghĩa các trường chung mà tất cả người dùng sẽ chia sẻ bất kể vai trò của họ.

Trong tệp client.model.js của bạn:

import mongoose from "mongoose";
import User from "./user.model.js";

const clientSchema = mongoose.Schema(
  {
    products: Array,
    address: String,
    phone: String,
  }
);

const Client = User.discriminator("Client", clientSchema);
export default Client;

Trong tệp seller.model.js của bạn:

import mongoose from "mongoose";
import User from "./user.model.js";

export const sellerSchema = mongoose.Schema(
  {
    rating: Number,
    businessType: { type: String, enum: ["individual", "corporation"] },
  }
);

const Seller = User.discriminator("Seller", sellerSchema);
export default Seller;

Trong tệp admin.model.js của bạn:

import mongoose from "mongoose";
import User from "./user.model.js";

export const adminSchema = mongoose.Schema(
  {
    permissions: Array,
    assignedTasks: Array,
    department: String,
  }
);

const Admin = User.discriminator("Admin", adminSchema);
export default Admin;

Các subtype hay con sẽ là Client, Seller, và Admin. Trong mỗi sơ đồ con, bạn nên thêm bất kỳ trường hoặc hành vi bổ sung nào cụ thể cho loại subtype này. Bằng cách tạo mô hình con bằng discriminator, mô hình con sẽ thừa hưởng tất cả các trường và phương thức của mô hình cha User.

Vì vậy, đoạn mã trước đây sẽ tạo một bộ sưu tập user trong cơ sở dữ liệu với mỗi tài liệu có một trường role là Client, hoặc Seller, hoặc Admin. Giờ đây, tất cả tài liệu đều chia sẻ các trường của cha (user), và tùy thuộc vào role của mỗi tài liệu, mỗi tài liệu lại có một trường bổ sung khác.

Mặc dù tất cả tài liệu được lưu trong một bộ sưu tập duy nhất, các mô hình hoàn toàn được tách biệt khi lập trình. Điều này có nghĩa là gì?

Ví dụ, nếu bạn cần lấy tất cả khách hàng từ bộ sưu tập User, bạn nên viết Client.find(). Câu lệnh này sử dụng khóa discriminator để tìm tất cả tài liệu có role là Client. Như vậy, bất kỳ thao tác hoặc truy vấn nào liên quan đến một trong các mô hình con sẽ vẫn được viết riêng biệt so với mô hình cha.

Lưu ý: Trước khi đi sâu vào các phần tiếp theo, hãy nhớ rằng bất kỳ statics, phương thức, trình xây dựng truy vấn hoặc hooks nào cũng nên được định nghĩa trước khi tạo mô hình thực tế (tức là trước const User = mongoose.model("User", userSchema)😉.

Statics

Statics hữu ích cho việc định nghĩa các hàm hoạt động ở cấp độ mô hình. Chúng cho phép bạn định nghĩa các hàm tái sử dụng cho các hoạt động liên quan đến toàn bộ mô hình. Chúng giúp đóng gói logic áp dụng cho mô hình hơn là từng tài liệu, làm cho code của bạn gọn gàng, có tổ chức và dễ bảo trì hơn.

Các phương thức như find, findOne, findById và những phương thức khác đều được gắn với mô hình. Bằng cách sử dụng thuộc tính statics của sơ đồ Mongoose, bạn sẽ có thể xây dựng phương thức mô hình của riêng mình.

Statics rất mạnh mẽ. Bằng cách sử dụng chúng, bạn có thể đóng gói các truy vấn phức tạp mà bạn muốn tái sử dụng. Ngoài ra, bạn cũng có thể tạo statics cho các hoạt động sửa đổi hoặc tổng hợp dữ liệu, như đếm tài liệu hoặc tìm tài liệu dựa trên tiêu chí cụ thể.

Trường hợp sử dụng statics: Statics dễ dàng xây dựng. Bạn định nghĩa một phương thức static trên sơ đồ của bạn sử dụng đối tượng statics.

Trong tệp user.model.js của bạn, thêm các phương thức static này, countUsers và findByEmail:

// model method
userSchema.statics.countUsers = function () {
    return this.countDocuments({});
};

// model method
userSchema.statics.findByEmail = async function (email) {
  return await this.findOne({ email });
};

Trong bất kỳ phương thức static nào, this đề cập đến mô hình chính nó. Trong ví dụ này, this trong this.findOne( email ) đề cập đến mô hình User.

Ví dụ sử dụng:

const user = await User.findByEmail("foo@bar.com");
//or
const client = await Client.findByEmail("foo@bar.com");
//or
const seller = await Seller.findByEmail("foo@bar.com");
//or
const admin = await Admin.findByEmail("foo@bar.com");

Khi bạn gọi phương thức static trên mô hình của mình, phương thức sẽ được gọi và this được thay thế bằng mô hình bạn đã gọi statics đó. Dòng này thực hiện truy vấn để tìm một tài liệu duy nhất trong bộ sưu tập MongoDB nơi trường email phù hợp với đối số email được cung cấp.

Methods

Methods là các hàm mà bạn có thể định nghĩa trên một sơ đồ và có thể được gọi trên các thể hiện của tài liệu được tạo từ sơ đồ này. Chúng giúp đóng gói logic bên trong chính tài liệu, làm cho code của bạn gọn gàng và modular hơn.

Bằng cách sử dụng phương thức của thể hiện, bạn có thể dễ dàng tương tác và thao tác dữ liệu liên quan đến tài liệu cụ thể.

Trường hợp sử dụng methods: Bạn có thể định nghĩa methods trên sơ đồ sử dụng đối tượng methods.

Trong tệp user.model.js của bạn, thêm một phương thức tài liệu thông qua đó bạn có thể kiểm tra mật khẩu của người dùng:

// instance or document method
userSchema.methods.getProfile = function () {
    return `${this.name} (${this.email})`;
};

// instance or document method
userSchema.methods.checkPassword = function (password) {
    return password === this.password ? true : false;
};

Bên trong bất kỳ phương thức tài liệu nào, this đề cập đến tài liệu chính nó. Trong ví dụ này, this trong this.password đề cập đến tài liệu user mà phương thức sẽ được gọi lên. Điều này có nghĩa là bạn có thể truy cập tất cả các trường của tài liệu này. Điều này rất quý giá bởi vì bạn có thể truy xuất, sửa đổi và kiểm tra bất cứ điều gì liên quan đến tài liệu này.

Ví dụ sử dụng:

const client = await Client.findById(...)
client.checkPassword("12345")
//or
const seller = await Seller.findById(...)
seller.checkPassword("12345")
//or
const admin = await Admin.findById(...)
admin.checkPassword("12345")

Vì phương thức là các hàm cấp độ thể hiện, chúng được gọi trên các tài liệu. await Client.findById(...) sẽ trả về một tài liệu có tất cả các phương thức tích hợp cũng như các phương thức do bạn định nghĩa trước checkPassword và getProfile. Vì vậy, bằng cách gọi, ví dụ client.checkPassword("12345"), từ khóa this trong định nghĩa hàm checkPassword sẽ được thay thế bằng tài liệu client. Điều này tiếp nối sẽ so sánh mật khẩu người dùng với mật khẩu đã được lưu trữ trước đó trong cơ sở dữ liệu.

Query Builder

Trình xây dựng truy vấn trong Mongoose là một phương thức tùy chỉnh mà bạn có thể định nghĩa trên đối tượng truy vấn để đơn giản hóa và đóng gói các mô hình truy vấn thông thường. Những trình xây dựng truy vấn này cho phép bạn tạo logic truy vấn có thể tái sử dụng và dễ đọc, giúp việc làm việc với dữ liệu của bạn trở nên dễ dàng hơn.

Một trong những cách sử dụng trình xây dựng truy vấn hữu ích nhất là kết nối. Chúng có thể được kết nối với các trình xây dựng truy vấn khác mà bạn đã xây dựng hoặc với các phương thức truy vấn tiêu chuẩn như tìm kiếm, sắp xếp và như vậy.

Trường hợp sử dụng Query builder: Bạn định nghĩa trình xây dựng truy vấn bằng cách thêm chúng vào thuộc tính query của sơ đồ Mongoose.

Trong tệp user.model.js của bạn, thêm phương thức trợ giúp truy vấn cho phép bạn thực hiện phân trang.

// query helper
userSchema.query.paginate = function ({ page, limit }) {
    // some code
    const skip = limit * (page - 1);
    return this.skip(skip).limit(limit);
};

Để thực hiện phân trang, bạn cần hai biến quan trọng: đầu tiên, số trang, và thứ hai, số lượng mục bạn sẽ truy xuất trên mỗi trang.

Để truy vấn cơ sở dữ liệu cho một số lượng cụ thể của tài liệu, bạn sẽ luôn sử dụng các phương thức truy vấn tích hợp skip và limit trong mongoose. skip được sử dụng để đặt một con trỏ sau một số lượng nhất định của tài liệu, sau đó truy vấn sẽ được thực hiện. limit được sử dụng để truy xuất một số lượng cụ thể của tài liệu.

Bên trong bất kỳ phương thức trình xây dựng truy vấn nào, this đề cập đến truy vấn chính nó. Và vì trình xây dựng truy vấn có thể kết nối, bạn có thể gọi bất kỳ trình xây dựng truy vấn nào sau nhau.

Cuối cùng, bất kỳ phương thức trình xây dựng truy vấn nào cũng nên trả về một đối tượng truy vấn mongoose, đó là lý do tại sao bạn phải viết return this.skip(skip).limit(limit).

Ví dụ sử dụng:

const results = await Client.find().paginate({ page: 2, limit: 5 });
//or
const results = await Seller.find().paginate({ page: 2, limit: 5 });
//or
const results = await Admin.find().paginate({ page: 2, limit: 5 });

Sau đó, bạn có thể gọi nó trên bất kỳ truy vấn nào, và await Client.find().paginate( page: 2, limit: 5 ) sẽ kích hoạt hàm paginate và thay thế từ khóa this bằng Client.find() bằng cách sử dụng trình xây dựng truy vấn.

Bạn có thể thực hiện phân trang với một số điều kiện nhất định, nhưng bạn sẽ luôn gọi skip và limit. Bằng cách định nghĩa trình xây dựng truy vấn paginate, bạn sẽ không phải lặp lại bản thân và bạn có thể đóng gói logic trong một hàm duy nhất.

Hooks

Hooks (còn được gọi là middleware) là các hàm được thực thi tại các điểm cụ thể trong vòng đời của một tài liệu. Chúng cho phép bạn thêm hành vi tùy chỉnh trước hoặc sau các thao tác nhất định, chẳng hạn như lưu, cập nhật hoặc xóa tài liệu.

Các loại Hooks:

  • Pre Hooks: Thực thi trước một thao tác.
  • Post Hooks: Thực thi sau một thao tác.

Trường hợp sử dụng Hooks: Trong tệp user.model.js của bạn, thêm middleware post save thông qua đó bạn có thể gửi email kích hoạt tài khoản một khi tài liệu người dùng được lưu trong cơ sở dữ liệu.

// post hook
userSchema.post("save", async function (doc, next) {
  // send email logic
  // if succeeded
  return next();
  // if failed
  return next(new Error("Failed to send email!"));
});

Hàm gọi lại sẽ được kích hoạt một khi bạn tạo một người dùng thông qua model.create() hoặc bất cứ khi nào bạn gọi phương thức save() trên tài liệu người dùng.

Trong ví dụ này, nếu bạn cần tránh gửi email khi lưu, bạn nên viết một điều kiện để chắc chắn rằng save này chỉ dành cho người dùng mới. Bạn có thể viết điều gì đó như if (doc.createdAt.getTime() === doc.updatedAt.getTime()).

Như vậy, tôi đã giới thiệu cho các bạn những tính năng vô cùng hữu ích của Mongoose Schemas, hy vọng rằng chúng sẽ giúp ích cho bạn trong quá trình sử dụng, làm việc.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí