Cách thực hiện request nối đuôi không biết trước số lần lặp trong Angular

Chú ý: tutorial này sử dụng cú pháp pipeable operator, tương thích với HttpClientModule (Angular v4.3+). Với HttpModule, các bạn có thể sử dụng cú pháp chain truyền thống với logic và các operators tương tự.

Bài toán đặt ra

Giả sử chúng ta có một api endpoint như sau:

GET api-url.example/staffs

params: page

response:
{
    staffs: any[]
    nextPage?: number
}

API này sẽ trả về danh sách nhân viên, mỗi lần 10 nhân viên, và thuộc tính nextPage giúp chúng ta biết còn trang tiếp theo hay ko và trang tiếp theo là trang bao nhiêu. Nếu response ko có thuộc tính nextPage đồng nghĩa với việc chúng ta đã nhận đc trang cuối cùng. Nhiệm vụ của chúng ta là thực hiện request nhiều lần, sau mỗi lần ghép nối số lượng nhân viên trả về vào 1 mảng, khi request đến trang cuối, ghép nối và trả về mảng chứa tất cả các nhân viên.

Cách thực hiện

Để thực hiện lại một request, chúng ta sẽ viện đến 1 operator của rxjs có tên retryWhen. Có thể các bạn đã biết đến một operator khác cũng giúp chúng ta thực hiện lại request là retry. Tuy nhiên method này chỉ nhận param là number tương ứng số lần mà request sẽ chạy lại. Ví dụ:

this.httpClient.get<StaffList>(`${this.apiUrl}/stores/staffs`).pipe(
    retry(3)
);

Method retryWhen giống như một bản nâng cấp của retry. Thay vì chỉ nhận vào số lần thử lại, retryWhen sẽ nhận sẽ nhận vào một handler, handler này sẽ nhận vào và trả ra một Observable chứa thông tin lỗi, trong đây chúng ta có thể tự do kiểm soát việc thực hiện lại request, ví dụ thêm delay time giữa các lần thực hiện lại request, đếm số lần lỗi ..... Về cơ bản cú pháp sẽ như sau:

return this.httpClient.get<StaffList>(`${this.apiUrl}/stores/staffs`).pipe(
    retryWhen(e => e)
);

Tuy nhiên request của chúng ta sẽ chỉ chạy qua retryWhen khi xảy ra lỗi. Chúng ta sẽ viện tới operator tap. (cho những ai chưa biết, operator tap là tên gọi mới của operator do và được dùng dưới dạng pipeable operator, nhiệm vụ là thực hiện một công việc song song trong luồng Observable hiện tại).

let staffs = [];
let params = new HttpParams({
    fromObject: { 'page': 1 }
});
return this.httpClient.get<StaffList>(`${this.apiUrl}/stores/staffs`, { params }).pipe(
    tap(response => {
        // tiếp tục xử lý trong đây;
    })
    retryWhen(e => e)
);

Quay lại yêu cầu phía trên, ở mỗi request, chúng ta sẽ cần truyền lên tham số page, sau mỗi request, chúng ta sẽ kiểm tra response có trả về 'nextPage' ko để thực hiện lại request với tham số là nextpage của response trước đó.

let staffs = [];
let params = new HttpParams({
    fromObject: { 'page': 1 }
});
let complete = false; // flag để dừng việc chạy lại request
if (complete) { // nếu đã hoàn thành, trả về staffs array tổng
    return Observable.of({
      staffs
    });
}
return this.httpClient.get<StaffList>(`${this.apiUrl}/stores/staffs`, { params }).pipe(
    tap(response => {
        // trường hợp vẫn còn staff
        if (response.nextPage) {
          // khởi tạo lại query param cho lần thử lại tiếp theo
          params = new HttpParams({
            fromObject: { page: response.nextPage }
          });
          // throw giúp kích hoạt retryWhen, giá trị chúng ta throw sẽ nhận được bên trong retryWhen handler
          throw response.staffs;
        }
        // nếu ko còn page tiếp theo nữa, chúng ta đổi flag complete sang true, tiếp tục throw response để ghép nối lần cuối trong `retryWhen`
        if (!response.nextPage && !complete) {
          complete = true;
          throw response.staffs;
        }
    }),
    retryWhen(e => e.pipe(
      tap((pageStaffs) => {
        // mỗi lần thực hiện lại request, chúng ta ghép nối staffs mới vào mảng `staffs` hiện tại
        staffs = staffs.concat(pageStaffs);
      })
    ))
);

Đến đây trông có vẻ ổn ổn. Tuy nhiên nếu bạn thử chạy, kết quả bạn nhận được là một loạt request /stores/staffs?page=1 với cùng query param page=1 và thử lại mãi mãi. Vậy chúng ta sai ở đâu? Nguyên nhân là retryWhen sẽ retry trên chính Observale instance đầu tiên mà chúng ta khởi tạo. Vì thế, để khởi tạo lại Observable và sử dụng query param mới, chúng ta sẽ viện đến operator defer.

let staffs = [];
let params = new HttpParams({
    fromObject: { 'page': 1 }
});
let complete = false; // flag để dừng việc chạy lại request
return Observable.defer(() => {
    if (!complete) {
        this.httpClient.get<StaffList>(`${this.apiUrl}/stores/staffs`, { params });
    } else {
        return Observable.of({
          staffs
        });
    }
}).pipe(
    tap(response => {
        // trường hợp vẫn còn staff
        if (response.nextPage) {
          // khởi tạo lại query param cho lần thử lại tiếp theo
          params = new HttpParams({
            fromObject: { page: response.nextPage }
          });
          // throw giúp kích hoạt retryWhen, giá trị chúng ta throw sẽ nhận được bên trong retryWhen handler
          throw response.staffs;
        }
        // nếu ko còn page tiếp theo nữa, chúng ta đổi flag complete sang true, tiếp tục throw response để ghép nối lần cuối trong `retryWhen`
        if (!response.nextPage && !complete) {
          complete = true;
          throw response.staffs;
        }
    }),
    retryWhen(e => e.pipe(
      tap((pageStaffs) => {
        // mỗi lần thực hiện lại request, chúng ta ghép nối staffs mới vào mảng `staffs` hiện tại
        staffs = staffs.concat(pageStaffs);
      })
    ))
);

defer sẽ cho mỗi lần retryWhen một Observable instance mới với query param mới. Đến đây đoạn code của chúng ta đã có thể hoạt động như ý muốn.

Tham khảo

https://www.learnrxjs.io/operators/error_handling/retrywhen.html https://www.learnrxjs.io/operators/utility/do.html http://reactivex.io/documentation/operators/defer.html