Slug และการตรวจสอบ
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”apps/api/src/common/slug.service.ts (SlugService) และ CommonModule เล็ก ๆ ที่ export service นี้ออกไป เชื่อมเข้ากับ PostsService.create/update (slug ของโพสต์ มาจาก title) และ TagsService.create (slug ของ tag มาจาก name) แทนที่ placeholder TODO(Content Workflow) ที่เหมือนกันสองจุดซึ่ง Posts resolver และ Tags resolver ทิ้งไว้ตั้งแต่ Module 5 PostsService ยังได้ private helper deriveExcerpt เพิ่มมาด้วย ใช้เมื่อ excerpt ของ CreatePostInput/UpdatePostInput ถูกเว้นว่างไว้
slug คือส่วนที่มนุษย์อ่านได้ของ URL โพสต์สาธารณะ — /posts/hello-devblog แทนที่จะเป็น /posts/671f2a... เรื่องนี้สำคัญด้วยเหตุผลที่จับต้องได้สองข้อ: ผู้อ่านเดาหรือจำ URL ได้ และ search engine มอง URL ที่มีคำจริงจากชื่อโพสต์เป็นสัญญาณความเกี่ยวข้องที่แรงกว่า database id ที่อ่านไม่ออก Schemas กำหนด Post.slug เป็น required, unique, index: true ไว้แล้ว ส่วนบทเรียนนี้คือตัวที่ ผลิต ค่าที่คู่ควรจะไปอยู่ตรงนั้นจริง ๆ แทน placeholder แบบ regex ของ Module 5
ชื่อสองชื่อที่ต่างกันสามารถ slugify ออกมาเป็น string เดียวกันได้ ("Hello, DevBlog!" และ "Hello DevBlog" ต่างก็กลายเป็น hello-devblog) และ unique index หมายความว่า save() ครั้งที่สองจะโยน MongoDB duplicate-key error ดิบ ๆ ออกมาถ้าไม่มีอะไรมาสกัดไว้ก่อน SlugService.generateUnique คือตัวป้องกันตรงนี้: คำนวณ slug ตั้งต้น แล้วถาม predicate exists ที่ผู้เรียกส่งมาว่า candidate ตัวนี้ถูกใช้ไปหรือยัง ถ้าชนก็ต่อ -2, -3 ไปเรื่อย ๆ จนเจอตัวที่ว่าง predicate เป็นหน้าที่ของผู้เรียก ไม่ใช่ของ SlugService เพราะคำถามว่า “slug นี้มีอยู่แล้วหรือยัง” แปลเป็น query คนละแบบสำหรับ Post (เช็คกับ collection posts) กับ Tag (เช็คกับ collection tags) SlugService จึงเป็นเจ้าของแค่การแปลง string กับ loop ลองใหม่ ไม่ใช่การเช็ค collision
ถึงอย่างนั้น TagsService.create ก็ ไม่ต้องการ พฤติกรรม auto-suffix ของ generateUnique แม้จะเรียก method เดียวกัน Post ที่ชื่อ “Hello, DevBlog” ซ้ำสองครั้งคือโพสต์สองอันที่ต่างกันจริง ๆ ซึ่งบังเอิญมีชื่อซ้ำกัน การ auto-suffix เป็น hello-devblog-2 จึงถูกต้อง แต่ Tag ชื่อ "NestJS" ซ้ำสองครั้งคือ tag เดียวกัน ซึ่ง Tags resolver เลือกปฏิเสธด้วย 409 ConflictException ไปแล้ว แทนที่จะสร้าง tag ที่สองซึ่งซ้ำกันเป๊ะ ๆ แบบเงียบ ๆ TagsService.create ทำแบบนั้นได้ด้วยการเรียก generateUnique พร้อม predicate exists ที่ resolve เป็น false เสมอ predicate แบบนี้ไม่มีวัน trigger suffix loop ทำให้การเรียกลดรูปเหลือแค่การแปลง slugify() ครั้งเดียว ส่วนการเช็คซ้ำจริง ๆ ยังคงเกิดขึ้นแบบที่ Module 5 กำหนดไว้ กับ slug ของ name shared service ตัวเดียวกัน แต่นโยบาย collision ต่างกันโดยตั้งใจสองแบบ เพราะ collision ของชื่อโพสต์กับ collision ของชื่อ tag มีความหมายไม่เหมือนกัน
การดึง excerpt อัตโนมัติเมื่อผู้เรียกไม่ได้ส่งมาให้ ปิดช่องว่างเล็ก ๆ ที่ CreatePostInput เปิดทิ้งไว้ตั้งแต่ Module 5: excerpt เป็น { nullable: true } ทั้งบน input และ GraphQL type Post ตรงกับ Post.excerpt?: string ใน Schemas เป๊ะ — แต่จนถึงตอนนี้ การไม่ใส่ excerpt ก็แค่ค้างเป็น undefined และหน้า list view ก็ไม่มีอะไรให้แสดง deriveExcerpt ตัดอักขระ Markdown กลุ่มที่มักโผล่ในประโยคแรกของโพสต์ออก (#, *, _, `, >, [, ], !) ยุบช่องว่างให้เหลือช่องเดียว แล้วตัดที่ 160 ตัวอักษร — ขนาดที่พอดีกับ snippet ผลการค้นหาหรือ Open Graph description โดยไม่ต้องใช้ Markdown parser จริงสำหรับค่าที่ไม่มีใครคาดหวังให้เป๊ะระดับพิกเซล
กฎการตรวจสอบสองข้อที่มีอยู่แล้วบน CreatePostInput ตั้งแต่ Module 5 คุ้มค่าที่จะเอ่ยถึงชัด ๆ ตอนนี้ที่ SlugService พึ่งพาความสมเหตุสมผลของ input พวกนั้น:
@Field()@IsString()@MinLength(3)title: string;
@Field()@IsString()@MinLength(1)body: string;@MinLength(3) ของ title ตัดชื่อยาวหนึ่งหรือสองตัวอักษรทิ้งตั้งแต่ก่อนไปถึง slugify() เพราะ slug ที่คู่ควรกับการ index ต้องมีมากกว่าเศษคำเดียว และชื่อที่สั้นเกินกว่าจะ slugify ให้มีความหมาย ก็สั้นเกินกว่าจะมีประโยชน์ใน list view หรือ social preview อยู่ดี ส่วน @MinLength(1) ของ body รับประกันแค่ว่า มี เนื้อหาอยู่บ้าง ไม่ได้กำหนดจำนวนคำหรือเวลาอ่านขั้นต่ำ เพราะ Draft → published เปิดทางให้ผู้เขียนบันทึกโพสต์ที่ยังไม่เสร็จเป็น draft ได้ กฎความยาว body ที่เข้มกว่านี้จะขัด workflow นั้นแทนที่จะหนุน
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”slug ที่แก้ไขได้ (สร้างใหม่ทุกครั้งที่ title เปลี่ยน) เทียบกับ slug ที่ถูกแช่แข็งตอนสร้าง PostsService.update สร้าง slug ใหม่ทุกครั้งที่ input.title มีค่า โดยใช้ SlugService.generateUnique เดียวกันกับที่ create ใช้ — ชื่อที่พิมพ์ผิดแล้วแก้ไข จะสะท้อนไปที่ URL ด้วย และการเช็ค collision ก็ตัด _id ของโพสต์เองออกไป ทำให้การเปลี่ยนชื่อกลับไปเป็นชื่อที่เคยผลิต slug ปัจจุบันของโพสต์นี้แล้ว ไม่ชนกับตัวเองแบบผิดพลาด ต้นทุนที่ต้องจ่ายเป็นเรื่องจริง: ใครก็ตามที่ bookmark หรือแชร์ URL เก่าไว้จะได้ 404 จาก findBySlug โดยไม่มี redirect slug ที่แช่แข็งตอนสร้างจะเลี่ยงเรื่องนี้ได้ทั้งหมด แลกกับ URL ที่ผิดถาวรถ้าชื่อมีคำผิดที่ควรแก้ DevBlog เลือกฝั่งที่แก้ไขได้ของ trade-off นี้ — URL ที่ผิดแต่ไม่มีวันพังยังแย่กว่า URL ที่ถูกต้องซึ่งอาจ redirect ไปไม่ถึงไหนสักครั้ง บล็อกจริงในโปรดักชันคงจะจับคู่ slug ที่แก้ไขได้กับตาราง redirect เล็ก ๆ (oldSlug → postId) เพื่อได้ทั้งสองอย่าง ซึ่งคอร์สนี้ไม่ได้เพิ่มเข้าไป
suffix ตามลำดับ (-2, -3, …) เทียบกับ suffix แบบสุ่ม (เช่น id สั้น ๆ) loop while (await exists(candidate)) ของ generateUnique เสียเวลาไปกลับฐานข้อมูลเพิ่มหนึ่งครั้งต่อ collision จริงหนึ่งครั้ง — ไม่มีนัยสำคัญสำหรับบล็อกที่มักมีผู้เขียนคนเดียว ที่โพสต์สองอันชื่อซ้ำกันเป็นเรื่องหายาก และ hello-devblog-2 ที่ได้ก็ยังอ่านออกและเดาได้ ส่วน suffix แบบสุ่ม (hello-devblog-x7q2) จบได้ในการเรียก predicate ครั้งเดียวไม่ว่าจะชนกันกี่ครั้ง แต่ได้ URL ที่คนอ่านแล้วรู้สึกเหมือนพัง สำหรับเนื้อหาที่ตั้งใจให้แชร์และจดจำ ความอ่านง่ายชนะ นี่คือ trade-off ที่ DevBlog เลือกตรงนี้
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”ติดตั้ง slugify ในโปรเจกต์ devblog API:
npm install slugifyสร้าง apps/api/src/common/slug.service.ts:
import { Injectable } from '@nestjs/common';import slugify from 'slugify';
@Injectable()export class SlugService { async generateUnique( base: string, exists: (slug: string) => Promise<boolean>, ): Promise<string> { const root = slugify(base, { lower: true, strict: true }); let candidate = root; let suffix = 2;
while (await exists(candidate)) { candidate = `${root}-${suffix}`; suffix += 1; }
return candidate; }}สร้าง apps/api/src/common/common.module.ts:
import { Module } from '@nestjs/common';import { SlugService } from './slug.service';
@Module({ providers: [SlugService], exports: [SlugService],})export class CommonModule {}Module 5 ทิ้ง PostsService.create ไว้พร้อม placeholder แบบ inline:
// BEFORE — apps/api/src/posts/posts.service.tsasync create(authorId: string, input: CreatePostInput): Promise<PostDocument> { // TODO(Content Workflow): a real slugify() with collision handling lands in // /devblog/en/content-workflow/ — this inline placeholder just keeps `create` // runnable until then. const slug = input.title .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); const created = new this.postModel({ ...input, slug, author: authorId }); return created.save();}อัปเดต apps/api/src/posts/posts.service.ts ให้เป็นของจริง โดย inject SlugService เชื่อมเข้ากับทั้ง create และ update แล้วเพิ่ม deriveExcerpt:
// AFTER — apps/api/src/posts/posts.service.tsimport { Injectable, NotFoundException } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { Model } from 'mongoose';import { Post, PostDocument } from './schemas/post.schema';import { PostStatus } from './enums/post-status.enum';import { CreatePostInput } from './dto/create-post.input';import { UpdatePostInput } from './dto/update-post.input';import { SlugService } from '../common/slug.service';
export interface FindPostsPageOptions { status?: PostStatus; tag?: string; page?: number; pageSize?: number;}
export interface PostsPageResult { items: PostDocument[]; total: number; page: number; pageSize: number;}
@Injectable()export class PostsService { constructor( @InjectModel(Post.name) private readonly postModel: Model<PostDocument>, private readonly slugService: SlugService, ) {}
async create(authorId: string, input: CreatePostInput): Promise<PostDocument> { const slug = await this.slugService.generateUnique(input.title, (candidate) => this.postModel.exists({ slug: candidate }).exec().then(Boolean), ); const excerpt = input.excerpt ?? this.deriveExcerpt(input.body);
const created = new this.postModel({ ...input, slug, excerpt, author: authorId }); return created.save(); }
async findPage(options: FindPostsPageOptions = {}): Promise<PostsPageResult> { const { status, tag, page = 1, pageSize = 10 } = options; const filter: Record<string, unknown> = {}; if (status) { filter.status = status; } if (tag) { filter.tags = tag; }
const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ this.postModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(pageSize).exec(), this.postModel.countDocuments(filter).exec(), ]);
return { items, total, page, pageSize }; }
findBySlug(slug: string): Promise<PostDocument | null> { return this.postModel.findOne({ slug }).exec(); }
findById(id: string): Promise<PostDocument | null> { return this.postModel.findById(id).exec(); }
async update(id: string, input: UpdatePostInput): Promise<PostDocument> { const patch: Partial<UpdatePostInput> & { slug?: string; excerpt?: string } = { ...input };
if (input.title) { patch.slug = await this.slugService.generateUnique(input.title, (candidate) => this.postModel.exists({ slug: candidate, _id: { $ne: id } }).exec().then(Boolean), ); } if (input.body && !input.excerpt) { patch.excerpt = this.deriveExcerpt(input.body); }
const updated = await this.postModel.findByIdAndUpdate(id, patch, { new: true }).exec(); if (!updated) { throw new NotFoundException('Post not found'); } return updated; }
async remove(id: string): Promise<PostDocument> { const removed = await this.postModel.findByIdAndDelete(id).exec(); if (!removed) { throw new NotFoundException('Post not found'); } return removed; }
async publish(id: string): Promise<PostDocument> { const published = await this.postModel .findByIdAndUpdate(id, { status: PostStatus.PUBLISHED, publishedAt: new Date() }, { new: true }) .exec(); if (!published) { throw new NotFoundException('Post not found'); } return published; }
private deriveExcerpt(body: string): string { const plain = body .replace(/[#*_`>[\]!]/g, '') .replace(/\s+/g, ' ') .trim();
return plain.length <= 160 ? plain : `${plain.slice(0, 160).trimEnd()}...`; }}Module 5 ทิ้ง placeholder ที่เหมือนกันไว้ใน TagsService.create:
// BEFORE — apps/api/src/tags/tags.service.tsasync create(name: string): Promise<TagDocument> { // TODO(Content Workflow): a shared slugify() util replaces this inline copy — // see the identical placeholder in PostsService.create. const slug = name .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '');
const existing = await this.tagModel.findOne({ slug }).exec(); if (existing) { throw new ConflictException(`Tag "${name}" already exists`); }
const created = new this.tagModel({ name, slug }); return created.save();}อัปเดต apps/api/src/tags/tags.service.ts ให้ใช้ SlugService — พร้อม predicate exists แบบ false เสมอที่อธิบายไว้ข้างบน เพื่อให้พฤติกรรม 409 ที่ Tags resolver verify ไว้แล้วยังคงเหมือนเดิมทุกประการ:
// AFTER — apps/api/src/tags/tags.service.tsimport { ConflictException, Injectable } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { Model } from 'mongoose';import { Tag, TagDocument } from './schemas/tag.schema';import { SlugService } from '../common/slug.service';
@Injectable()export class TagsService { constructor( @InjectModel(Tag.name) private readonly tagModel: Model<TagDocument>, private readonly slugService: SlugService, ) {}
findAll(): Promise<TagDocument[]> { return this.tagModel.find().sort({ name: 1 }).exec(); }
async create(name: string): Promise<TagDocument> { const slug = await this.slugService.generateUnique(name, () => Promise.resolve(false));
const existing = await this.tagModel.findOne({ slug }).exec(); if (existing) { throw new ConflictException(`Tag "${name}" already exists`); }
const created = new this.tagModel({ name, slug }); return created.save(); }}Import CommonModule เข้าไปในทั้งสอง feature module อัปเดต apps/api/src/posts/posts.module.ts:
import { Module } from '@nestjs/common';import { MongooseModule } from '@nestjs/mongoose';import { Post, PostSchema } from './schemas/post.schema';import { PostsService } from './posts.service';import { PostsResolver } from './posts.resolver';import { UsersModule } from '../users/users.module';import { CommonModule } from '../common/common.module';
@Module({ imports: [ MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }]), UsersModule, CommonModule, ], providers: [PostsService, PostsResolver],})export class PostsModule {}อัปเดต apps/api/src/tags/tags.module.ts:
import { Module } from '@nestjs/common';import { MongooseModule } from '@nestjs/mongoose';import { Tag, TagSchema } from './schemas/tag.schema';import { TagsService } from './tags.service';import { TagsResolver } from './tags.resolver';import { CommonModule } from '../common/common.module';
@Module({ imports: [ MongooseModule.forFeature([{ name: Tag.name, schema: TagSchema }]), CommonModule, ], providers: [TagsService, TagsResolver],})export class TagsModule {}SlugService.generateUniqueรับ predicate สำหรับเช็ค collision เป็น parameter แทนที่จะรับ Mongoose model ตัว service ไม่รู้ด้วยซ้ำว่าPostหรือTagคืออะไร และก็ไม่จำเป็นต้องรู้ จึงอยู่ในcommon/ไม่ใช่ในposts/หรือtags/postModel.exists({ slug: candidate, _id: { $ne: id } })ในupdateตัดโพสต์ที่กำลังแก้อยู่ออกจากการเช็ค collision ของตัวเอง ถ้าไม่มี_id: { $ne: id }การบันทึกโพสต์โดยไม่เปลี่ยนชื่อ (ซึ่งไม่ทำให้ slug เปลี่ยน) จะไปเจอ “โพสต์ที่มี slug นี้อยู่แล้ว” คือตัวเองทุกครั้ง แล้วต่อ-2เข้าไปโดยไม่จำเป็น- predicate
() => Promise.resolve(false)ของTagsService.createคือบรรทัดเดียวที่ดูแปลกเมื่อมองแยกออกมา แต่นี่แหละคือสิ่งที่ทำให้generateUniqueลดรูปเหลือแค่การเรียกslugify()ธรรมดา ซึ่งตั้งใจไว้แบบนั้นตามที่หัวข้อ “Why” ด้านบนอธิบาย
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”npm run start:devพร้อม header Authorization จาก mutation login/register ใน Sandbox รัน createPost ด้วยชื่อเดียวกับที่ Posts resolver’s Verify section ใช้ไปแล้วครั้งหนึ่ง คือ "Hello, DevBlog":
mutation CreatePostAgain { createPost(input: { title: "Hello, DevBlog", body: "Second post, same title." }) { slug excerpt }}{ "data": { "createPost": { "slug": "hello-devblog-2", "excerpt": "Second post, same title." } }}slug ที่ได้กลับมาคือ hello-devblog-2 ไม่ใช่ duplicate-key error — suffix loop ของ SlugService.generateUnique ดักจับ collision กับโพสต์ที่สร้างไว้แล้วใน Verify section ของ Module 5 ได้ excerpt ที่ได้กลับมาเท่ากับ body เป๊ะในกรณีนี้ เพราะ string ทั้งหมดสั้นกว่า 160 ตัวอักษร ลองใช้ body ที่ยาวกว่านั้นดู แล้ว excerpt จะถูกตัดพร้อม ... ต่อท้าย แม้ mutation นี้จะไม่เคยตั้งค่า excerpt เองเลยก็ตาม
รัน createTag ด้วยชื่อเดียวกันสองครั้ง:
mutation CreateTagTwice { createTag(name: "NestJS") { slug }}การเรียกครั้งที่สองยังคงล้มเหลวด้วย 409 ConflictException เดิมจาก Tags resolver ยืนยันว่านโยบายชื่อซ้ำของ TagsService.create ไม่เปลี่ยนเลย แม้ตอนนี้จะวิ่งผ่าน SlugService แล้วก็ตาม สุดท้าย ลอง updatePost โพสต์ที่มีอยู่ด้วย title ใหม่ แล้วยืนยันว่า slug เปลี่ยนตาม ส่วนการ updatePost โพสต์เดิมซ้ำอีกครั้งโดยไม่แตะ title ต้องได้ slug เดิมเป๊ะ (ไม่มี -2 งอกมาจาก filter การยกเว้น)
SlugService.generateUnique เป็น helper จัดการ collision เล็ก ๆ ที่ไม่ผูกกับ model ใดโดยเฉพาะ ใช้ร่วมกันผ่าน CommonModule โดย PostsService (auto-suffix เมื่อชื่อชนกันจริง) และ TagsService (เลือกที่จะไม่ auto-suffix โดยตั้งใจ คงนโยบาย 409 เดิมไว้) ทั้งสอง service เข้ามาแทน placeholder แบบ inline ที่ซ้ำกันซึ่ง Module 5 ทิ้งไว้ นอกจากนี้ PostsService ยังดึง excerpt จาก body ให้เมื่อผู้เรียกไม่ได้ส่งมา และกฎ validation title/body บน CreatePostInput ที่มีอยู่เดิม ตอนนี้ก็มีเหตุผลบันทึกไว้ชัดเจนว่ามีไว้ทำไม ไม่ใช่แค่ “class-validator อยากได้อะไรสักอย่าง”
Next: Draft → published →