📘 Booklab Workshop

Day 4

เชื่อมหน้าบ้านเข้ากับ API จริง

เวลา: 120 นาที จบคาบนี้จะได้: หน้าเว็บดึงข้อมูลจากฐานข้อมูลจริงผ่าน API พร้อมช่องค้นหา และจัดการสถานะกำลังโหลด / ผิดพลาด / ไม่พบข้อมูล ครบ


ช่วงที่ 1 — เข้าใจการคุยกันสองฝั่ง (25 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • 🟢 NestJS (หลังบ้าน) ← กำลังทำช่วงนี้
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: สร้างตัวกลาง (service) ที่ยิง HTTP ไปหาหลังบ้าน

Angular Service & NestJS API CRUD Integration

1.1 รันทั้งสองฝั่ง

Terminal ที่ 1

powershell
cd C:\webdev\booklab
npm run dev:api

Terminal ที่ 2

powershell
cd C:\webdev\booklab
npm run dev:web

1.2 ทดลองเห็นปัญหาก่อนแก้

เปิด localhost:4200 แล้วกด F12 เปิดเครื่องมือนักพัฒนา → เลือกแท็บ Network

💡 แท็บ Network คือเครื่องมือ debug ที่สำคัญที่สุดของงานฝั่งหน้าบ้าน มันแสดงทุกคำขอที่หน้าเว็บส่งออกไป พร้อมคำตอบที่ได้กลับมา ตั้งแต่คาบนี้ไป เวลาหน้าเว็บว่าง ให้เปิดตรงนี้ดูก่อนเสมอ ว่า

  1. ส่งคำขอออกไปหรือเปล่า
  2. ส่งไปที่ URL ถูกไหม
  3. ได้อะไรกลับมา

พิมพ์ http://localhost:3000/api/books ในเบราว์เซอร์

✅ ต้องได้ข้อมูลหนังสือ JSON กลับมาจาก API ฝั่งหลังบ้าน (port 3000)

💡 เข้าใจการทำงานของ CORS หน้าบ้าน Angular (port 4200) และหลังบ้าน NestJS (port 3000) รันอยู่คนละ port กัน ปกติเบราว์เซอร์จะมีกฎความปลอดภัยชื่อ CORS (Cross-Origin Resource Sharing) บล็อกไม่ให้ดึงข้อมูลข้าม port การที่เราใส่ app.enableCors() ไว้ที่ไฟล์ main.ts ของ NestJS ตั้งแต่คาบที่ 1 ทำให้ Angular สามารถยิง HTTP มาที่ http://localhost:3000/api/books ได้โดยตรงอย่างปลอดภัย

1.3 เปิดใช้ HttpClient

แก้ apps/web/src/app/app.config.ts

typescript
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(),
  ],
};

💡 HttpClient = เครื่องมือของ Angular สำหรับส่งคำขอไปยัง API เปรียบเหมือนรถที่ใช้วิ่งไปกลับ 💡 provideHttpClient() = เปิดใช้งาน ต้องประกาศไว้ที่นี่ครั้งเดียว แล้วทุก component ใช้ได้หมด


ช่วงที่ 2 — สร้างคนวิ่งส่งของ (25 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: ใช้ service ที่สร้างไว้ดึงข้อมูลจริงมาแสดง

2.1 สร้าง service

powershell
cd apps\web
npm exec -- ng generate service books/book --injectable --skip-tests --type=service

⚠️ ต้องใส่ flag --injectable --type=service เสมอ Angular เวอร์ชันนี้เปลี่ยนค่าเริ่มต้นของคำสั่งนี้ไปสร้างโค้ดแบบใหม่ที่ชื่อ @Service() แทน @Injectable() ที่เราใช้กันทั้งวิชา ถ้าลืมใส่ทั้งสอง flag นี้ โค้ดที่ได้จะไม่ตรงกับที่เรียนมาและใช้งานไม่ได้ จำไว้ว่าเวลาสร้าง service ใน Angular ต้องพิมพ์ยาวแบบนี้เสมอ

✅ ต้องเห็น ไฟล์ apps/web/src/app/books/book.service.ts เกิดขึ้น เปิดดูจะเป็นโครงเปล่า ๆ แบบนี้

typescript
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class BookService {}

แก้ไฟล์นี้ให้เป็นแบบนี้

typescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { Book } from './book';

@Injectable({ providedIn: 'root' })
export class BookService {
  private http = inject(HttpClient);
  private apiUrl = 'http://localhost:3000/api/books';

  getAll(q: string = ''): Promise<Book[]> {
    return firstValueFrom(this.http.get<Book[]>(this.apiUrl, { params: { q } }));
  }

  getById(id: number): Promise<Book> {
    return firstValueFrom(this.http.get<Book>(`${this.apiUrl}/${id}`));
  }
}

💡 service ฝั่ง Angular คือคนวิ่งส่งของ หน้าเว็บอยากได้ข้อมูล ก็ให้คนนี้วิ่งไปติดต่อ API แทน component ทุกตัวเรียกใช้คนเดียวกันได้ ไม่ต้องเขียนโค้ดยิง API ซ้ำในทุกหน้า

💡 @Injectable({ providedIn: 'root' }) = สร้างไว้ตัวเดียวใช้ร่วมกันทั้งแอป 💡 inject(HttpClient) = ขอ HttpClient มาใช้ 💡 private apiUrl = 'http://localhost:3000/api/books'; = URL ของ API ฝั่งหลังบ้าน

💡 this.http.get<Book[]>(...) ตัว <Book[]> ในวงเล็บมุมคือการบอกว่า "ข้อมูลที่จะได้กลับมาเป็นรายการหนังสือ" ทำให้ VS Code ช่วยเตือนได้ถ้าเราเผลอใช้ผิดช่อง

💡 firstValueFrom(...) — ทำไมต้องมี HttpClient คืนค่าเป็นสิ่งที่เรียกว่า Observable ซึ่งใช้งานคนละแบบกับที่เราคุ้น เราจึงห่อด้วย firstValueFrom เพื่อแปลงเป็น Promise ที่ใช้ await ได้ตามที่เรียนมาในคาบแรก

💡 ${this.apiUrl}/${id} = ต่อ URL เพื่อระบุ id ของหนังสือที่ต้องการ

⚠️ ยังต้องมีไฟล์ book.ts (interface) ตามที่สร้างไว้แล้วในคาบที่ 3 ข้อ 2.2 ถ้ายังไม่มีให้กลับไปสร้างก่อน เพราะ service ตัวนี้ import จากไฟล์นั้น


ช่วงที่ 3 — ดึงข้อมูลมาแสดง (35 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • 🟢 NestJS (หลังบ้าน) ← กำลังทำช่วงนี้
  • 🟢 PostgreSQL (ฐานข้อมูล) ← กำลังทำช่วงนี้

ต่อไป: เพิ่มช่องค้นหาที่ส่งคำค้นไปกรองข้อมูลถึงฐานข้อมูล

3.1 เปลี่ยนจากข้อมูลจำลองเป็นของจริง

แทนที่ apps/web/src/app/books/book-list/book-list.ts

typescript
import { Component, signal, inject, OnInit } from '@angular/core';
import { BookService } from '../book.service';
import { BookCard } from '../book-card/book-card';
import { Book } from '../book';

@Component({
  selector: 'app-book-list',
  imports: [BookCard],
  templateUrl: './book-list.html',
  styleUrl: './book-list.css',
})
export class BookList implements OnInit {
  private bookService = inject(BookService);

  books = signal<Book[]>([]);
  loading = signal(false);
  error = signal('');

  ngOnInit() {
    this.load();
  }

  async load() {
    this.loading.set(true);
    this.error.set('');
    try {
      const result = await this.bookService.getAll();
      this.books.set(result);
    } catch {
      this.error.set('โหลดข้อมูลไม่สำเร็จ กรุณาตรวจว่า API ทำงานอยู่');
    } finally {
      this.loading.set(false);
    }
  }
}

💡 ngOnInit() = ฟังก์ชันที่ Angular เรียกให้อัตโนมัติตอน component ถูกสร้างเสร็จ เป็นจังหวะที่เหมาะกับการโหลดข้อมูลครั้งแรก

💡 try / catch / finally = ดักความผิดพลาด

  • try = ลองทำ
  • catch = ถ้าพัง ให้ทำอะไร
  • finally = ทำเสมอไม่ว่าจะสำเร็จหรือพัง — เราใช้ปิดสถานะกำลังโหลด

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

3.2 แสดงทั้งสามสถานะ

แทนที่ book-list.html

html
<div class="p-6 max-w-3xl mx-auto">
  <h1 class="text-2xl font-bold mb-4">รายการหนังสือ</h1>

  @if (loading()) {
    <p class="text-gray-500">กำลังโหลด...</p>
  } @else if (error()) {
    <div class="border border-red-300 bg-red-50 text-red-700 rounded p-4">
      {{ error() }}
    </div>
  } @else if (books().length === 0) {
    <p class="text-gray-500">ไม่พบข้อมูล</p>
  } @else {
    <div class="grid gap-3">
      @for (b of books(); track b.id) {
        <app-book-card [book]="b" />
      }
    </div>
  }
</div>

💡 @if / @else if / @else = เลือกแสดงอย่างใดอย่างหนึ่ง ลำดับสำคัญ Angular จะไล่จากบนลงล่างแล้วหยุดที่อันแรกที่เป็นจริง

✅ เปิด localhost:4200 ต้องเห็นหนังสือ 5 เล่มจากฐานข้อมูลจริง พร้อมป้ายหมวดหมู่

3.3 ทดลองทำให้พัง — สำคัญมาก

ไปที่ Terminal ที่รัน API แล้วกด Ctrl + C เพื่อปิด จากนั้นกด refresh หน้าเว็บ

✅ ต้องเห็นกล่องสีแดง ไม่ใช่หน้าขาว

เปิด F12 → Network จะเห็นคำขอที่ล้มเหลวเป็นสีแดง

แล้วเปิด API กลับมา (npm run dev:api) กด refresh อีกครั้ง ต้องกลับมาปกติ

💡 นี่คือขั้นตอน debug ที่จะใช้ตลอด — หน้าเว็บว่างเมื่อไหร่ ให้ถามสามคำถามตามลำดับ

  1. API รันอยู่ไหม (ดู Terminal)
  2. มีข้อมูลในฐานข้อมูลไหม (เปิด Prisma Studio)
  3. คำขอส่งไปถูก URL ไหม ได้อะไรกลับมา (ดู F12 → Network)

ช่วงที่ 4 — เพิ่มช่องค้นหา (25 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • 🟢 NestJS (หลังบ้าน) ← กำลังทำช่วงนี้
  • 🟢 PostgreSQL (ฐานข้อมูล) ← กำลังทำช่วงนี้

ต่อไป: คาบหน้า: ทำหน้ารายละเอียดและเพิ่ม-แก้-ลบข้อมูล

4.1 เพิ่มความสามารถค้นหา

แก้ book-list.ts เพิ่มตัวแปรและแก้ load()

typescript
export class BookList implements OnInit {
  private bookService = inject(BookService);

  books = signal<Book[]>([]);
  loading = signal(false);
  error = signal('');
  keyword = signal('');

  ngOnInit() {
    this.load();
  }

  async load() {
    this.loading.set(true);
    this.error.set('');
    try {
      const result = await this.bookService.getAll(this.keyword());
      this.books.set(result);
    } catch {
      this.error.set('โหลดข้อมูลไม่สำเร็จ กรุณาตรวจว่า API ทำงานอยู่');
    } finally {
      this.loading.set(false);
    }
  }

  onSearch(value: string) {
    this.keyword.set(value);
    this.load();
  }
}

4.2 เพิ่มช่องกรอกในหน้าจอ

แทรกก่อนบล็อก @if ใน book-list.html

html
<div class="flex gap-2 mb-4">
  <input
    #searchBox
    (keyup.enter)="onSearch(searchBox.value)"
    placeholder="ค้นหาชื่อหนังสือ"
    class="border rounded px-3 py-2 flex-1" />
  <button
    (click)="onSearch(searchBox.value)"
    class="px-4 py-2 bg-blue-600 text-white rounded">
    ค้นหา
  </button>
</div>

💡 #searchBox = ตั้งชื่อเล่นให้ช่องกรอกนี้ เพื่อให้อ้างถึงได้ใน HTML เดียวกัน เขียน searchBox.value ก็ได้ค่าที่ผู้ใช้พิมพ์

💡 (keyup.enter) = เมื่อกดปุ่ม Enter ในช่องนี้ให้ทำสิ่งนี้ — Angular ดักปุ่มเฉพาะได้เลย ไม่ต้องเช็ครหัสปุ่มเอง

✅ พิมพ์ clean แล้วกด Enter ต้องเหลือแค่ Clean Code

✅ พิมพ์คำที่ไม่มีจริง ต้องขึ้น "ไม่พบข้อมูล" ไม่ใช่หน้าว่าง

4.3 ดูว่าเกิดอะไรขึ้นเบื้องหลัง

เปิด F12 → Network → พิมพ์คำค้นแล้วกด Enter

✅ ต้องเห็นคำขอไปที่ /api/books?q=clean

💡 ?q=clean คือ query parameter — วิธีส่งค่าเพิ่มเติมไปกับ URL ฝั่ง API รับด้วย @Query('q') ที่เราเขียนไว้ในคาบที่ 2 แล้วส่งต่อให้ Prisma ใช้เป็นเงื่อนไข contains

ลองไล่ดูเส้นทางทั้งหมด: ช่องกรอก → onSearch()BookService → HTTP (CORS) → NestJS controller → service → Prisma → PostgreSQL แล้ววิ่งกลับทางเดิม — นี่คือภาพรวมทั้งระบบที่เราสร้างมาสี่คาบ

🚀 ถ้าเสร็จก่อนเพื่อน

  1. เพิ่มตัวเลขบอกว่าพบกี่เล่ม
  2. ทำให้ปุ่มค้นหากดไม่ได้ระหว่างกำลังโหลด (คำใบ้: [disabled]="loading()")
  3. เพิ่มปุ่มล้างคำค้นให้กลับมาแสดงทั้งหมด

❓ ปัญหาที่พบบ่อย

อาการ สาเหตุ วิธีแก้
ขึ้นกล่องแดงตลอด API ไม่ได้รัน เปิด Terminal รัน npm run dev:api
404 ใน Network URL ผิด ต้องเป็น http://localhost:3000/api/books
NullInjectorError: No provider for HttpClient ลืมใส่ใน app.config.ts เพิ่ม provideHttpClient()
ค้นหาแล้วไม่กรอง API ไม่ได้รับค่า ดู Network ว่ามี ?q= ต่อท้ายไหม
หน้าว่างไม่มี error ฐานข้อมูลไม่มีข้อมูล เปิด npm exec -- prisma studio ตรวจ แล้ว seed ใหม่

📌 สรุปเส้นทางข้อมูลทั้งระบบ

text
ผู้ใช้พิมพ์
   ↓
Component (book-list.ts)
   ↓
Service (book.service.ts) → HttpClient
   ↓
HTTP Request (CORS: 4200 → 3000)
   ↓
NestJS Controller (@Query)
   ↓
NestJS Service → Prisma
   ↓
PostgreSQL
   ↓
วิ่งกลับทางเดิมมาแสดงบนหน้าจอ

คาบหน้า ทำหน้ารายละเอียด และเพิ่ม–แก้–ลบ ข้อมูลได้