โหมดมืด
บทที่ 3 — Services และ Dependency Injection
หลังจบบท คุณจะ:
- เขียน service ที่ idiomatic (เป็นสำนวนมาตรฐานของ Angular ที่ชุมชนยอมรับ)
- เข้าใจ DI hierarchy (ลำดับชั้นของ DI)
- ใช้
inject()function (modern) - ใช้ providers + InjectionToken (กุญแจพิเศษสำหรับ inject ค่าที่ไม่ใช่ class)
ก่อนอ่าน: ควรผ่าน บทที่ 0-2 มาก่อน · ถ้าเคยอ่านเรื่อง Dependency Inversion ใน concepts/02-solid มาจะเข้าใจ "ทำไม" ของบทนี้เร็วขึ้น (SOLID คือชุดหลักการออกแบบโค้ดที่ดี 5 ข้อ ระดับกลาง-สูง) — แต่ไม่จำเป็นต้องอ่านก่อนก็ได้ ข้ามได้สบายใจถ้าเพิ่งเริ่ม
1. Service คืออะไร
Service คือ class ที่เก็บ "งานเบื้องหลัง" ที่ไม่ใช่เรื่องหน้าตา — แยกออกมาจาก component เพื่อให้ component โฟกัสแค่การแสดงผล ส่วนงานอย่างเรียก API, จัดการ auth, เขียน log ไปอยู่ใน service
หน้าที่หลักของ service:
- เก็บ business logic — แยกตรรกะออกจาก UI
- แชร์ข้อมูล/พฤติกรรมข้าม component — หลาย component ใช้ service เดียวกันได้
- เป็น singleton (มี object เพียงตัวเดียวทั้งแอป — ทุก component ที่ขอ UserService จะได้ instance (ตัวของ object) ตัวเดียวกัน)
ตัวอย่างที่เจอบ่อย: UserService (เรียก API ผู้ใช้), AuthService (สถานะ login), LoggerService (เขียน log), StorageService (จัดการ localStorage)
🔍 เปรียบเทียบ: ถ้า component คือ "พนักงานหน้าร้าน" ที่คุยกับลูกค้า service ก็คือ "ครัวหลังร้าน" ที่ทำงานจริง — พนักงานไม่ต้องรู้ว่าครัวทำยังไง แค่สั่งแล้วได้ของ
typescript
@Injectable({
providedIn: 'root' // มีตัวเดียวทั้งแอป (singleton), โหลดเมื่อจำเป็น (lazy)
})
export class UserService {
getUsers() {
return ['Anna', 'Ben'];
}
}2. Dependency Injection (DI) คืออะไร
Dependency Injection = "อย่าสร้างของที่ต้องใช้เอง ให้คนอื่นส่งมาให้"
ลองดูความต่าง — สมมติ component ต้องใช้ UserService:
typescript
// ❌ สร้างเอง (new it yourself)
class MyComponent {
private userService = new UserService(/* ต้องหา dependency ของมันมาเองอีก... */);
// — test ยาก (ผูกกับ service จริงเสมอ สลับเป็นของปลอมไม่ได้)
// — ถ้า UserService เปลี่ยนวิธีสร้าง ต้องตามแก้ทุกที่
}
// ✅ ให้ Angular ฉีดมาให้ (inject)
class MyComponent {
constructor(private userService: UserService) {}
// — Angular เตรียม instance (ตัวของ object) มาให้
// — ตอน test สลับเป็นของปลอม (mock) ได้ง่าย
// — เปลี่ยน implementation (วิธีทำงานจริง) ได้โดยไม่แตะ component
}🔍 เปรียบเทียบ: DI เหมือนสั่งอาหารที่ร้าน — คุณไม่ต้องเข้าครัวไปทำเอง (ไม่ต้อง
new) แค่บอกว่าต้องการอะไร (ประกาศใน constructor) แล้วมันถูกเสิร์ฟมาให้ · ใครจะทำ ทำที่ไหน เป็นเรื่องของ "ระบบ" จัดการ — เราแค่ "ขอ"
แนวคิดที่อยู่เบื้องหลังนี้คือ แทนที่เราจะคุมการสร้าง object เอง เรา "ยกการคุม" ให้ framework จัดการ lifecycle (วงชีวิต — ตั้งแต่สร้างถึงทำลาย) ให้แทน
หลักการนี้มีชื่อเรียกว่า Inversion of Control (IoC) · (เชื่อมโยงกับ Dependency Inversion ใน SOLID — เป็นเรื่องขั้นสูงที่ไม่จำเป็นต้องรู้ตอนนี้)
3. Create Service
สร้าง service ด้วย CLI ได้ในคำสั่งเดียว — มันสร้างไฟล์ .service.ts (ตัว service) + .spec.ts (ไฟล์เทสต์) ให้พร้อม:
bash
# ng g s = ng generate service (สร้าง service ใหม่)
ng g s services/user→ Generates:
text
src/app/services/
├── user.service.ts
└── user.service.spec.tstypescript
// user.service.ts
import { Injectable, signal } from '@angular/core';
// ประกาศ interface User (โครงสร้างข้อมูลผู้ใช้)
interface User { id: number; name: string; email: string; }
@Injectable({
providedIn: 'root' // ⭐ มีตัวเดียวทั้งแอป (singleton), เรียกใช้ได้จากทุกที่
})
export class UserService {
// signal<User[]>([]) = signal ที่เก็บ array ของ User เริ่มต้นเป็น []
// asReadonly() = เปิดให้อ่านอย่างเดียว (เขียนผ่าน private signal เท่านั้น) — ดูบท 2 §14
private users = signal<User[]>([]);
readonly all = this.users.asReadonly();
getAll() {
return this.users(); // คืน array ปัจจุบัน (ใช้ใน test §14)
}
add(user: User) {
this.users.update(users => [...users, user]);
}
remove(id: number) {
this.users.update(users => users.filter(u => u.id !== id));
}
}4. Inject Service — inject() เป็นหลัก
วิธี idiomatic ของ Angular ยุคใหม่ (v14+) คือเรียก inject() function — สั้น สะอาด ใช้ได้ในทั้ง class และ function (resolver, guard, interceptor) · ส่วน constructor injection แบบเก่ายังใช้ได้แต่เก็บไว้สำหรับ codebase เก่าเป็นหลัก:
A. inject() Function (Modern — Angular 14+) ⭐
typescript
import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
@Component({...})
export class UserListComponent {
private userService = inject(UserService);
ngOnInit() {
const users = this.userService.all();
}
}→ ใช้แบบนี้เป็นหลักในโค้ดใหม่ทุกที่ — component, service, guard, resolver, interceptor
B. Constructor Injection (Legacy — แบบเก่า, เจอในโค้ดเก่า)
typescript
import { Component } from '@angular/core';
import { UserService } from './user.service';
@Component({...})
export class UserListComponent {
constructor(private userService: UserService) {}
ngOnInit() {
const users = this.userService.all();
}
}→ พบในโปรเจกต์ก่อน v14 หรือ codebase ที่ยังไม่ migrate · อ่านได้แต่ไม่ควรเขียนใหม่แบบนี้
ทำไมต้องใช้ inject()
text
ฟังก์ชัน inject() ดีกว่าตรงที่:
- ไม่ต้องมี constructor ที่รับ param ยาวเป็นพรืด
- ใช้ในฟังก์ชันได้ด้วย (route resolver, guard, interceptor) — constructor ทำไม่ได้
- เข้ากับ inheritance ได้ง่ายกว่า (ไม่ต้องส่ง dep ผ่าน super())
- เป็น API ที่ Angular ทีมจะใส่ feature ใหม่ ๆ ให้ (signal queries, etc.)
→ โค้ดใหม่ใช้ inject() เสมอ · constructor injection เก็บไว้อ่านของเก่า5. @Injectable Decorator
@Injectable คือป้ายบอกว่า class นี้ "ฉีดเข้า DI ได้" · ค่า providedIn กำหนดขอบเขตว่ามี instance เดียวระดับไหน — 'root' (ทั้งแอป) คือที่ใช้บ่อยสุดและแนะนำ เพราะ tree-shakable (ถ้าไม่มีใครใช้ จะถูกตัดออกจาก bundle (ไฟล์ JavaScript ที่รวมโค้ดทั้งหมดไว้สำหรับส่งให้เบราว์เซอร์)):
⚠️ ตัวอย่างข้างล่างเป็น 3 ทางเลือก (เลือกใส่ตัวใดตัวหนึ่งบน class) — ไม่ใช่ stack รวมกันบน class เดียว ·
@Injectableต้องอยู่บน class declaration เสมอ:
typescript
// 1) ทั้งแอปมี instance เดียว — ใช้บ่อยสุด
@Injectable({ providedIn: 'root' })
export class LoggerService { /* ... */ }
// 2) แชร์ข้ามทุกแอป Angular ในเบราว์เซอร์เดียวกัน — แทบไม่ได้ใช้
@Injectable({ providedIn: 'platform' })
export class PlatformBusService { /* ... */ }
// 3) ไม่ provide ที่นี่ — ต้องประกาศ provide เองที่อื่น (component / route / app.config)
@Injectable()
export class ManualService { /* ... */ }📌 สรุปสั้น ๆ scope ของ
providedIn:
'root'= ทั้งแอป (ใช้บ่อย — เริ่มที่นี่ทุกครั้ง)'platform'= แชร์ข้ามหลายแอป Angular ในเบราว์เซอร์เดียว · เฉพาะกรณีมีหลาย Angular app อยู่ในหน้าเดียว (ใช้น้อย)'any'= เลิกใช้ (deprecated/removed ในเวอร์ชันหลัง ๆ) — ตรวจสอบสถานะ/เวอร์ชันล่าสุดในเอกสารทางการก่อนใช้ · ถ้าต้องการ instance แยกต่อ route ให้ provide เองใน route providers แทน- environment injector (injector ระดับ route ที่แยกจาก root) / lazy boundaries (ขอบเขตที่โหลดแบบ lazy) — เรื่องขั้นสูง
providedIn: 'root' (Default Recommended)
typescript
@Injectable({ providedIn: 'root' })
export class LoggerService { }
// → Angular สร้าง instance เดียวให้ทั้งแอปอัตโนมัติ
// → tree-shakable: ถ้าไม่มีใครเรียกใช้ จะถูกตัดออกจาก bundle (ทำให้ไฟล์เล็กลง)6. DI Hierarchy
DI ของ Angular เป็น "ลำดับชั้น" ตามต้นไม้ component — เราเลือกได้ว่าจะ provide service ที่ระดับไหน (ทั้งแอป / เฉพาะ route subtree หรือที่เรียกว่าต้นไม้ย่อยของ route (กลุ่ม route/component ที่อยู่ลึกลงไปจากตำแหน่งหนึ่ง) / เฉพาะ component) · เวลาขอ service Angular จะ "ไล่หาจากล่างขึ้นบน" จนเจอตัวแรก โดย:
- ล่าง = component ที่ขอ หรือ leaf node ในแผนภาพด้านล่าง
- บน = Application root
Component-level Provider
typescript
@Component({
selector: 'app-feature',
providers: [FeatureService] // สร้าง instance ใหม่ต่อ component
})
export class FeatureComponent {
private svc = inject(FeatureService); // inject() แทน constructor injection
}→ FeatureComponent แต่ละตัวได้ FeatureService เป็นของตัวเอง
ลำดับการไล่หา (Lookup Algorithm)
💡 injector (ตัวจัดหา dependency) = object (วัตถุ) ภายในที่เก็บข้อมูลว่า "เมื่อใครขอ X จะส่งอะไรไป" — Angular มี injector หลายตัวซ้อนกันตามโครง component/route และไล่ขึ้นบนจนเจอ:
text
1. ดู providers ของ component ปัจจุบัน
2. ไล่ขึ้นไปตามต้นไม้ component
3. ดู providers ของ route
4. ดู providers ระดับแอป
5. ถ้าไม่เจอ → โยน error7. Provide Configuration
"Provide" คือการบอก Angular ว่า "เมื่อมีคนขอ X ให้ส่งอะไรไป" · ตั้งได้ 3 ระดับตามขอบเขตที่ต้องการ — ทั้งแอป (app.config), เฉพาะ component, หรือเฉพาะ route subtree:
Application-level (app.config.ts)
typescript
import { ApplicationConfig } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// ที่มีมาให้ในตัว (built-in)
provideRouter(routes),
provideHttpClient(),
// ที่เราทำเอง (custom)
{ provide: API_URL, useValue: 'https://api.example.com' },
UserService,
]
};Component-level
typescript
@Component({
providers: [
LocalService,
{ provide: TOKEN, useValue: 'something' }
]
})Route-level
typescript
const routes: Routes = [
{
path: 'admin',
providers: [AdminService], // ใช้ได้ในกิ่ง route นี้และลูกของมัน
component: AdminComponent
}
];8. Provider Types
เวลา provide เราบอกได้หลายแบบว่า "ส่งอะไรไปเมื่อมีคนขอ" — ส่ง class (useClass), ส่งค่าคงที่ (useValue), ให้ฟังก์ชันคำนวณ (useFactory), หรือชี้ไปตัวที่มีอยู่ (useExisting):
useClass (Default)
typescript
{ provide: UserService, useClass: UserService } // ค่าเริ่มต้น
{ provide: UserService, useClass: MockUserService } // สลับเป็น implementation อื่น (เช่นของปลอม)
// เขียนสั้น
UserService // เหมือนกับ { provide: UserService, useClass: UserService }useValue — Constant (ค่าคงที่)
typescript
{ provide: API_URL, useValue: 'https://api.example.com' }
{ provide: CONFIG, useValue: { timeout: 5000, debug: true } }useFactory — Computed (คำนวณด้วยฟังก์ชัน)
typescript
{
provide: LoggerService,
useFactory: (config: Config) => {
return config.debug ? new VerboseLogger() : new SilentLogger();
},
// deps: [Config] = บอก Angular ว่า factory function นี้ต้องการ Config เป็น argument แรก (Angular จะ inject ให้)
deps: [Config]
}useExisting — Alias (ชื่อแทน)
typescript
{ provide: OldService, useExisting: NewService }
// inject(OldService) คืน instance ตัวเดียวกับ inject(NewService)
// ใช้เมื่อ migrate จากชื่อเก่า (OldService) ไปชื่อใหม่ (NewService) แบบค่อยเป็นค่อยไป — โค้ดเก่าที่ inject OldService ยังทำงานได้โดยไม่ต้องแก้9. InjectionToken — Non-Class Values
DI ปกติใช้ "class" เป็นกุญแจในการขอ แต่ถ้าอยาก inject ของที่ไม่ใช่ class (เช่น string URL, object config, ค่า primitive) จะใช้ class เป็นกุญแจไม่ได้ — ต้องสร้าง "กุญแจ" พิเศษด้วย InjectionToken:
typescript
import { InjectionToken } from '@angular/core';
// สร้าง token (กุญแจ)
export const API_URL = new InjectionToken<string>('API_URL');
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
interface AppConfig {
apiUrl: string;
timeout: number;
}typescript
// ประกาศ provide
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
{ provide: APP_CONFIG, useValue: { apiUrl: '...', timeout: 5000 } }
]typescript
// ดึงมาใช้ (inject)
@Injectable({ providedIn: 'root' })
export class HttpService {
private apiUrl = inject(API_URL);
private config = inject(APP_CONFIG);
}→ ใช้กับ: ค่า config ตามสภาพแวดล้อม (environment — สภาพรันงาน เช่น dev / staging / production), feature flag (สวิตช์เปิด/ปิดฟีเจอร์), ค่าคงที่แบบ primitive (ชนิดข้อมูลพื้นฐาน เช่น string, number, boolean)
With Default Factory
typescript
export const API_URL = new InjectionToken<string>('API_URL', {
providedIn: 'root',
factory: () => 'https://default-api.com'
});
// ใช้ได้โดยไม่ต้องประกาศ provider เอง (ใช้ค่า default จาก factory)
private apiUrl = inject(API_URL); // 'https://default-api.com'10. Practical Service Examples
ดู service 3 ชนิดที่เจอจริงเกือบทุกแอป — HTTP service (เรียก API), state service (เก็บสถานะแชร์ด้วย signal), และ util service (งานช่วยเหลือเช่นจัดการ localStorage):
HTTP Service
typescript
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
getAll() {
return this.http.get<User[]>(`${this.apiUrl}/users`);
}
getById(id: number) {
return this.http.get<User>(`${this.apiUrl}/users/${id}`);
}
create(user: User) {
return this.http.post<User>(`${this.apiUrl}/users`, user);
}
}State Service
typescript
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private _user = signal<User | null>(null);
private _loading = signal(false);
private _token = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly token = this._token.asReadonly(); // ใช้ใน authInterceptor (§11)
readonly isLoggedIn = computed(() => this._user() !== null);
readonly isAdmin = computed(() => this._user()?.role === 'admin');
readonly loading = this._loading.asReadonly();
// async = ฟังก์ชันที่ทำงาน asynchronous (รอผลได้โดยไม่ block) ใช้คู่กับ await — ควรรู้ JavaScript async/await พื้นฐานก่อน
async login(email: string, password: string) {
this._loading.set(true);
try {
// firstValueFrom = เอาค่าแรกจาก Observable มาเป็น Promise ใช้กับ async/await; Observable ดูบท 6
const res = await firstValueFrom(
this.http.post<{ token: string; user: User }>('/api/login', { email, password })
);
// ⚠️ localStorage มีความเสี่ยง XSS — JavaScript ที่ inject ได้อ่าน token ได้เลย
// production app อาจต้องพิจารณา httpOnly cookie แทน หรืออย่างน้อยต้องมี CSP ที่ดี
localStorage.setItem('token', res.token);
this._token.set(res.token);
this._user.set(res.user);
} finally {
this._loading.set(false);
}
}
logout() {
localStorage.removeItem('token');
this._user.set(null);
}
}Util Service
typescript
@Injectable({ providedIn: 'root' })
export class StorageService {
get<T>(key: string): T | null {
const value = localStorage.getItem(key);
if (!value) return null;
// defensive programming: ถ้า value ไม่ใช่ JSON ที่ถูกต้อง จะ return null แทน throw
try { return JSON.parse(value); } catch { return null; }
}
set<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
remove(key: string): void {
localStorage.removeItem(key);
}
clear(): void {
localStorage.clear();
}
}11. inject() นอก Component (Non-Component Context)
ข้อดีใหญ่ของ inject() คือใช้ได้นอก class ด้วย — ในฟังก์ชันอย่าง route resolver, guard, interceptor (สิ่งที่ constructor injection ทำไม่ได้) นี่คือเหตุผลหลักที่ Angular ยุคใหม่เชียร์ inject():
📖 ประเภทฟังก์ชันที่ใช้ใน section นี้:
ResolveFn,CanActivateFn= ประเภทฟังก์ชันสำหรับ Route guard และ resolver (จะสอนในบทที่ 4),HttpInterceptorFn= ฟังก์ชันที่ดักจัดการ HTTP request (คำขอ) / response (การตอบกลับ) (บทที่ 6)
typescript
// Route resolver — ดึงข้อมูลก่อนเข้าหน้า (Angular 16+)
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(UserService);
const id = Number(route.paramMap.get('id'));
return userService.getById(id);
};typescript
// Route guard — ควบคุมว่าเข้าหน้าได้ไหม
import { CanActivateFn } from '@angular/router';
import { inject } from '@angular/core';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
return true;
};typescript
// HTTP interceptor — ดักแก้ทุก request/response (คำขอ/การตอบกลับ) (Angular 15+)
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};→ inject() ทำงานได้ใน function context — ไม่ใช่แค่ class
12. Optional Inject
บางครั้ง service ที่ขออาจมีหรือไม่มีก็ได้ — ใส่ { optional: true } แล้วถ้าไม่เจอจะได้ null แทนที่จะ error · ส่วน skipSelf/self/host ใช้ควบคุมว่าจะให้ไล่หาในลำดับชั้น DI แค่ไหน:
typescript
import { inject } from '@angular/core';
@Injectable({...})
export class MyService {
// ถ้าไม่เจอ provider → คืน null (แทนที่จะ error)
private logger = inject(LoggerService, { optional: true });
log(msg: string) {
this.logger?.log(msg); // เรียกแบบปลอดภัย (ถ้า null ก็ไม่พัง)
}
}🚧 DestroyRef + takeUntilDestroyed (Angular 16+) — ข้ามไปก่อนได้ · กลับมาอ่านเมื่อผ่านบทที่ 6 (RxJS) แล้ว เนื้อหานี้ใช้
Observable+subscribe()(ล้าง subscription หรือการรับฟังข้อมูลให้อัตโนมัติ) ที่จะสอนในบท 6 — ตอนนี้แค่รู้ว่า "Angular 16+ มีDestroyRefช่วย auto-cleanup subscribe ให้" ก็พอ จะมีตัวอย่างเต็มในบท 6 (ขอวางไว้ตรงนี้เพราะเป็น "เคสที่ต้องใช้inject()แบบมี option" — เกี่ยวกับ DI โดยตรง)
inject() option object — skipSelf/self/host/optional
🎯 Modern API (Angular 14+): decorator form
@SkipSelf(),@Self(),@Host(),@Optional()ยังใช้ได้ แต่ idiom (วิธีเขียนที่เป็นมาตรฐาน/สำนวนที่ชุมชนยอมรับ) ปัจจุบัน คือ option object ของinject():
typescript
import { inject } from '@angular/core';
export class MyComponent {
// ข้าม injector ปัจจุบัน แล้วไล่หาขึ้นไป
private parentService = inject(ParentService, { skipSelf: true });
// หาเฉพาะใน injector ปัจจุบันเท่านั้น
private localService = inject(LocalService, { self: true });
// ไล่หาขึ้นไปได้แค่ถึง host component
private hostService = inject(HostService, { host: true });
// มีก็เอา ไม่มีคืน null
private optional = inject(OptionalService, { optional: true });
// รวมกันได้
private parentOptional = inject(ParentService, { skipSelf: true, optional: true });
}
// ❌ ของเก่า (ยังใช้ได้แต่ไม่แนะนำในโค้ดใหม่):
// constructor(@SkipSelf() private parentService: ParentService) {}13. Hierarchical Injectors (Injector แบบลำดับชั้น) — Use Case
ประโยชน์จริงของ DI แบบลำดับชั้น: provide service เดียวกันที่ระดับ component ทำให้แต่ละส่วนของหน้ามี instance แยกกัน (เช่น theme คนละสีต่อ section) — ตัวอย่างนี้แสดงให้เห็น:
typescript
// Theme service — แยกคนละตัวต่อ section
@Injectable()
export class ThemeService {
color = signal('blue');
}
// Section A: สีน้ำเงิน
@Component({
providers: [ThemeService]
})
export class SectionA { }
// Section B: สีแดง
@Component({
providers: [
{ provide: ThemeService, useFactory: () => {
const s = new ThemeService();
s.color.set('red');
return s;
}}
]
})
export class SectionB { }→ แต่ละ section มี ThemeService เป็นของตัวเอง
14. Testing — Mock Service
นี่คือผลตอบแทนใหญ่ของ DI: ตอนเทสต์เราสลับ service จริงเป็น "ของปลอม" (mock) ได้ง่าย แค่ provide ตัว mock แทน — component ไม่รู้ตัว ทำให้เทสต์เร็วและไม่พึ่ง API จริง:
📖 Jasmine คือ testing framework ที่มาพร้อม Angular · SpyObj คือ object ปลอม (mock) ที่สามารถติดตามการเรียกใช้งานได้ —
jasmine.createSpyObj('UserService', ['getAll'])สร้าง mock ที่มี methodgetAllให้เรียกใช้ในเทสต์ได้ ·describe/beforeEach/it/expectเป็นฟังก์ชันที่ Jasmine เตรียมไว้ให้ใช้ได้เลยทั่วทั้งไฟล์ test โดยไม่ต้อง import — เป็นธรรมเนียมของ testing framework
typescript
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs'; // of([...]) = สร้าง Observable ที่ emit array นี้ครั้งเดียวแล้วจบ (RxJS utility — บทที่ 6)
describe('UserListComponent', () => {
let mockUserService: jasmine.SpyObj<UserService>;
beforeEach(() => {
mockUserService = jasmine.createSpyObj('UserService', ['getAll']);
mockUserService.getAll.and.returnValue(of([
{ id: 1, name: 'Test' }
]));
TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }
]
});
});
it('should load users', () => {
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
expect(mockUserService.getAll).toHaveBeenCalled();
});
});→ สลับ UserService จริงเป็นของปลอม — ทำให้ test แยกขาด ไม่พึ่งของจริง
15. Common Services Patterns
2 แพตเทิร์นที่ช่วยจัดระเบียบเมื่อแอปโต — Repository (รวมการเข้าถึงข้อมูลไว้ที่เดียว) และ Facade (ตัวกลางที่ประสาน repo + store + log ให้ component เรียกง่าย ๆ):
Repository Pattern (รวมการเข้าถึงข้อมูลไว้ที่เดียว)
💡
Observable<T>= "สายข้อมูล async" ที่HttpClientคืนมา — ตอนนี้รู้แค่ว่ามันคือผลลัพธ์ที่ค่อย ๆ ส่งออกมา · สอนเต็มในบท 6 (RxJS)
typescript
@Injectable({ providedIn: 'root' })
export class UserRepository {
private http = inject(HttpClient);
findAll(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
findById(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
save(user: User): Observable<User> {
return user.id
? this.http.put<User>(`/api/users/${user.id}`, user)
: this.http.post<User>('/api/users', user);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`/api/users/${id}`);
}
}Facade Pattern (ตัวกลางที่ซ่อนความซับซ้อน)
📖
UserStoreในตัวอย่างนี้มี method เพิ่มเติมจากตัวอย่าง basic ในบทก่อน —setLoading(),setError(),setUsers()เป็น method ที่ Facade ใช้เพื่อ update state:typescript@Injectable({ providedIn: 'root' }) export class UserStore { private _users = signal<User[]>([]); private _loading = signal(false); private _error = signal<Error | null>(null); readonly users = this._users.asReadonly(); setUsers(users: User[]) { this._users.set(users); } setLoading(v: boolean) { this._loading.set(v); } setError(e: Error | null) { this._error.set(e); } }
typescript
@Injectable({ providedIn: 'root' })
export class UserFacade {
private repo = inject(UserRepository);
private store = inject(UserStore);
private logger = inject(LoggerService);
async loadAll() {
this.logger.log('Loading users');
this.store.setLoading(true);
try {
// firstValueFrom = ดึงค่าแรกจาก Observable เป็น Promise (บท 6)
const users = await firstValueFrom(this.repo.findAll());
this.store.setUsers(users);
} catch (e) {
this.store.setError(e as Error);
this.logger.error('Failed to load', e);
} finally {
this.store.setLoading(false);
}
}
}→ Component เรียก Facade — facade ประสานงาน store + repo + log ให้
16. Provide via Function (Recommended)
Angular ยุคใหม่นิยม provide ผ่าน "ฟังก์ชัน" (provideRouter, provideHttpClient, ...) แทนการเขียน object ยาว ๆ — สั้นกว่า อ่านง่ายกว่า และ type-safe (ปลอดภัยด้านชนิดข้อมูล — TypeScript ตรวจให้ก่อน compile) กว่า · library สมัยใหม่ก็ทำ provideXxx ของตัวเองมาให้:
Angular ยุคใหม่ = ใช้ฟังก์ชัน provide*:
typescript
// แบบเก่า — เยิ่นเย้อ
providers: [
UserService,
{ provide: API_URL, useValue: '...' },
// multi: true = บอกว่า token นี้มี provider ได้หลายตัว (list) — Angular จะรวมทั้งหมดเป็น array
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
// แบบใหม่ — ใช้ฟังก์ชัน (Angular 14+)
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideAnimations(),
provideAnimationsAsync(),
]Custom provideXxx
typescript
// library จะเตรียมฟังก์ชันแบบนี้มาให้
// EnvironmentProviders = type พิเศษของ Angular สำหรับ providers ระดับ root/platform — ใช้ makeEnvironmentProviders() สร้าง
export function provideMyFeature(config: MyConfig): EnvironmentProviders {
return makeEnvironmentProviders([
MyService,
{ provide: MY_CONFIG, useValue: config }
]);
}
// ฝั่งคนใช้
providers: [
provideMyFeature({ apiUrl: '...', timeout: 5000 })
]17. Application Initializer
บางทีต้องรันโค้ดให้เสร็จ "ก่อน" แอปเริ่มทำงาน — เช่นโหลด config จากเซิร์ฟเวอร์ก่อน · Angular ยุคใหม่ใช้ provideAppInitializer() — สั้น สะอาด · ของเก่า APP_INITIALIZER ยังใช้ได้แต่ถือเป็นแบบเก่ากว่า — ตรวจสอบสถานะ/เวอร์ชันล่าสุดในเอกสารทางการก่อนใช้:
Modern — provideAppInitializer() ⭐
typescript
import { provideAppInitializer, inject } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() => {
const configService = inject(ConfigService);
return configService.load(); // คืน Promise — Angular รอก่อน bootstrap
})
]
};→ แอปจะรอ Promise ให้เสร็จก่อนค่อย bootstrap (เริ่มต้นรัน Angular application — ไม่เกี่ยวกับ Bootstrap CSS framework)
Legacy — APP_INITIALIZER (เจอในโค้ดเก่า)
typescript
import { APP_INITIALIZER, inject } from '@angular/core';
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useFactory: () => {
const configService = inject(ConfigService);
return () => configService.load();
}
}
]→ ถือเป็นแบบเก่ากว่า — อ่านเข้าใจของเก่าได้ แต่โค้ดใหม่ใช้ provideAppInitializer() แทน
18. Environment Configuration
ค่าที่ต่างกันระหว่าง dev กับ prod (เช่น URL ของ API) เก็บแยกในไฟล์ environment.ts — ตอน build production Angular สลับไฟล์ให้อัตโนมัติ ทำให้โค้ดไม่ต้องมี if (production) กระจาย:
typescript
// environments/environment.ts (สำหรับ dev)
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api'
};
// environments/environment.prod.ts (สำหรับ production)
export const environment = {
production: true,
apiUrl: 'https://api.example.com'
};typescript
import { environment } from '../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private url = environment.apiUrl;
}📖 ในโปรเจกต์ที่สร้างด้วย Angular CLI (
ng new) ไฟล์ environment จะถูกตั้งค่าไว้ในangular.jsonให้อัตโนมัติแล้ว — ใช้ได้เลยโดยไม่ต้องตั้งค่าเพิ่ม
→ ตอน build production Angular สลับไฟล์ให้:
bash
ng build --configuration=production19. Common Patterns
2 แพตเทิร์น service ที่เจอบ่อย — singleton ที่ถือ state (เช่น service แจ้งเตือน toast ที่เรียกได้จากทุกที่) และ pluggable provider (สลับ implementation ตาม config เช่นเลือก payment gateway ผ่าน interface + token):
Singleton with State
typescript
@Injectable({ providedIn: 'root' })
export class NotificationService {
private _messages = signal<Notification[]>([]);
readonly messages = this._messages.asReadonly();
show(message: string, type: 'success' | 'error' = 'success') {
const id = Date.now();
this._messages.update(msgs => [...msgs, { id, message, type }]);
setTimeout(() => this.dismiss(id), 3000);
}
dismiss(id: number) {
this._messages.update(msgs => msgs.filter(m => m.id !== id));
}
}เรียกจากที่ไหนก็ได้:
typescript
@Component({...})
export class FooComponent {
private notify = inject(NotificationService);
onSave() {
this.notify.show('Saved!');
}
}Pluggable Provider (สลับ implementation (วิธีทำงานจริง) ได้)
typescript
// Interface (สัญญาว่าต้องมี method อะไร)
export interface PaymentGateway {
charge(amount: number): Promise<Payment>;
}
// Token (กุญแจ)
export const PAYMENT_GATEWAY = new InjectionToken<PaymentGateway>('PaymentGateway');
// implementation (ตัวทำงานจริงแต่ละแบบ)
@Injectable()
export class StripeGateway implements PaymentGateway {
async charge(amount: number) { /* Stripe */ }
}
@Injectable()
export class PayPalGateway implements PaymentGateway {
async charge(amount: number) { /* PayPal */ }
}
// เลือก provide ตาม config
providers: [
{
provide: PAYMENT_GATEWAY,
useClass: environment.paymentProvider === 'stripe' ? StripeGateway : PayPalGateway
}
]
// ใช้
@Component({...})
export class CheckoutComponent {
private payment = inject(PAYMENT_GATEWAY);
async pay() {
await this.payment.charge(100);
}
}20. ⚠️ Common Pitfalls
ข้อผิดพลาดเรื่อง service/DI ที่เจอบ่อย — ที่พบสุดคือ new UserService() เองแทนที่จะ inject (ทำให้ test ยาก) ตารางนี้รวมไว้ครบ:
| ❌ ที่มักทำผิด | ✅ ที่ถูก |
|---|---|
new UserService() ตรง ๆ | ขอผ่าน DI (inject) |
ลืม @Injectable | ใส่ decorator |
| provide ซ้ำหลายที่ (NgModule + 'root') | provide ที่เดียว |
| inject นอก injection context | ใช้ runInInjectionContext |
| ใช้ระดับ component กับ state ที่ต้องแชร์ | ใช้ providedIn: 'root' |
| constructor รับพารามิเตอร์ยาวเป็นพรืด | ใช้ inject() |
| mock class ตรง ๆ | ใช้ interface + token |
| dependency วนกันเอง (circular) | แก้การวนซ้ำ (ทำลายการเรียกหากัน) — แยกเป็น service ที่ 3 ที่เป็น facade (ตัวกลางประสาน) หรือใช้ event bus (ระบบส่งข้อความผ่านศูนย์กลางแทนการเรียกตรง) |
21. Service vs Component — Decision
สับสนว่าอะไรควรเป็น service อะไรควรเป็น component? กฎง่าย ๆ: มี UI → component · ไม่มี UI (logic/data/state ที่ใช้ซ้ำ) → service:
text
Service:
✅ ไม่มี UI
✅ logic ที่ใช้ซ้ำได้
✅ state ที่แชร์กัน
✅ อยู่ยาว (singleton)
✅ เชื่อมต่อภายนอก (HTTP, WebSocket)
Component:
✅ มี UI (template)
✅ เจาะจงกับหน้าจอนั้น
✅ ชั่วคราว (ต่อหน้า/ต่อ section)22. Real Example — Auth Flow
รวมทุกอย่างในบทเป็นระบบ login จริง — InjectionToken (API URL) + AuthService (signal เก็บ user) + interceptor (แนบ token ทุก request) + guard (กันหน้า) · ดูว่าชิ้นส่วน DI ทั้งหมดทำงานร่วมกันยังไง:
typescript
// api.token.ts
import { InjectionToken } from '@angular/core';
export const API_URL = new InjectionToken<string>('API_URL', {
providedIn: 'root',
factory: () => 'http://localhost:3000/api'
});typescript
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
private router = inject(Router);
private _user = signal<User | null>(null);
// guard typeof localStorage เพื่อป้องกัน SSR (Angular Universal) ที่ไม่มี localStorage
private _token = signal<string | null>(typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null);
readonly user = this._user.asReadonly();
readonly token = this._token.asReadonly();
readonly isLoggedIn = computed(() => this._user() !== null);
constructor() {
// โหลดจาก storage ตอนเริ่ม
// ⚠️ side effect ใน constructor ของ root service จะรันตอน app startup
// ก่อน component แรกจะ mount — ถ้าทำงานกับ SSR/test ที่ deterministic
// ควรย้ายไปทำใน provideAppInitializer() แทน
// ✅ ตัวอย่างนี้ทำงานได้ — สำหรับมือใหม่ที่ยังไม่มี SSR ใช้แบบนี้ก่อนได้
if (this._token()) {
this.fetchUser();
}
}
async login(email: string, password: string) {
const res = await firstValueFrom(
this.http.post<{ token: string; user: User }>(`${this.apiUrl}/login`, { email, password })
);
localStorage.setItem('token', res.token);
this._token.set(res.token);
this._user.set(res.user);
this.router.navigate(['/dashboard']);
}
logout() {
localStorage.removeItem('token');
this._token.set(null);
this._user.set(null);
this.router.navigate(['/login']);
}
private async fetchUser() {
try {
const user = await firstValueFrom(this.http.get<User>(`${this.apiUrl}/me`));
this._user.set(user);
} catch {
this.logout(); // token ใช้ไม่ได้
}
}
}typescript
// auth.interceptor.ts
// 💡 catchError / throwError / .pipe() เป็นของ RxJS — สอนเต็มในบท 6 · ตอนนี้แค่รู้ว่ามันคือ "ดักจัดการ error จาก HTTP"
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// 🔴 inject() ต้องเรียกที่ top ของ function (ตอนนี้ยังอยู่ใน injection context)
// callback ใน .pipe() ทำงานทีหลัง = ออกจาก context แล้ว = NG0203 error
const auth = inject(AuthService);
const token = auth.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req).pipe(
catchError(err => {
if (err.status === 401) {
auth.logout(); // ✅ ใช้ reference ที่ inject ไว้แล้ว
}
return throwError(() => err);
})
);
};📌 ทำไม
inject()ต้องอยู่บนสุด: เพราะ callback ใน.pipe()(เช่นcatchError,tap) ทำงาน "หลัง" interceptor setup เสร็จ — ตอนนั้นเราออกจาก injection context แล้ว เรียกinject()ไม่ได้ จะเจอ errorNG0203
typescript
// auth.guard.ts
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
return true;
};typescript
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
]
};typescript
// routes
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [authGuard]
}
];23. Checkpoint
ลองสร้าง service จริง 5 แบบด้วยตัวเอง ไล่จากง่าย (logger) ไปจนถึง pluggable + เทสต์ด้วย mock — ทำครบแล้วจะเข้าใจ DI ทะลุ:
🛠️ Checkpoint 3.1 — Logger Service
- สร้าง method สำหรับ log, warn, error
- inject ใน component หลายตัว
- เพิ่มการกรอง level (เช่น แสดงเฉพาะ warn ขึ้นไป)
- inject เป็น interceptor ด้วย
🛠️ Checkpoint 3.2 — Theme Service
- Service ที่มี signal ของ theme ('light' | 'dark')
- method สำหรับ toggle
- บันทึกลง localStorage
- ใช้ใน 3 component
🛠️ Checkpoint 3.3 — Configurable Service
- ConfigService พร้อม InjectionToken
- provide config ผ่าน useValue
- config ต่างกันระหว่าง dev กับ prod (environments)
🛠️ Checkpoint 3.4 — Pluggable
- Interface
Storageที่มี get/set - Implementations: LocalStorage, SessionStorage, MemoryStorage
- provide ตาม env variable
- inject แล้วใช้งาน
🛠️ Checkpoint 3.5 — Test with Mock
- Service ที่ depend on HttpClient
- เขียน test โดย mock HttpClient
- ตรวจสอบ call arguments
24. สรุปบท
ทบทวนแกนของบท — service เก็บ logic/state, DI ฉีดให้โดยไม่ต้อง new (ทำให้ test + สลับง่าย) ถ้าจับ 3 คำนี้ได้: @Injectable, inject(), providedIn: 'root' ก็พอใช้งานจริงได้แล้ว:
✅ Service = TypeScript class สำหรับ logic / data / state — ไม่มี UI ✅ @Injectable({ providedIn: 'root' }) = singleton (มีตัวเดียวทั้งแอป — ใช้บ่อยสุด) ✅ DI = ฉีด dependency เข้ามา — ไม่ต้อง new, test/สลับได้ง่าย ✅ inject() function = วิธี idiomatic ยุคใหม่ (ใช้แทน constructor injection) ✅ Provider types: useClass, useValue, useFactory, useExisting ✅ InjectionToken = inject ของที่ไม่ใช่ class (config, primitive) ✅ DI hierarchy: component → route → app (Angular ไล่หาขึ้นบน) ✅ inject() ใช้ในฟังก์ชันได้ด้วย: route resolver, guard, interceptor ✅ provide* functions (Angular 14+) — เซ็ตอัพแบบสั้น สะอาด ✅ Environment config ผ่าน environments/environment.ts + token ✅ Repository / Facade patterns — จัดระเบียบเมื่อแอปโต ✅ Mock service ใน test ได้โดยสลับ provider