Skip to content

บทที่ 3 — Services และ Dependency Injection

← บทที่ 2 | สารบัญ | บทที่ 4 →

หลังจบบท คุณจะ:

  • เขียน 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.ts
typescript
// 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) — เรื่องขั้นสูง
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. ถ้าไม่เจอ → โยน error

7. 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 ที่มี method getAll ให้เรียกใช้ในเทสต์ได้ · 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 ให้


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=production

19. 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() ไม่ได้ จะเจอ error NG0203

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 + tokenRepository / Facade patterns — จัดระเบียบเมื่อแอปโต ✅ Mock service ใน test ได้โดยสลับ provider


← บทที่ 2 | บทที่ 4 → Routing