นำกระบวนทัศน์ใหม่มาใช้เพื่อเพิ่มความสอดคล้องของข้อมูลและความปลอดภัยของประเภท

ในบทความนี้ เราจะพูดถึงแนวทางอื่นในการจัดการเลเยอร์การเข้าถึงข้อมูลใน TypeScript ด้วย MongoDB โดยไม่ต้องใช้ ORM

ตามเนื้อผ้า นักพัฒนาใช้ประโยชน์จากเครื่องมือ Object-Relational Mapping (ORM) เพื่อแมประหว่างประเภทข้อมูลในฐานข้อมูลและภาษาการเขียนโปรแกรมเชิงวัตถุ อย่างไรก็ตาม บางครั้ง ORM อาจนำไปสู่ปัญหาด้านประสิทธิภาพ การกำหนดค่าที่ซับซ้อน หรือไม่ยืดหยุ่นในการอ่าน/เขียนข้อมูล

เพื่อหลีกเลี่ยงความท้าทายเหล่านี้ เราจะใช้วิธีการที่ใช้ zod สำหรับการตรวจสอบข้อมูลและการอนุมานประเภท companion object รูปแบบสำหรับการแปลงเอนทิตีเป็น DTO (ออบเจ็กต์การถ่ายโอนข้อมูล) และสรุปตรรกะข้อมูลภายในคลาสบริการ

ทำไมต้องมีการตรวจสอบสคีมา?

การตรวจสอบความถูกต้องของสคีมาเป็นกระบวนการเขียนโปรแกรมที่ช่วยให้มั่นใจว่าข้อมูลสอดคล้องกับสคีมาหรือโครงสร้างที่กำหนดไว้ล่วงหน้า สคีมาทำหน้าที่เป็นพิมพ์เขียวหรือแบบจำลองที่กำหนดข้อมูลที่ได้รับอนุญาตและองค์กร ซึ่งมักระบุเป็นประเภทข้อมูล ข้อจำกัด และความสัมพันธ์ ด้วยการบังคับใช้การตรวจสอบความถูกต้องของสคีมา โปรแกรมสามารถตรวจจับข้อผิดพลาดของข้อมูลได้ตั้งแต่เนิ่นๆ ปรับปรุงความสอดคล้องของข้อมูล และป้องกันปัญหาที่อาจเกิดขึ้นที่เกี่ยวข้องกับข้อมูลที่ไม่ถูกต้องหรือมีรูปแบบไม่ถูกต้อง

มีสถานการณ์ทั่วไปสองสถานการณ์ที่เราจำเป็นต้องใช้การตรวจสอบความถูกต้องของสคีมา:

  1. เมื่อจัดการข้อมูลอินพุต เช่น เพย์โหลดคำขอ API ในบริบทนี้ การตรวจสอบความถูกต้องของสคีมาสามารถช่วยให้แน่ใจว่าข้อมูลที่ส่งมาสอดคล้องกับรูปแบบและโครงสร้างที่คาดหวัง นี่เป็นสิ่งสำคัญอย่างยิ่งสำหรับชุดข้อมูลที่ซับซ้อนซึ่งอาจประกอบด้วยเขตข้อมูลและประเภทข้อมูลที่แตกต่างกันหลากหลาย
  2. เมื่อดำเนินการอ่านและเขียนด้วยฐานข้อมูล NoSQL เช่น MongoDB ในบริบทนี้ การตรวจสอบความถูกต้องของสคีมาสามารถช่วยให้แน่ใจว่าข้อมูลที่เขียนสอดคล้องกับสคีมาที่คาดหวัง และยังสามารถตรวจสอบความถูกต้องของข้อมูลที่ดึงมาได้อีกด้วย สิ่งนี้สำคัญอย่างยิ่งเมื่อข้อกังวลเรื่องความสอดคล้องของข้อมูล หรือเมื่อมีข้อกำหนดที่เข้มงวดด้านคุณภาพและความถูกต้องของข้อมูล

บทความนี้มุ่งเน้นไปที่สถานการณ์สมมติที่สองเป็นหลัก

ทำไมต้องซอด?

Zod เป็นไลบรารี TypeScript แรกที่ได้รับการออกแบบมาเพื่อการประกาศสคีมาและการตรวจสอบความถูกต้อง ช่วยให้นักพัฒนาสามารถสร้างสคีมาที่ตรวจสอบข้อมูลรันไทม์และสร้างประเภท TypeScript เพื่อให้มั่นใจในความปลอดภัยของประเภทโดยไม่ต้องมีคำอธิบายประกอบประเภทด้วยตนเองเพิ่มเติม ด้วยการผสานรวมกับ TypeScript ทำให้ zod ปรับปรุงความสามารถในการพิมพ์แบบคงที่ของภาษาด้วยการตรวจสอบรันไทม์ โดยนำเสนอคุณลักษณะต่างๆ เช่น การจัดการข้อผิดพลาดแบบกำหนดเอง สคีมาแบบซ้อน และการแปลง

โปรดทราบว่าแม้ว่า MongoDB จะมีฟีเจอร์ “การตรวจสอบ JSON Schema” แต่บริการบางอย่างที่เข้ากันได้กับ MongoDB API เช่น AWS DocumentDB ก็ไม่รองรับ นอกจากนี้ ฉันเชื่อว่าโค้ดของแอปพลิเคชันควรตระหนักและบังคับใช้ข้อจำกัดด้านข้อมูลทั้งหมดที่โค้ดนั้นดำเนินการอยู่ ดังนั้น ฉันยังคงต้องการให้การตรวจสอบความถูกต้องของสคีมาเป็นส่วนหนึ่งของโค้ดแอปพลิเคชัน และนี่คือจุดที่ zod มีประโยชน์ได้

การกำหนดเอนทิตีด้วย Zod

ลองพิจารณาตัวอย่างที่เราทำงานร่วมกับผู้ใช้ในฐานข้อมูล MongoDB เราจะเริ่มต้นด้วยการกำหนดเอนทิตีที่แสดงถึงวิธีการจัดเก็บผู้ใช้ในฐานข้อมูล

import { z } from "zod";
import { ObjectId } from "mongodb";

export const userEntitySchema = z.object({
  _id: z.instanceof(ObjectId),
  name: z.string(),
  email: z.string().email(),
});

export type UserEntity = z.infer<typeof userEntitySchema>;

ในโค้ดข้างต้น เรากำลังกำหนดประเภท userEntitySchema และ UserEntity สคีมาอธิบายว่าเอนทิตีผู้ใช้มีสามฟิลด์: _id, name และ email ประเภท UserEntity ถูกอนุมานโดยตรงจากสคีมา

การกำหนด Data Transfer Object (DTO) ด้วย Zod

ต่อไป เราจะกำหนด DTO ที่แสดงถึงวิธีที่เราจะใช้ข้อมูลผู้ใช้ในแอปพลิเคชันของเรา

export const userDTOSchema = z.object({
  id: z.string(),
  name: userEntitySchema.shape.name,
  email: userEntitySchema.shape.email,
});

export type UserDTO = z.infer<typeof userDTOSchema>;

ที่นี่ เราได้กำหนดสคีมาและประเภทที่คล้ายกันสำหรับ UserDTO แต่สังเกตว่าฟิลด์ _id ถูกแทนที่ด้วย id ในขณะที่ name และ email ต่างก็ใช้รูปร่างจาก userEntitySchema

การใช้รูปแบบวัตถุสหาย

หากต้องการแปลงเอนทิตีเป็น DTO เราจะใช้รูปแบบออบเจ็กต์ที่แสดงร่วม รูปแบบนี้เกี่ยวข้องกับการสร้างวัตถุที่แสดงร่วมเพื่อเก็บฟังก์ชันคงที่ที่เกี่ยวข้องกับคลาสหรือประเภท

export const UserDTO = {
  convertFromEntity(entity: UserEntity): UserDTO {
    const candidate: UserDTO = {
      id: entity._id.toHexString(),
      name: entity.name,
      email: entity.email,
    };
    return userDTOSchema.parse(candidate);
  },
};

ในโค้ดข้างต้น เราได้กำหนดวัตถุชื่อ UserDTO ด้วยฟังก์ชันเดียว:convertFromEntity ซึ่งฟังก์ชันนี้สามารถแปลงวัตถุ UserEntity ให้เป็นวัตถุ UserDTO ได้

เพื่ออธิบายเพิ่มเติมอีกเล็กน้อย TypeScript จะจัดการ "ประเภท" และ "คำจำกัดความของวัตถุที่เป็นรูปธรรม" ในเนมสเปซที่ต่างกัน นั่นเป็นสาเหตุที่เราสามารถกำหนดประเภท UserDTO ได้เช่นเดียวกับวัตถุที่เป็นรูปธรรมที่เรียกว่าชื่อเดียวกัน อ็อบเจ็กต์ที่กำหนดในลักษณะนี้มักจะมีฟังก์ชัน util หรือ helper บางอย่างที่ใช้กับประเภทชื่อเดียวกัน ดังนั้นจึงเรียกว่า "อ็อบเจ็กต์ร่วม"

โปรดทราบว่าเราไม่เพียงรับประกันความปลอดภัยของประเภทในเวลาคอมไพล์เท่านั้น แต่ยังรับประกันความปลอดภัยของประเภทรันไทม์ผ่าน schema.parse() ซึ่งจะทำให้แน่ใจว่าออบเจ็กต์ตรงตามข้อกำหนดการตรวจสอบความถูกต้องของสคีมา

การห่อหุ้มลอจิกข้อมูลในคลาสบริการ

สุดท้าย เราจะสรุปตรรกะข้อมูลของเราภายในคลาสบริการ ซึ่งจะจัดการการดำเนินการ CRUD ทั้งหมดสำหรับผู้ใช้ คลาสบริการนี้จะโต้ตอบโดยตรงกับ MongoDB โดยใช้ไดรเวอร์ MongoDB Node.js

import { MongoClient, Db } from "mongodb";

export class UserService {
  private readonly db: Db;

  constructor(mongoClient: MongoClient) {
    this.db = mongoClient.db();
  }

  private getUsersCollection() {
    return this.db.collection<UserEntity>("users");
  }

  async findUser(id: string): Promise<UserDTO | null> {
    const entity = await this.getUsersCollection().findOne({ _id: new ObjectId(id) });
    return entity ? UserDTO.convertFromEntity(entity) : null;
  }

  async createUser(dto: Omit<UserDTO, "id">): Promise<UserDTO> {
    const candidate = userEntitySchema.parse({
      ...dto,
      _id: new ObjectId(),
    });
    const { insertedId } = await this.getUsersCollection().insertOne(candidate);
    return UserDTO.convertFromEntity({ ...dto, _id: insertedId });
  }

  async updateUser(id: string, dto: Omit<Partial<UserDTO>, "id">): Promise<UserDTO | null> {
    const candidate = userEntitySchema.partial().parse(dto);

    const { value } = await this.getUsersCollection().findOneAndUpdate(
      { _id: new ObjectId(id) },
      { $set: candidate },
      { returnDocument: "after" }
    );
    return value ? UserDTO.convertFromEntity(value) : null;
  }

  async deleteUser(id: string): Promise<void> {
    await this.getUsersCollection().deleteOne({ _id: new ObjectId(id) });
  }
}

ในคลาส UserService นี้ เราใช้การดำเนินการ CRUD ทั้งหมดสำหรับผู้ใช้: findUser, createUser, updateUser และ deleteUser วิธีการทั้งหมดนี้โต้ตอบกับ MongoDB โดยตรงผ่านไดรเวอร์ MongoDB Node.js และจัดการการแปลงระหว่าง UserEntity ถึง UserDTO

โปรดทราบว่าตัวอย่างนี้อาจไม่ได้แสดงถึงข้อจำกัดของข้อมูลทางธุรกิจในโลกแห่งความเป็นจริง แต่เป็นแนวคิดในการสรุปตรรกะข้อมูลประมาณ User

บางคนอาจต้องการบังคับใช้รูปแบบ Repository และเรียกมันว่า UserRepository ทำให้คลาสมุ่งเน้นไปที่การดำเนินการ CRUD เพียงอย่างเดียวโดยไม่มีตรรกะทางธุรกิจ จากนั้นใช้ UserRepository ภายใน UserService สำหรับโครงการขนาดเล็กถึงขนาดกลาง ฉันขอแนะนำให้เริ่มต้นด้วย Service ซึ่งน่าจะตอบสนองความต้องการได้อย่างมีประสิทธิภาพอยู่แล้วโดยไม่มีค่าใช้จ่ายมากเกินไป

โบนัส: รองรับ IDE นอกกรอบด้วย IntelliSense

ด้วยการกำหนดประเภทตามที่เราอธิบายไว้ในบล็อกนี้ IDE จะช่วยให้เราครอบคลุมได้โดยไม่จำเป็นต้องใช้ไลบรารีหรือปลั๊กอินเพิ่มเติม ตัวอย่างเช่น เมื่อเราพยายามสร้างแบบสอบถามโดยใช้ .findOne() IDE หรือโปรแกรมแก้ไขโค้ดจะรู้ว่าฟิลด์ใดบ้างที่เราสามารถจัดเก็บได้:

ข้อมูลโค้ดสำหรับการตรวจสอบไอเดีย

ที่นี่เราอาจมีข้อมูลโค้ดง่ายๆ เพื่อยืนยันแนวคิดของเรา:

import _ from "lodash";

const main = async () => {
  const mongoClient = new MongoClient("mongodb://localhost:27017/data-access-example");

  try {
    await mongoClient.db().dropCollection("users");
  } catch (e) {
    if (_.get(e, "codeName") !== "NamespaceNotFound") {
      throw e;
    }
    console.log(`Collection "users" does not exist, no need to drop`);
  }

  await mongoClient.db().createCollection("users");

  const userService = new UserService(mongoClient);

  const createdUser = await userService.createUser({ name: "example", email: "[email protected]" });
  console.log({ createdUser });

  const updatedUser = await userService.updateUser(createdUser.id, { name: "exampleX" });
  console.log({ updatedUser });

  const foundUser = await userService.findUser(createdUser.id);
  console.log({ foundUser });

  await userService.deleteUser(createdUser.id);
};

main()
  .then(() => {
    process.exit();
  })
  .catch((e) => {
    console.error(e);
    process.exit(1);
  });

บทสรุป

ในบล็อกโพสต์นี้ เราได้พูดคุยถึงแนวทางอื่นในการจัดการเลเยอร์การเข้าถึงข้อมูลโดยไม่มี ORM เมื่อใช้ TypeScript และ MongoDB วิธีการนี้ให้ความยืดหยุ่นและขจัดปัญหาคอขวดที่อาจเกิดขึ้นซึ่งเกี่ยวข้องกับ ORM ในขณะที่ยังคงรักษาความสอดคล้องและความชัดเจนของชั้นข้อมูลของเรา ด้วยไลบรารี zod สำหรับการตรวจสอบความถูกต้องของข้อมูลและการอนุมานประเภท รูปแบบออบเจ็กต์ที่แสดงร่วมกันสำหรับการดำเนินการแปลง และการสรุปภายในคลาสบริการ เราจึงสามารถรักษาโค้ดของเราให้ชัดเจน กระชับ และใช้งานได้ดี