Angular กับ Canceled HTTP Requests
พวกเรารู้จัก routing ใช่ไหม เคย navigate ระหว่าง pages เวลาส่ง request ไป API รู้ใช่ไหมว่ามันมีเวลาอยู่ตรงนั้นเสมอ จะช้าหรือเร็วก็ควรพิจารณาการยกเลิก request เพื่อหยุดยั้งงานที่สูญเปล่านั่น
Angular ใช้กลไกที่เรียกว่า component จัดการกับ UI และออกแบบการไหลของ data ไปพร้อมกัน ซึ่งในขณะที่ data กำลังเดินทางมาถึงผ่าน life cycle hooks เช่น ngOnInit ก็มีความเป็นไปได้ที่จะเกิดการเปลี่ยน page หรือแม้แต่เปลี่ยน component ที่แสดงผลอยู่ นี่จึงเป็นที่มาของบทความนี้ หัวใจคือการสั่งยกเลิก HTTP requests
แต่เราจะเริ่มต้นตั้งแต่สร้างโปรเจกต์เพื่อให้มือใหม่ได้เห็นการพัฒนาและต่อยอดทีละเล็กทีละน้อย แบ่งได้ 4 ส่วนใหญ่ๆดังนี้
- ภาพรวมและเตรียมโปรเจกต์
- จำลอง data จาก observable
- จำลอง data จาก fake APIs
- กลไก canceled http requests
ภาพรวมและเตรียมโปรเจกต์
สมมติเรามี component อยู่ 3 ชิ้น ได้แก่ NoteComponent, MapComponent และ NoteDetailsComponent พฤติกรรมคือให้มันอยู่กันคนละหน้า ให้ NoteComponent เป็นศูนย์กลาง (หน้าแรก) มีปุ่มอยู่ในแต่ละรายการเพื่อ navigate ไปหน้า MapComponent และ NoteDetailsComponent ดังนี้
เริ่มแรกคือ NoteComponent ร้องขอ data จาก API ทันทีที่เห็นปุ่ม “แผนที่” และ “รายละเอียด” เขาอาจกดปุ่มใดก็ได้ สมมติว่าเป็นปุ่มรายละเอียด ทว่าไม่ทันที่หน้า NoteDetailsComponent จะได้แสดงผลเขาก็เปลี่ยนใจกดปุ่ม “ย้อนกลับ” เพื่อกลับไปเลือกรายการใหม่ ณ เวลาตรงนี้ที่เราต้องการให้เกิดการยกเลิก http request เหตุเพราะ NoteComponent จะร้องขอ data จาก API ใหม่เสมอ เป็นผลให้มี requests สะสมไว้ใน stack และรอคอย (pending state) จนกระทั่ง data นั้นจะมาถึง เหตุการณ์เช่นนี้แหละครับคืองานสูญเปล่าแท้จริง
แล้วเราจะจัดการอย่างไร?
คำตอบ เราจะใช้สิ่งที่เรียกว่า Interceptor กับ RxJS เพราะเมื่อใดก็ตามที่เปลี่ยน page ใหม่หรือ component ใหม่ในหน้าเดียวกันได้ร้องขอ data จาก API เราสามารถยกเลิกการร้องขอนี้ได้ทันที
มาลองดูกันครับ
เตรียมพร้อม
ขอดูเวอร์ชัน Angular CLI ก่อน
ng --version
Angular CLI: 9.0.2
Node: 10.16.0
OS: darwin x64
@angular-devkit/architect 0.900.2
@angular-devkit/core 9.0.2
@angular-devkit/schematics 9.0.2
@schematics/angular 9.0.2
@schematics/update 0.900.2
rxjs 6.5.3
เริ่มสร้าง project
เราจะสร้างโปรเจกต์ชื่อ we-note
ng new we-note
มันถามว่า Would you like to add Angular routing? ตอบ y
มันถามว่า Which stylesheet format would you like to use? เลือก css
สร้าง NoteComponent จากคำสั่ง generate file ซึ่งเป็นการ generate component เมื่อ g ย่อจาก generate, c ย่อจาก component และ components/note คือให้สร้างไว้ใน folder ชื่อ components (ตั้งอะไรก็ได้)
ng g c components/note
ผลจะได้ 4 ไฟล์อยู่ภายใน components folder ดังนี้
- note.component.css
- note.component.html
- note.component.spec.ts
- note.component.ts
ถัดไปให้สร้าง NoteDetailsComponent
ng g c components/note-details
ถ้าไปให้สร้าง MapComponent
ng g c components/map
จากนั้นเปิดไฟล์ app-routing.module.ts เพื่อสร้าง routing ระหว่าง page
const routes: Routes = [ { path: 'notes', children: [ { path: ':id/details', component: NoteDetailsComponent }, { path: ':id/map', component: MapComponent }, { path: '', component: NoteComponent }, ], }, { path: '**', redirectTo: '/notes', pathMatch: 'full' }];
รัน
ng s
เปิดไฟล์ app.component.html ทำให้เหลือแค่ router-outlet ก็พอครับ
<router-outlet></router-outlet>
ทดสอบ routing ด้วยการพิมพ์บน address bar ของ browser ดังนี้
พิมพ์
http://localhost:4200
แล้ว enter ผลคือ
พิพม์
http://localhost:4200/notes/123/details
ผล
พิมพ์
http://localhost:4200/notes/123/map
ผล
NoteComponent Linking
ตกแต่งและเพิ่ม link เพื่อไปยังหน้า note details กับ map
เปิดไฟล์ note.component.html
<h1>Notes</h1><div> <a routerLink="123/details">รายละเอียด</a> <a routerLink="123/map">แผนที่</a></div>
ไฟล์ note.component.css
div { background-color: gainsboro;}div a { margin-right: 8px;}a { display: inline-flex; background-color: gray; padding: 0 10px; height: 44px; text-decoration: none; color: #f5f5f7; opacity: .8; border-radius: 4px; align-items: center;}
ผล
จำลอง data จาก observable
สำหรับมือใหม่ที่ยังไม่รู้จัก observable ขอให้อ่านบทความที่เกี่ยวข้องก่อน เช่น
NoteService
มาถึงคราวของ data กันบ้าง เราจะสร้าง service ชื่อว่า NoteService ทำหน้าที่ response data แก่ประดา components ที่ต้องการข้างต้นโดยจำลองการสุ่มค่า delay time ระหว่าง 1 ถึง 5 วินาทีด้วย observable
ng g s services/note
เปิดไฟล์ note.service.ts จะได้
import { Injectable } from '@angular/core';@Injectable({ providedIn: 'root' })export class NoteService { }
จำลอง note data ชื่อ notes เป็น array
...export class NoteService { notes: any[] = [ { id: 123, title: 'พัทยากลาง', date: new Date(2020, 2, 8, 12), node: 'พัทยากลางแหล่งอาบแดด คลื่นแรงและน้ำทะเลขุ่น' }, { id: 124, title: 'พัทยากลาง ร้านอาหาร', date: new Date(2020, 2, 8, 13), node: 'ร้านอาหารคนไทยแบบกันเอง มีฝรั่งสองคนชายหญิงเข้าใจว่าพวกเราเป็นคนจีน' }, { id: 125, title: 'alcazar show', date: new Date(2020, 2, 9, 20), node: 'เป็นโชว์ของสาวประเภทสองที่สนุกสนานมาก อลังการ บัตรไม่แพงแค่ 500 บาทต่อที่นั่ง'}, ];}
สร้างเมธอด findAll ส่งค่ากลับไปแบบ observable
...import { of } from 'rxjs';export class NoteService {... findAll(): Observable<any[]> { return of(this.notes); }}
จากนั้นเพิ่ม delay สุ่มระหว่าง 1 ถึง 5 วินาที (เพื่อนๆจะสุ่มมากหรือน้อยกว่านี้ก็ได้)
import { delay } from 'rxjs/operators';...findAll(): Observable<any[]> { return of(this.notes).pipe(delay(this.random(1, 5) * 1000));}
สร้างเมธอด random
...export class NoteService {... random(min: number, max: number):number { return Math.floor(Math.random() * max) + min; }}
NoteComponent Data is Coming
เราจะให้ NoteComponent ร้องขอ data จาก NoteService แล้วนำไปแสดงผล
เปิดไฟล์ note.component.ts ประกาศตัวแปรชื่อ notes$ ให้มีชนิดเป็น Observable
...export class NoteComponent implements OnInit { notes$: Observable<any[]>;...}
ที่ constructor ของคลาส inject NoteService เข้ามา
constructor( private noteService: NoteService,) { }
ที่เมธอด ngOnInit ร้องขอ data
ngOnInit(): void { this.notes$ = this.noteService.findAll();}
เปิดไฟล์ note.component.html วาด data ที่ได้รับมา
...<div *ngFor="let note of notes$ | async"> <span>{{note.title}}</span> <a routerLink="123/details">รายละเอียด</a> <a routerLink="123/map">แผนที่</a></div>
ผล
ระหว่างที่คอย data มาถึงเราจะพบว่ามันมีเวลาที่เห็นเป็นหน้าหรือส่วนว่างเปล่า ตรงนี้สามารถทดแทนด้วยข้อความหรือ progress spinner ได้
ปรับปรุงไฟล์ note.component.html
<h1>Notes</h1><div *ngIf="notes$ | async as notes; else loading"> <div *ngFor="let note of notes"> <span>{{note.title}}</span> <a routerLink="123/details">รายละเอียด</a> <a routerLink="123/map">แผนที่</a> </div></div><ng-template #loading> Loading...</ng-template>
จำลอง data จาก fake APIs
data ที่ได้จาก observable จะถูกยกเลิกอัตโนมัติเมื่อมีการเปลี่ยน page เราจะแก้ไขโค้ดของ NoteService เล็กน้อยเพื่อพิมพ์ log ออกมาชมดู
เปิดไฟล์ note.service.ts แก้ไขเมธอด findAll
findAll(): Observable<any[]> { return of(this.notes).pipe( delay(this.random(1, 5) * 1000), map((note: any) => { console.log(note); return note; }) );}
ผล
โค้ดข้างต้นแสดงให้เห็นว่าทุกครั้งที่หน้า NoteComponent จะถูก render นั้น observable จะยังคงทำงานเป็นปกติ กระทั่งเกิดการเปลี่ยนหน้า observable นี้จะถูก unsubscribe โดยอัตโนมัติตราบใดที่เรายังใช้ | async (อ่านเพิ่มเติม)
คำว่า fake หรือ mock ในที่นี้คือความหมายเดียวกัน กล่าวคือ data นี้ไม่ใช่ของจริง (จำลอง)
Fake Backend
ด้วย HTTP Client เรียกไปยัง APIs เราจะสร้างกลไกแทรกแซงเพื่อปั้น data (จำลอง) เป็น response กลับไปโดยอาศัย HttpInterceptor
สร้าง Interceptor ชื่อ FakeBackendInterceptor
ng g interceptor interceptors/fake-backend
สิ่งที่ได้ไม่ต่างจาก ng generate อื่นๆ กล่าวคือได้ folder ชื่อ interceptors และมีคลาส FakeBackendInterceptor ดังที่หวังอยู่ภายใน
...@Injectable()export class FakeBackendInterceptor implements HttpInterceptor { constructor() {} intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { return next.handle(request); }}
เราจะ handle request ตามที่ต้องการ ในกรณีนี้สมมติเป็น path ที่มีหน้าตาแบบนี้
/notes
จึงแก้ไขเมธอด intercept ได้ว่า
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { const { url, method } = request; switch(true) { case url.endsWith('/notes') && method === 'GET': return this.findAllNotes(); default: return next.handle(request); }}
ในคลาสเดียวกันนี้เพิ่มเมธอด findAllNotes และเราจะหยิบเอา logic ทั้งหมดจาก NoteService.findAll มาไว้ที่นี่
findAllNotes(): Observable<HttpEvent<any>> { const notes: any[] = [ { id: 123, title: 'พัทยากลาง', date: new Date(2020, 2, 8, 12), node: 'พัทยากลางแหล่งอาบแดด คลื่นแรงและน้ำทะเลขุ่น' }, { id: 124, title: 'พัทยากลาง ร้านอาหาร', date: new Date(2020, 2, 8, 13), node: 'ร้านอาหารคนไทยแบบกันเอง มีฝรั่งสองคนชายหญิงเข้าใจว่าพวกเราเป็นคนจีน' }, { id: 125, title: 'alcazar show', date: new Date(2020, 2, 9, 20), node: 'เป็นโชว์ของสาวประเภทสองที่สนุกสนานมาก อลังการ บัตรไม่แพงแค่ 500 บาทต่อที่นั่ง' }, ]; return of(new HttpResponse({ status: 200, body: notes}));}
เพิ่ม delay และ log เข้าไปด้วย
findAllNotes(): Observable<HttpEvent<any>> {... return of(new HttpResponse({ status: 200, body: notes })).pipe( delay(this.random(1, 5) * 1000), map((note: any) => { console.log(note); return note; }) );}
และอย่าได้ลืมย้ายเมธอด random
random(min: number, max: number): number { return Math.floor(Math.random() * max) + min;}
กลับมาดูไฟล์ note.service.ts จะเหลือเท่านี้
import { HttpClient } from '@angular/common/http';import { Injectable } from '@angular/core';import { Observable } from 'rxjs';@Injectable({ providedIn: 'root' })export class NoteService { constructor( private http: HttpClient, ) { } findAll(): Observable<any[]> { const url = `http://localhost:4200/notes`; return this.http.get<any[]>(url); }}
แต่ก่อนที่ FakeBackendInterceptor จะทำงานได้เราก็ต้องนำไปลงทะเบียนกับ providers
ก่อนครับ
เปิดไฟล์ app.module.ts มองหาส่วนที่เขียนว่า providers: [ ] แล้วเพิ่ม interceptor ของเราเข้าไป
import { HTTP_INTERCEPTORS } from '@angular/common/http';import { FakeBackendInterceptor } from './interceptors/fake-backend.interceptor';...providers: [ { provide: HTTP_INTERCEPTORS, useClass: FakeBackendInterceptor, multi: true }],
Angular อนุญาตให้ใช้ HTTP_INTERCEPTORS ร่วมกันได้มากกว่าหนึ่งคลาสโดยการระบุ multi ค่าเป็น true ผลคือ useClass ทั้งหมดจะถูกทำงาน หากไม่แล้ว (multi: false) ผมเข้าใจว่า HTTP_INTERCEPTORS ล่าสุด (ลำดับท้าย) เท่านั้นจะถูกทำงาน
สืบเนื่องจากเราใช้งาน HttpClient ก็อย่าลืม import HttpClientModule ที่ไฟล์เดียวกันนี้ในส่วนของ imports
import { HttpClientModule } from '@angular/common/http';...imports: [... HttpClientModule,],
ทดสอบสิ ทุกอย่างต้องยังคงปกติสุขนะครับ
กลไก canceled http requests
เพราะโดยปกติเราใช้ HttpClient ร้องขอ data จาก APIs หรือ backend ซึ่ง data ที่กลับมาจะอยู่ในรูปของ observable กลไก canceled http นี้ก็คือการ unsubscribe การทำงานของ observable นั้น
สร้าง service ใหม่ชื่อ CanceledHttpService
ng g s services/canceled-http
ให้มีบริการสำคัญ 2 บริการ ได้แก่
- cancelPendingRequests
- onCancelPendingRequests
...export class CanceledHttpService { private subject = new Subject() cancelPendingRequests() { this.subject.next(); } onCancelPendingRequests() { return this.subject.asObservable(); }}
ทุกครั้งที่มีการ routing จะเกิด event ขึ้น Angular เรียกมันว่า Router events เราจะใช้ event นี้ใน interceptor เพื่อทำงาน 2 อย่าง ได้แก่
- เมื่อ routing สำเร็จด้วยดี (ActivationEnd) จะเรียก cancelPendingRequests
- หาไม่แล้วขณะอยู่ระหว่างการร้องขอ data จะใช้ RxJS ชื่อ takeUntil ตรวจสอบ observable นั้นมีค่าส่งมาหรือไม่ (ส่งจาก next) เมื่อใดที่มีค่าส่งมาก็จะเรียก complete ถือเป็นการ unsubscribe การทำงานของ observable นั้น
สร้าง interceptor ตัวใหม่ ชื่อ CanceledHttpInterceptor
ng g interceptor interceptors/canceled-http
เปิดไฟล์ canceled-http.interceptor.ts ส่วนของ constructor
...import { CanceledHttpService } from '../services/canceled-http.service';import { Router, ActivationEnd } from '@angular/router';...constructor( private router: Router, private canceledHttpService: CanceledHttpService,) { this.router.events.subscribe(event => { if (event instanceof ActivationEnd) { this.canceledHttpService.cancelPendingRequests(); } })}...
ส่วนของเมธอด intercept
...import { takeUntil } from 'rxjs/operators';...intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { return next.handle(request).pipe( takeUntil(this.canceledHttpService.onCancelPendingRequests()) );}...
นำไปลงทะเบียนใน providers เปิดไฟล์ app.module.ts
...import { CanceledHttpInterceptor } from './interceptors/canceled-http.interceptor';...providers: [ { provide: HTTP_INTERCEPTORS, useClass: CanceledHttpInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: FakeBackendInterceptor, multi: true }],...
ทดสอบ
เพื่อทดสอบผลการทำงาน เราจะใช้บริการ fake JSON data ของเว็บไซต์ https://jsonplaceholder.typicode.com มองหา /photos ซึ่งมีปริมาณ data ถึง 5000 photos
แก้ไขไฟล์ note.service.ts ให้ร้องขอไปดังนี้
findAll(): Observable<any[]> { // const url = `http://localhost:4200/notes`; const url = `https://jsonplaceholder.typicode.com/photos`; return this.http.get<any[]>(url);}
ไปยัง browser ปรับระดับความเร็วอินเตอร์เน็ตลง
จากนั้นเรียกหน้าแรก NoteComponent สลับกับ MapComponent หรือ NoteDetailsComponent แบบไวๆครับ
ผล
หวังว่าบทความนี้จะเป็นประโยชน์แก่นักพัฒนา Angular ทุกท่าน สวัสดีครับ
อ้างอิง
https://rxjs.dev/api/operators/takeUntil
https://jasonwatmore.com/post/2019/05/02/angular-7-mock-backend-example-for-backendless-development
https://medium.com/angular-in-depth/angular-show-loading-indicator-when-obs-async-is-not-yet-resolved-9d8e5497dd8
https://www.freakyjolly.com/angular-how-to-cancel-http-calls-on-router-change/#more-2919