iT邦幫忙

2025 iThome 鐵人賽

DAY 7
2
Modern Web

Angular 進階實務 30天系列 第 7

Day 7:Angular HTTP服務設計:從API規範到架構選擇

  • 分享至 

  • xImage
  •  

前言

在開發中型以上的Angular專案時,建立完整的HTTP服務管理體系是必要的。本文將從RestAPI設計規範開始,深入探討Token安全管理、錯誤處理機制,最後比較Interceptor與Http-base.service兩種架構選擇,幫助開發者建構穩定可靠的HTTP服務層。

為什麼這些主題要一起討論?

在實際專案開發中,HTTP服務層的設計並非獨立的技術選擇,而是一個需要整體考量的系統:

相互依存的關係:API規範影響錯誤處理的設計,Token管理機制決定了服務架構的選擇,錯誤處理策略又會影響使用者體驗的設計。

統一的設計決策:當我們選擇Interceptor時,Token的處理方式、錯誤的攔截機制、API回應格式的解析都需要在同一個層級考慮。選擇Http-base.service時,這些邏輯則需要封裝在服務層中。

實務經驗分享:在專案中,我通常在同一個HTTP服務中處理這些問題,因此一起討論更貼近實際開發情境,也能提供更具參考價值的整合方案。

RestAPI說明

REST(REpresentational State Transfer)是以資源為導向的架構風格。讓我們了解幾個常見HTTP動詞的實際應用:

常用HTTP方法

GET:用於資料查詢,參數附在URL上,適合取得列表或單筆資料。由於參數公開且有長度限制,不適合敏感資訊。

POST:主要用於新增資料,資料放在Body中傳送。實務上,當查詢參數過多或涉及隱私時,也會改用POST。

PUT:理論上用於整筆資料置換,但實際開發中較少使用,多以POST和PATCH取代。

PATCH:用於部分資料修改,如更新使用者地址或email。

DELETE:刪除資料時使用,參數同樣加在URL上。

OPTIONS:瀏覽器在跨域請求時自動發送的預檢請求,用於確認安全性。

跨域請求流程

當你發送跨域API請求時:

fetch('https://api.example.com/users', {
  method: 'POST',
  // ...
});

瀏覽器會自動執行以下步驟:

  1. 檢測到跨域請求
  2. 自動發送OPTIONS預檢請求
  3. 確認安全後,才發送真正的POST請求

RestAPI設計規範

URL命名規範

1. 使用複數名詞表示資源

*# 正確*
GET /api/users          *# 取得所有使用者*
GET /api/users/123      *# 取得特定使用者*
POST /api/orders        *# 新增訂單#* 

2. 資源層級關係

*# 巢狀資源*
GET /api/users/123/orders       *# 取得使用者的訂單*
POST /api/users/123/orders      *# 為使用者新增訂單*
GET /api/orders/456/items       *# 取得訂單的商品項目*

3. 查詢參數設計

*# 分頁*
GET /api/users?page=1&limit=20

*# 排序*
GET /api/users?sort=created_at&order=desc

*# 篩選*
GET /api/users?status=active&role=admin&search=john

*# 複合查詢*
GET /api/orders?date_from=2024-01-01&date_to=2024-01-31&status=completed

HTTP狀態碼標準

成功回應

  • 200 OK:GET、PUT、PATCH成功
  • 201 Created:POST新增成功
  • 204 No Content:DELETE成功或PUT更新無回傳內容

客戶端錯誤

  • 400 Bad Request:請求格式錯誤
  • 401 Unauthorized:未授權,需要登入
  • 403 Forbidden:已登入但無權限
  • 404 Not Found:資源不存在
  • 422 Unprocessable Entity:資料驗證失敗

伺服器錯誤

  • 500 Internal Server Error:伺服器內部錯誤
  • 503 Service Unavailable:服務暫時無法使用

請求/回應格式參考

統一回應格式

typescript

interface ApiResponse<T> {
  success: boolean;
  data: T;
  message: string;
  timestamp: string;
  pagination?: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

*// 成功範例*
{
  "success": true,
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"
  },
  "message": "取得使用者資料成功",
  "timestamp": "2024-08-21T10:30:00Z"
}

*// 列表範例*
{
  "success": true,
  "data": [...],
  "message": "取得使用者列表成功",
  "timestamp": "2024-08-21T10:30:00Z",
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 150,
    "totalPages": 8
  }
}

Token的儲存與安全考量

三種主要儲存方式比較

1. localStorage

// 儲存與取得
localStorage.setItem('token', 'eyJhbGciOiJIUzI1...');
const token = localStorage.getItem('token');

優點:持久儲存、容量大(5-10MB)、同源分頁共享
缺點:容易受XSS攻擊、無法設定過期時間、SSR首次渲染取不到

2. sessionStorage

sessionStorage.setItem('token', 'eyJhbGciOiJIUzI1...');

優點:分頁關閉自動清除、不同分頁獨立
缺點:同樣有XSS風險、使用者體驗較差

3. httpOnly Cookie(推薦)

// 只能由後端設定
res.cookie('token', 'eyJhbGciOiJIUzI1...', {
  httpOnly: true,    // JavaScript無法讀取
  secure: true,      // 只在HTTPS傳送
  sameSite: 'strict' // CSRF防護
});

優點:最安全、自動帶入請求、可設定過期
缺點:需要後端配合、需額外CSRF防護

架構設計選擇:Interceptor vs Http-base.service

通常有兩種方式處理
一個是在底層的服務設定預設的Header,如果後端通知過期的時候,統一登出
一個是利用攔截器,例如 authInterceptor ,直接在每次收發http請求的時候做處理,後端通知過期的時候也可以統一登出

特性比較

比較項目 Interceptor Http-base.service
影響範圍 所有HTTP請求(包含第三方API) 只影響透過service的請求
控制精度 較粗粒度,難以排除特定請求 細粒度控制,可選擇性使用
使用方式 開發者無感知,自動生效 需主動使用,有學習成本
程式碼侵入性 低,不需修改現有API呼叫 高,需將所有API改用service
錯誤處理 統一在interceptor層處理 可在service層客製化處理
第三方API 自動加token(可能不需要,或是有規格差異) 不會影響直接的HTTP呼叫
設定複雜度 簡單,一次設定全域生效 中等,需封裝各種HTTP方法
團隊採用 容易推廣,自動生效 需團隊約定統一使用
彈性度 較低,全域統一邏輯 較高,可針對不同需求客製

Interceptor實作範例

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const isAuthRequest = req.url.includes('/auth/login');
  const token = authService.getToken();

  if (!isAuthRequest && token) {
    req = req.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });
  }

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        authService.logout();
      }
      return throwError(() => error);
    })
  );
};

Http-base.service實作範例

export class HttpBaseService {

  /**
   * 執行HTTP GET請求
   */
  public get<T>(endpoint: string, options: RequestOptions = {}): Observable<T> {
    return this.request<T>(HttpMethod.GET, endpoint, options);
  }

  /**
   * 通用請求方法
   */
  private request<T>(method: HttpMethod, endpoint: string, options: RequestOptions = {}): Observable<T> {
    const url = this.buildUrl(endpoint);
    const requestOptions = this.buildRequestOptions(options);

    this.requestStarted();

    let request: Observable<any>;

    switch (method) {
      case HttpMethod.GET:
        request = this.http.get<ApiResponse<T>>(url, requestOptions);
        break;
      // 其他HTTP方法...
      default:
        throw new Error(`不支持的HTTP方法: ${method}`);
    }

    return request.pipe(
      timeout(options.timeoutMs || this.defaultTimeout),
      retry({ count: options.retryCount ?? this.defaultRetryCount }),
      this.extractData<T>(),
      catchError(err => this.handleError<T>(err, options.skipErrorHandler)),
      finalize(() => this.requestFinished())
    );
  }

  /**
   * 構建HTTP請求選項
   */
  private buildRequestOptions(options: RequestOptions): any {
    const responseType = options.responseType || 'json';

    const defaultOptions: any = {
      headers: this.getDefaultHeaders(options.ignoreAuthHeader, options.removeContentType),
      responseType: responseType,
      withCredentials: options.withCredentials ?? false,
      reportProgress: options.reportProgress ?? false
    };

    // 處理自訂headers和params
    if (options.headers) {
      // headers處理邏輯
    }

    if (options.params) {
      defaultOptions.params = options.params;
    }

    return defaultOptions;
  }

  /**
   * 預設HTTP Headers
   */
  private getDefaultHeaders(ignoreAuthHeader = false, removeContentType = false): HttpHeaders {
    let headers = new HttpHeaders({
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-Requested-With': 'XMLHttpRequest',
      'Cache-Control': 'no-cache'
    });

    if (removeContentType) {
      headers = headers.delete('Content-Type');
    }

    if (!ignoreAuthHeader) {
      const token = localStorage.getItem('auth_token');
      if (token) {
        headers = headers.set('Authorization', `Bearer ${token}`);
      }
    }

    return headers;
  }
}

使用場景建議

選擇Interceptor的情況

  • 專案主要呼叫自家API
  • 希望無感知的自動化處理
  • 不常使用第三方API
  • 團隊偏好簡單統一的解決方案

選擇Http-base.service的情況

  • 經常呼叫不需要token的第三方API
  • 需要針對不同API有不同處理邏輯
  • 要整合多方中台、後台系統
  • 需要細粒度的請求控制

實際應用

// Service中使用Http-base.service
constructor(private httpBaseService: HttpBaseService) {}

// 取得使用者資料
getUserData(id: string): Observable<User> {
  return this.httpBaseService.get(`/user/${id}`);
}

// 上傳檔案(不需要Content-Type)
uploadFile(file: FormData): Observable<any> {
  return this.httpBaseService.post('/upload', {
    body: file,
    removeContentType: true
  });
}

總結

建構完整的Angular HTTP服務架構需要考慮多個層面:

API設計層面:遵循RestAPI規範,使用標準的HTTP狀態碼和統一的回應格式,讓API更易於維護和使用。

安全層面:優先選擇httpOnly Cookie儲存Token,建立完善的授權機制,確保應用程式的安全性。

錯誤處理層面:設計統一的錯誤格式和使用者友善的錯誤訊息,提升使用者體驗和除錯效率。

架構選擇層面:根據專案複雜度選擇Interceptor或Http-base.service。Interceptor適合簡單統一的場景,Http-base.service則提供更高的彈性和控制力。

這些設計原則相互配合,就能構建出相對穩定、安全且易於維護的HTTP服務架構。


上一篇
Day 6:loading 與 HttpInterceptor
下一篇
Day 8:跨域解決與服務分層架構
系列文
Angular 進階實務 30天20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言