นำกระบวนทัศน์ใหม่มาใช้เพื่อเพิ่มความสอดคล้องของข้อมูลและความปลอดภัยของประเภท
ในบทความนี้ เราจะพูดถึงแนวทางอื่นในการจัดการเลเยอร์การเข้าถึงข้อมูลใน TypeScript ด้วย MongoDB โดยไม่ต้องใช้ ORM
ตามเนื้อผ้า นักพัฒนาใช้ประโยชน์จากเครื่องมือ Object-Relational Mapping (ORM) เพื่อแมประหว่างประเภทข้อมูลในฐานข้อมูลและภาษาการเขียนโปรแกรมเชิงวัตถุ อย่างไรก็ตาม บางครั้ง ORM อาจนำไปสู่ปัญหาด้านประสิทธิภาพ การกำหนดค่าที่ซับซ้อน หรือไม่ยืดหยุ่นในการอ่าน/เขียนข้อมูล
เพื่อหลีกเลี่ยงความท้าทายเหล่านี้ เราจะใช้วิธีการที่ใช้ zod
สำหรับการตรวจสอบข้อมูลและการอนุมานประเภท companion object
รูปแบบสำหรับการแปลงเอนทิตีเป็น DTO (ออบเจ็กต์การถ่ายโอนข้อมูล) และสรุปตรรกะข้อมูลภายในคลาสบริการ
ทำไมต้องมีการตรวจสอบสคีมา?
การตรวจสอบความถูกต้องของสคีมาเป็นกระบวนการเขียนโปรแกรมที่ช่วยให้มั่นใจว่าข้อมูลสอดคล้องกับสคีมาหรือโครงสร้างที่กำหนดไว้ล่วงหน้า สคีมาทำหน้าที่เป็นพิมพ์เขียวหรือแบบจำลองที่กำหนดข้อมูลที่ได้รับอนุญาตและองค์กร ซึ่งมักระบุเป็นประเภทข้อมูล ข้อจำกัด และความสัมพันธ์ ด้วยการบังคับใช้การตรวจสอบความถูกต้องของสคีมา โปรแกรมสามารถตรวจจับข้อผิดพลาดของข้อมูลได้ตั้งแต่เนิ่นๆ ปรับปรุงความสอดคล้องของข้อมูล และป้องกันปัญหาที่อาจเกิดขึ้นที่เกี่ยวข้องกับข้อมูลที่ไม่ถูกต้องหรือมีรูปแบบไม่ถูกต้อง
มีสถานการณ์ทั่วไปสองสถานการณ์ที่เราจำเป็นต้องใช้การตรวจสอบความถูกต้องของสคีมา:
- เมื่อจัดการข้อมูลอินพุต เช่น เพย์โหลดคำขอ API ในบริบทนี้ การตรวจสอบความถูกต้องของสคีมาสามารถช่วยให้แน่ใจว่าข้อมูลที่ส่งมาสอดคล้องกับรูปแบบและโครงสร้างที่คาดหวัง นี่เป็นสิ่งสำคัญอย่างยิ่งสำหรับชุดข้อมูลที่ซับซ้อนซึ่งอาจประกอบด้วยเขตข้อมูลและประเภทข้อมูลที่แตกต่างกันหลากหลาย
- เมื่อดำเนินการอ่านและเขียนด้วยฐานข้อมูล 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
สำหรับการตรวจสอบความถูกต้องของข้อมูลและการอนุมานประเภท รูปแบบออบเจ็กต์ที่แสดงร่วมกันสำหรับการดำเนินการแปลง และการสรุปภายในคลาสบริการ เราจึงสามารถรักษาโค้ดของเราให้ชัดเจน กระชับ และใช้งานได้ดี