HarmonyOS 開發中的 RESTful API 封裝:網路層架構設計

HarmonyOS 開發中的 RESTful API 封裝:網路層架構設計

從「一把梭」到「優雅架構」,聊聊鴻蒙網路層的設計哲學

一、為什麼需要封裝網路層?

說實話,剛接觸鴻蒙開發那會兒,我寫網路請求是這樣的:

typescript 代碼解讀複製代碼// 每個頁面都這麼寫...
http.request('https://api.example.com/user', {
  method: http.RequestMethod.GET,
  header: { 'Content-Type': 'application/json' }
}, (err, data) => {
  if (err) {
    console.error('請求失敗');
    return;
  }
  // 處理資料...
});

寫著寫著就發現問題了——程式碼重複得讓人心慌。每個介面都要寫一遍請求頭配置、錯誤處理、資料解析,改個 baseUrl 得全域搜尋替換,加個 token 驗證得改幾十個檔案...

更要命的是,當後端介面從 /api/v1/user 升級到 /api/v2/user 時,我差點把鍵盤砸了。

這就是為什麼我們需要網路層架構設計。它不是炫技,是救命稻草。

一個好的網路層應該具備什麼能力?

  1. 統一配置:baseUrl、逾時時間、公共請求頭,一處配置全域生效
  2. 請求攔截:發請求前能加點料(比如自動注入 token)
  3. 回應攔截:收到資料後能做點事(比如統一錯誤處理)
  4. 型別安全:TypeScript 時代了,回傳值得有型別
  5. 易於擴展:想加個快取?想加個 mock?不動舊程式碼

二、核心原理:分層架構設計

網路層不是「一個類打天下」,而是分層的協作體系。我們採用經典的三層架構

各層職責清晰

  • 應用層:只管呼叫 API,不關心網路細節
  • 業務層:封裝具體介面,定義業務型別
  • 網路層:處理請求/回應,攔截器鏈
  • 基礎層:配置、錯誤、快取等基礎設施

三、程式碼實戰:從零搭建網路層

範例1:核心 HttpClient 類

這是整個網路層的「心臟」,負責發起請求和調度攔截器:

typescript 代碼解讀複製代碼// network/HttpClient.ets
import http from '@ohos.net.http';
import { RequestConfig, Response, Interceptor } from './types';

/**
 * HttpClient - 網路請求核心類
 * 職責:發起HTTP請求、管理攔截器鏈、統一錯誤處理
 */
export class HttpClient {
  private baseUrl: string;
  private timeout: number = 30000; // 預設30秒逾時
  private interceptors: Interceptor[] = []; // 攔截器鏈
  private defaultHeaders: Record<string, string> = {};

  constructor(config: { baseUrl: string; timeout?: number }) {
    this.baseUrl = config.baseUrl;
    if (config.timeout) {
      this.timeout = config.timeout;
    }
    // 初始化預設請求頭
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    };
  }

  /**
   * 添加攔截器到攔截器鏈
   * @param interceptor 攔截器實例
   */
  use(interceptor: Interceptor): void {
    this.interceptors.push(interceptor);
  }

  /**
   * 核心請求方法
   * @param config 請求配置
   * @returns Promise<Response<T>>
   */
  async request<T>(config: RequestConfig): Promise<Response<T>> {
    // 1. 合併配置
    const mergedConfig = this.mergeConfig(config);

    // 2. 執行請求攔截器
    let processedConfig = mergedConfig;
    for (const interceptor of this.interceptors) {
      if (interceptor.beforeRequest) {
        processedConfig = await interceptor.beforeRequest(processedConfig);
      }
    }

    // 3. 發起實際請求
    const httpResponse = await this.executeRequest(processedConfig);

    // 4. 執行回應攔截器
    let response: Response<T> = {
      data: httpResponse.result as T,
      status: httpResponse.responseCode,
      headers: httpResponse.header,
      config: processedConfig
    };

    for (const interceptor of this.interceptors) {
      if (interceptor.afterResponse) {
        response = await interceptor.afterResponse(response);
      }
    }

    return response;
  }

  /**
   * 合併請求配置
   * @private
   */
  private mergeConfig(config: RequestConfig): RequestConfig {
    return {
      url: this.baseUrl + config.url,
      method: config.method || 'GET',
      headers: { ...this.defaultHeaders, ...config.headers },
      params: config.params,
      data: config.data,
      timeout: config.timeout || this.timeout
    };
  }

  /**
   * 執行實際HTTP請求
   * @private
   */
  private async executeRequest(config: RequestConfig): Promise<http.HttpResponse> {
    const httpRequest = http.createHttp();

    try {
      const response = await httpRequest.request(config.url, {
        method: this.getMethod(config.method),
        header: config.headers,
        extraData: config.data,
        connectTimeout: config.timeout,
        readTimeout: config.timeout
      });

      return response;
    } finally {
      // 確保銷毀HTTP物件,避免記憶體洩漏
      httpRequest.destroy();
    }
  }

  /**
   * 轉換請求方法枚舉
   * @private
   */
  private getMethod(method: string): http.RequestMethod {
    const methodMap: Record<string, http.RequestMethod> = {
      'GET': http.RequestMethod.GET,
      'POST': http.RequestMethod.POST,
      'PUT': http.RequestMethod.PUT,
      'DELETE': http.RequestMethod.DELETE,
      'PATCH': http.RequestMethod.PATCH
    };
    return methodMap[method] || http.RequestMethod.GET;
  }

  // 快捷方法:GET請求
  get<T>(url: string, params?: Record<string, any>): Promise<Response<T>> {
    return this.request<T>({ url, method: 'GET', params });
  }

  // 快捷方法:POST請求
  post<T>(url: string, data?: any): Promise<Response<T>> {
    return this.request<T>({ url, method: 'POST', data });
  }

  // 快捷方法:PUT請求
  put<T>(url: string, data?: any): Promise<Response<T>> {
    return this.request<T>({ url, method: 'PUT', data });
  }

  // 快捷方法:DELETE請求
  delete<T>(url: string): Promise<Response<T>> {
    return this.request<T>({ url, method: 'DELETE' });
  }
}

範例2:型別定義與攔截器介面

型別安全是網路層的靈魂,先定義好契約:

typescript 代碼解讀複製代碼// network/types.ets

/**
 * 請求配置介面
 */
export interface RequestConfig {
  url: string;                    // 請求URL(相對路徑)
  method?: string;                // 請求方法
  headers?: Record<string, string>; // 請求頭
  params?: Record<string, any>;   // URL參數
  data?: any;                     // 請求體資料
  timeout?: number;               // 逾時時間
}

/**
 * 回應結構介面
 */
export interface Response<T = any> {
  data: T;                        // 回應資料
  status: number;                 // HTTP狀態碼
  headers: Record<string, string>; // 回應頭
  config: RequestConfig;          // 原始請求配置
}

/**
 * 攔截器介面
 * 攔截器可以在請求發出前和回應返回後進行攔截處理
 */
export interface Interceptor {
  /**
   * 請求攔截:在請求發出前執行
   * 可用於添加驗證token、記錄日誌等
   */
  beforeRequest?(config: RequestConfig): Promise<RequestConfig>;

  /**
   * 回應攔截:在回應返回後執行
   * 可用於統一錯誤處理、資料轉換等
   */
  afterResponse?<T>(response: Response<T>): Promise<Response<T>>;
}

/**
 * API錯誤類
 * 封裝網路請求中的各類錯誤
 */
export class ApiError extends Error {
  code: number;        // 錯誤碼
  status: number;      // HTTP狀態碼
  data: any;           // 錯誤回應資料

  constructor(message: string, code: number, status: number = 0, data?: any) {
    super(message);
    this.code = code;
    this.status = status;
    this.data = data;
    this.name = 'ApiError';
  }
}

/**
 * 錯誤碼枚舉
 */
export enum ErrorCode {
  NETWORK_ERROR = -1,      // 網路錯誤
  TIMEOUT = -2,            // 請求逾時
  SERVER_ERROR = 500,      // 伺服器錯誤
  NOT_FOUND = 404,         // 資源不存在
  UNAUTHORIZED = 401,      // 未授權
  FORBIDDEN = 403,         // 禁止存取
  BAD_REQUEST = 400,       // 請求錯誤
}

範例3:業務 API 封裝實戰

有了 HttpClient,封裝業務介面就像搭積木一樣簡單:

typescript 代碼解讀複製代碼// api/UserAPI.ets
import { HttpClient, Response } from '../network';
import { User, LoginParams, LoginResult } from '../models/user';

/**
 * 使用者相關API
 * 封裝所有使用者模組的網路請求
 */
export class UserAPI {
  private http: HttpClient;

  constructor(http: HttpClient) {
    this.http = http;
  }

  /**
   * 使用者登入
   * @param params 登入參數(使用者名稱、密碼)
   * @returns 登入結果(token、使用者資訊)
   */
  async login(params: LoginParams): Promise<Response<LoginResult>> {
    // 傳送POST請求到 /auth/login
    const response = await this.http.post<LoginResult>('/auth/login', params);

    // 登入成功後,可以將token儲存到本地
    if (response.data.token) {
      // TODO: 儲存token到Preferences
      console.info('[UserAPI] 登入成功,token已取得');
    }

    return response;
  }

  /**
   * 取得目前使用者資訊
   * @returns 使用者詳細資訊
   */
  async getCurrentUser(): Promise<Response<User>> {
    return await this.http.get<User>('/user/current');
  }

  /**
   * 更新使用者資料
   * @param user 使用者資訊(部分欄位)
   * @returns 更新後的完整使用者資訊
   */
  async updateProfile(user: Partial<User>): Promise<Response<User>> {
    return await this.http.put<User>('/user/profile', user);
  }

  /**
   * 修改密碼
   * @param oldPassword 舊密碼
   * @param newPassword 新密碼
   */
  async changePassword(oldPassword: string, newPassword: string): Promise<Response<void>> {
    return await this.http.post<void>('/user/password', {
      oldPassword,
      newPassword
    });
  }

  /**
   * 使用者登出
   */
  async logout(): Promise<Response<void>> {
    const response = await this.http.post<void>('/auth/logout');

    // 清除本地token
    // TODO: 從Preferences中移除token
    console.info('[UserAPI] 已登出');

    return response;
  }
}

範例4:全域網路層初始化

在應用啟動時初始化網路層,配置攔截器:

typescript 代碼解讀複製代碼// network/index.ets
import { HttpClient } from './HttpClient';
import { AuthInterceptor, LogInterceptor, ErrorInterceptor } from './interceptors';

/**
 * 全域HttpClient實例
 * 整個應用共用這一個實例
 */
let httpClient: HttpClient | null = null;

/**
 * 初始化網路層
 * 在應用啟動時呼叫(EntryAbility.onCreate)
 */
export function initNetwork(): HttpClient {
  if (httpClient) {
    return httpClient;
  }

  // 建立HttpClient實例
  httpClient = new HttpClient({
    baseUrl: 'https://api.myapp.com/v1',  // API基礎位址
    timeout: 30000                         // 30秒逾時
  });

  // 註冊攔截器(按順序執行)
  httpClient.use(new LogInterceptor());        // 日誌攔截器
  httpClient.use(new AuthInterceptor());       // 驗證攔截器
  httpClient.use(new ErrorInterceptor());      // 錯誤攔截器

  console.info('[Network] 網路層初始化完成');
  return httpClient;
}

/**
 * 取得全域HttpClient實例
 */
export function getHttpClient(): HttpClient {
  if (!httpClient) {
    throw new Error('網路層未初始化,請先呼叫 initNetwork()');
  }
  return httpClient;
}

// 匯出便捷方法
export { HttpClient } from './HttpClient';
export * from './types';

四、踩坑與注意事項

坑1:HTTP 物件未銷毀導致記憶體洩漏

問題:每次請求都 http.createHttp() 建立新物件,但忘記銷毀,導致記憶體持續增長。

解決:在 finally 區塊中確保銷毀:

typescript 代碼解讀複製代碼// ❌ 錯誤寫法
const httpRequest = http.createHttp();
const response = await httpRequest.request(url, options);
// 忘記銷毀!

// ✅ 正確寫法
const httpRequest = http.createHttp();
try {
  const response = await httpRequest.request(url, options);
  return response;
} finally {
  httpRequest.destroy(); // 無論成功失敗都銷毀
}

坑2:baseUrl 拼接錯誤

問題:baseUrl 以 / 結尾,url 以 / 開頭,導致雙斜線。

解決:標準化處理:

typescript 代碼解讀複製代碼private normalizeUrl(baseUrl: string, url: string): string {
  const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
  const path = url.startsWith('/') ? url : `/${url}`;
  return base + path;
}

坑3:並發請求的競態問題

問題:快速切換頁面時,舊頁面的請求先返回,覆蓋了新頁面資料。

解決:使用請求 ID 或 AbortController 取消舊請求(下篇詳解)。

坑4:回應資料型別不匹配

問題:後端回傳 { code: 0, data: {...} },但直接把整個回應當業務資料用了。

解決:在回應攔截器中統一解析:

typescript 代碼解讀複製代碼// 假設後端統一返回格式:{ code: number, message: string, data: T }
afterResponse<T>(response: Response<any>): Promise<Response<T>> {
  const { code, message, data } = response.data;

  if (code !== 0) {
    throw new ApiError(message, code, response.status);
  }

  // 只返回業務資料部分
  return {
    ...response,
    data: data as T
  };
}

五、HarmonyOS 6 適配要點

HarmonyOS 6 對網路模組有一些重要更新:

1. HTTP 模組 API 變更

typescript 代碼解讀複製代碼// HarmonyOS 5.x
import http from '@ohos.net.http';

// HarmonyOS 6(推薦)
import { http } from '@kit.NetworkKit';

2. 請求配置增強

HarmonyOS 6 新增了更多配置項:

typescript 代碼解讀複製代碼// HarmonyOS 6 新增配置
const options: http.HttpRequestOptions = {
  method: http.RequestMethod.POST,
  header: { 'Content-Type': 'application/json' },
  extraData: { key: 'value' },

  // ✨ 新增:期望的回應型別
  expectingDataType: http.HttpDataType.OBJECT,  // 自動解析JSON

  // ✨ 新增:使用HTTP2
  usingProtocol: http.HttpProtocol.HTTP2,

  // ✨ 新增:優先級
  priority: http.HttpRequestPriority.HIGH
};

3. 回應資料直接解析

typescript 代碼解讀複製代碼// HarmonyOS 5.x 需要手動解析
const data = JSON.parse(response.result as string);

// HarmonyOS 6 自動解析(設定 expectingDataType 後)
const data = response.result; // 已經是物件了!

4. 錯誤處理增強

typescript 代碼解讀複製代碼// HarmonyOS 6 提供更詳細的錯誤資訊
try {
  const response = await httpRequest.request(url, options);
} catch (error) {
  // error 包含更詳細的錯誤型別
  if (error.code === 2300001) {
    console.error('網路不可用');
  } else if (error.code === 2300002) {
    console.error('連線逾時');
  } else if (error.code === 2300003) {
    console.error('協定錯誤');
  }
}

六、總結

搭建一個優雅的網路層,核心是分層解耦

  1. HttpClient 負責請求調度,是「心臟」
  2. 攔截器鏈 負責橫切關注點,是「血管」
  3. 業務 API 負責介面封裝,是「四肢」
  4. 型別定義 負責契約約束,是「骨架」

記住幾個關鍵點:

  • ✅ HTTP 物件用完必須銷毀
  • ✅ 攔截器按順序執行,注意依賴關係
  • ✅ 型別定義要完整,別用 any
  • ✅ 錯誤處理要統一,別到處 try-catch

下一篇我們深入攔截器鏈的設計,看看如何優雅地實現日誌、驗證、重試等功能。


💡 提示哦:網路層程式碼建議放在 common/network/ 目錄,業務 API 按模組放在 services/ 目錄,保持職責清晰。


原文出處:https://juejin.cn/post/7652636952518721572


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝16   💬1   ❤️1
510
🥈
我愛JS
📝1   ❤️1
31
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登