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 時,我差點把鍵盤砸了。
這就是為什麼我們需要網路層架構設計。它不是炫技,是救命稻草。
網路層不是「一個類打天下」,而是分層的協作體系。我們採用經典的三層架構:
各層職責清晰:
這是整個網路層的「心臟」,負責發起請求和調度攔截器:
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' });
}
}
型別安全是網路層的靈魂,先定義好契約:
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, // 請求錯誤
}
有了 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;
}
}
在應用啟動時初始化網路層,配置攔截器:
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';
問題:每次請求都 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(); // 無論成功失敗都銷毀
}
問題: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;
}
問題:快速切換頁面時,舊頁面的請求先返回,覆蓋了新頁面資料。
解決:使用請求 ID 或 AbortController 取消舊請求(下篇詳解)。
問題:後端回傳 { 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 對網路模組有一些重要更新:
typescript 代碼解讀複製代碼// HarmonyOS 5.x
import http from '@ohos.net.http';
// HarmonyOS 6(推薦)
import { http } from '@kit.NetworkKit';
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
};
typescript 代碼解讀複製代碼// HarmonyOS 5.x 需要手動解析
const data = JSON.parse(response.result as string);
// HarmonyOS 6 自動解析(設定 expectingDataType 後)
const data = response.result; // 已經是物件了!
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('協定錯誤');
}
}
搭建一個優雅的網路層,核心是分層解耦:
記住幾個關鍵點:
下一篇我們深入攔截器鏈的設計,看看如何優雅地實現日誌、驗證、重試等功能。
💡 提示哦:網路層程式碼建議放在
common/network/目錄,業務 API 按模組放在services/目錄,保持職責清晰。