HarmonyOS应用《民族图鉴》开发第39篇:ApiService——统一API请求封装与数据防腐层

一、API 层的架构设计
1.1 为什么需要 ApiService
几乎所有 App 都要和后端接口打交道。如果每个页面自己发 HTTP 请求,会是什么样?
| 痛点 | 表现 |
|---|---|
| 代码重复 | 每个页面都写一遍请求头、错误处理、URL 拼接 |
| 难以维护 | 接口地址改了,全项目搜着改 |
| 格式不统一 | 有的页面用 try-catch,有的不处理错误 |
| Mock 麻烦 | 后端没好的时候,每个页面自己造假数据 |
| 类型混乱 | 返回值都是 any,不知道是什么结构 |
解决方案:用 ApiService 把所有 API 调用集中管理。
这是一个"网关模式"——所有对外的数据请求都经过 ApiService 这道门,统一处理、统一管控。
1.2 Repository 模式
ApiService 的设计思想来源于 Repository 模式(仓库模式),这是领域驱动设计(DDD)中的经典模式。
什么是 Repository 模式?
Repository 是介于业务逻辑层和数据来源之间的一层,它封装了数据获取和存储的细节,让业务层不需要知道数据到底来自哪里。
┌─────────────────────────────────────────┐
│ 业务层(页面/组件) │
│ 只关心"我要什么数据" │
└───────────────┬─────────────────────────┘
│
┌───────────────▼─────────────────────────┐
│ Repository 层(ApiService) │
│ 定义数据访问的接口(契约) │
│ 封装数据来源的细节 │
└───────────────┬─────────────────────────┘
┌──────┴───────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 远程 API │ │ 本地 Mock │
│ 网络请求 │ │ 静态数据 │
└──────────────┘ └──────────────┘
▲ ▲
└──────┬───────┘
│
┌──────▼───────┐
│ 本地缓存 │
│ 内存/磁盘 │
└──────────────┘
Repository 模式的核心价值:
- 隔离变化:数据来源的变化(换接口、加缓存)不影响业务层
- 统一契约:所有数据访问都走相同的接口
- 易于测试:可以用 Mock 实现来测试业务逻辑
- 灵活切换:开发用 Mock,生产用真实 API,无缝切换
1.3 数据来源的抽象
Repository 模式的关键是:面向接口编程,而不是面向实现编程。
// 定义接口(契约)
interface EthnicRepository {
getList(page: number, pageSize: number): Promise<EthnicGroup[]>;
getById(id: string): Promise<EthnicGroup | null>;
search(keyword: string): Promise<EthnicGroup[]>;
}
// Mock 实现
class MockEthnicRepository implements EthnicRepository {
// ... 用本地数据实现
}
// 远程 API 实现
class RemoteEthnicRepository implements EthnicRepository {
// ... 用 HTTP 请求实现
}
// 带缓存的装饰器
class CachedEthnicRepository implements EthnicRepository {
constructor(private realRepo: EthnicRepository) {}
// ... 先查缓存,没有再调真实实现
}
业务层只依赖 EthnicRepository 接口,不关心具体是哪个实现。这样:
- 想换数据源?换个实现类就行
- 想加缓存?套个装饰器就行
- 想加埋点?套个装饰器就行
这就是开闭原则——对扩展开放,对修改关闭。
1.4 本项目的简化实现
民族图鉴项目没有严格按照 DDD 的 Repository 模式来写(太复杂了),但核心思想是一样的:
- ApiService 对外提供统一的方法(相当于 Repository 接口)
- 内部有 Mock 和 Remote 两种实现(相当于具体实现类)
- 页面层不关心数据从哪来(面向接口编程)
简化版有简化版的好处:
- 代码量少,容易理解
- 没有复杂的类层级
- 对于中小项目完全够用
等项目变大了,再往严格的 Repository 模式演进也不迟。架构是演进出来的,不是设计出来的。
二、双模式架构:Mock + Remote
2.1 为什么要双模式
- 开发阶段:后端接口还没写好,前端用 Mock 数据开发
- 测试阶段:接口不稳定,用 Mock 保证测试能跑通
- 演示环境:没有后端服务,也能完整演示功能
- 降级兜底:后端挂了,前端还能用 Mock 数据顶着(至少核心功能能用)
2.2 模式切换
export enum DataSourceMode {
MOCK = 'mock', // 本地 Mock 数据(默认)
REMOTE = 'remote' // 远程 API
}
export class ApiService {
private currentMode: DataSourceMode = DataSourceMode.MOCK;
private apiBaseUrl: string = '';
public init(baseUrl?: string): void {
if (baseUrl && baseUrl.trim().length > 0) {
this.apiBaseUrl = baseUrl.replace(/\/+$/, ''); // 去掉末尾的斜杠
this.httpClient.init(this.apiBaseUrl);
this.currentMode = DataSourceMode.REMOTE;
} else {
this.currentMode = DataSourceMode.MOCK;
}
}
}
切换逻辑很简单:
- 传了 baseUrl → 远程模式
- 没传 → Mock 模式
为什么默认是 Mock?
因为这是一个演示项目,没有真实后端。实际项目中可以反过来——默认远程,开发环境配 Mock。
2.3 统一的接口定义
不管是 Mock 还是远程,对外的方法签名完全一样:
// 不管底层是 Mock 还是 Remote,调用方式都一样
const list = await apiService.getEthnicList(1, 20);
const detail = await apiService.getEthnicById('han');
页面层完全不感知数据从哪来。这就是"面向接口编程"——上层只依赖接口,不依赖具体实现。
🎯 设计思想:这是典型的"策略模式(Strategy Pattern)"。获取数据有两种策略(Mock / Remote),它们实现了相同的接口,上层可以随意切换。
2.4 策略模式深入讨论
策略模式是 ApiService 最核心的设计模式,值得深入聊一聊。
什么是策略模式?
策略模式定义了一系列算法,把它们一个个封装起来,并且使它们可以互相替换。此模式让算法的变化独立于使用算法的客户。
说人话就是:同一件事,有多种做法,把每种做法封装起来,想用哪个用哪个。
策略模式的组成:
┌─────────────────────────────────────────┐
│ 策略接口(Strategy) │
│ 定义了统一的方法签名 │
└───────────────┬─────────────────────────┘
┌──────┴───────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 具体策略A │ │ 具体策略B │
│ Mock 数据 │ │ 远程 API │
│ 实现接口 │ │ 实现接口 │
└──────────────┘ └──────────────┘
▲ ▲
└──────┬───────┘
│
┌──────▼───────┐
│ 上下文(Context)│
│ ApiService │
│ 持有策略引用 │
└──────────────┘
│
▼
┌──────────────┐
│ 客户端 │
│ 页面/组件 │
└──────────────┘
策略模式的三个角色:
| 角色 | 本项目中的体现 | 作用 |
|---|---|---|
| 策略接口 | ApiService 对外的方法签名 | 定义统一的契约 |
| 具体策略 | Mock 实现、Remote 实现 | 具体的算法实现 |
| 上下文 | ApiService 本身 | 持有策略引用,对外提供服务 |
为什么用策略模式?
- 算法可以自由切换:想 Mock 就 Mock,想远程就远程,改个配置就行
- 扩展性好:以后加个"缓存优先"策略,加个类就行,不用改现有代码
- 避免多重条件判断:不用到处写
if (mode === 'mock') { ... } else { ... } - 单一职责:每个策略只干自己的事,Mock 不管网络,Remote 不管本地数据
策略模式的缺点:
- 策略类会增多:每种策略一个类,策略多了类就多
- 客户端需要知道策略:客户端得知道有哪些策略,才能选
- 增加代码复杂度:简单的逻辑用策略模式反而显得重
什么时候用策略模式?
- 一个系统有很多类,它们的区别只是行为不同
- 需要在运行时动态选择算法
- 算法需要独立于客户端而变化
- 不想暴露复杂的、与算法相关的数据结构
对于 ApiService 来说,策略模式是很合适的——因为 Mock 和 Remote 就是"获取数据"这件事的两种不同策略。
2.5 扩展:更多策略
除了 Mock 和 Remote,还可以有更多策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Mock | 本地静态数据 | 开发、演示 |
| Remote | 远程 API | 生产环境 |
| CacheFirst | 先查缓存,没有再请求 | 节省流量、提高速度 |
| NetworkFirst | 先请求网络,失败用缓存 | 数据要新鲜 |
| StaleWhileRevalidate | 先返回缓存,同时后台更新 | 体验最好,实现复杂 |
这些策略可以通过装饰器模式叠加使用——比如 CacheFirst 就是在 Remote 外面套了一层缓存。
// 装饰器模式:给 Repository 加缓存
class CachedEthnicRepository implements EthnicRepository {
private cache: Map<string, EthnicGroup> = new Map();
constructor(private realRepo: EthnicRepository) {}
async getById(id: string): Promise<EthnicGroup | null> {
// 先查缓存
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
// 没有就调真实的
const result = await this.realRepo.getById(id);
if (result) {
this.cache.set(id, result);
}
return result;
}
// ... 其他方法
}
// 使用:给 Remote 套一层缓存
const repo = new CachedEthnicRepository(new RemoteEthnicRepository());
装饰器模式和策略模式很搭,可以灵活组合各种能力。
三、核心接口实现
3.1 民族列表
public async getEthnicList(page: number = 1, pageSize: number = 56): Promise<EthnicGroup[]> {
// Mock 模式:前端分页
if (this.currentMode === DataSourceMode.MOCK) {
const start = (page - 1) * pageSize;
return ETHNIC_GROUPS.slice(start, start + pageSize);
}
// Remote 模式:调用远程 API
try {
const response = await this.httpClient.get<EthnicListResponse>(
`/ethnics?page=${page}&pageSize=${pageSize}`
);
if (response.success && response.data) {
return this.mapApiToModel(response.data.ethnics);
}
hilog.warn(DOMAIN, 'ApiService', 'getEthnicList failed, fallback to mock');
return ETHNIC_GROUPS.slice(0, pageSize); // 失败降级到 Mock
} catch (e) {
hilog.error(DOMAIN, 'ApiService', 'getEthnicList exception, fallback to mock');
return ETHNIC_GROUPS.slice(0, pageSize); // 异常降级到 Mock
}
}
这段代码体现了几个重要设计:
- Mock 和 Remote 走不同分支,但返回类型一样
- Remote 模式失败时降级到 Mock——即使后端挂了,用户至少还能看到数据
- 分页参数统一:page / pageSize 命名统一,前后端约定好
关于前端分页:
Mock 模式下是"前端分页"——所有数据都在前端,slice 一下就行。真实项目中应该是"后端分页"——后端返回对应页的数据。
3.2 民族详情
public async getEthnicById(id: string): Promise<EthnicGroup | null> {
if (this.currentMode === DataSourceMode.MOCK) {
return mockGetById(id);
}
try {
const response = await this.httpClient.get<EthnicApiItem>(`/ethnics/${id}`);
if (response.success && response.data) {
return this.mapApiItemToModel(response.data);
}
return null;
} catch (e) {
hilog.error(DOMAIN, 'ApiService', `getEthnicById exception for ${id}`);
return null;
}
}
详情页接口更简单——传 ID,返回单条数据。
3.3 搜索接口
public async searchEthnics(params: SearchParams): Promise<EthnicGroup[]> {
if (this.currentMode === DataSourceMode.MOCK) {
return mockSearch(params.keyword, params.region);
}
try {
const queryParams: Record<string, string> = {
keyword: params.keyword
};
if (params.region && params.region !== 'all') {
queryParams.region = params.region;
}
const response = await this.httpClient.get<EthnicListResponse>(
'/ethnics/search', queryParams
);
if (response.success && response.data) {
return this.mapApiToModel(response.data.ethnics);
}
return [];
} catch (e) {
hilog.error(DOMAIN, 'ApiService', 'searchEthnics exception');
return [];
}
}
搜索接口的特点:
- 参数是一个对象(SearchParams),不是零散的参数
- 可选参数(region)不传就不拼到 URL 里
- 空筛选(region=‘all’)也不拼
3.4 每日冷知识
public async getDailyFact(date?: string): Promise<DailyFactResponse | null> {
if (this.currentMode === DataSourceMode.MOCK) {
return this.getMockDailyFact(date);
}
try {
const url = date ? `/facts/daily?date=${date}` : '/facts/daily';
const response = await this.httpClient.get<DailyFactResponse>(url);
if (response.success && response.data) {
return response.data;
}
return null;
} catch (e) {
hilog.error(DOMAIN, 'ApiService', 'getDailyFact exception');
return null;
}
}
四、数据映射:防腐层
4.1 什么是防腐层
"防腐层(Anti-Corruption Layer,ACL)"是领域驱动设计(DDD)里的概念。简单说就是:
外部系统的数据结构,不要直接用到我们的业务代码里。中间加一层转换,把外部数据转换成我们的内部模型。
为什么叫"防腐"?因为外部系统的变化(字段改名、结构调整)会"腐蚀"我们的业务代码——到处都要改。加了防腐层,外部变化就被挡在外面了,只需要改转换逻辑。
4.2 数据映射实现
// API 返回的数据结构(外部模型)
export interface EthnicApiItem {
id: string;
name: string;
name_en: string; // 下划线命名(后端风格)
alias_name: string; // 字段名可能和前端不一样
population_num: string;
// ...
}
// 前端业务模型(内部模型)
export interface EthnicGroup {
id: string;
name: string;
nameEn: string; // 小驼峰(前端风格)
alias: string; // 字段名更简洁
population: string;
// ...
}
转换函数:
private mapApiItemToModel(item: EthnicApiItem): EthnicGroup {
return {
id: item.id,
name: item.name,
nameEn: item.nameEn,
alias: item.alias,
pinyin: item.pinyin,
population: item.population,
populationRank: item.populationRank,
language: item.language,
languageEn: item.languageEn,
// ... 所有字段一一映射
};
}
private mapApiToModel(items: EthnicApiItem[]): EthnicGroup[] {
return items.map(item => this.mapApiItemToModel(item))
.filter((item): item is EthnicGroup => item !== null);
}
类型守卫(Type Guard):
filter((item): item is EthnicGroup => item !== null) 这个写法叫"类型守卫"——告诉 TypeScript 编译器,过滤后数组里的元素都是 EthnicGroup,没有 null。
💡 什么时候需要防腐层?
场景 需要吗? 原因 前后端是一个团队,字段约定一致 可选 变化少,不改也行 后端是第三方/外部团队 必须 他们改字段不会通知你 数据来源多个(后端 + Mock + 缓存) 必须 统一成内部模型,上层不用区分 项目周期长、需求变化多 必须 以后改起来方便 本项目因为有 Mock 和 Remote 两种数据源,而且是教学项目,所以加了防腐层。
4.3 防腐层的价值
| 好处 | 说明 |
|---|---|
| 隔离变化 | 后端改字段名,只改 map 函数,不用改业务代码 |
| 统一数据 | 多个数据源(Mock/Remote/缓存)统一成同一种结构 |
| 类型安全 | 经过转换的数据,类型是确定的,不是 any |
| 可以加逻辑 | 转换时可以做格式化、默认值、字段合并等 |
| 易于测试 | 转换函数是纯函数,好写单元测试 |
4.4 代价与权衡
天下没有免费的午餐,防腐层也有代价:
- 代码量增加:多写一套类型定义和转换函数
- 性能开销:每次请求都要遍历转换一次(一般可以忽略)
- 可能漏字段:新增字段时,忘了在 map 函数里加
什么时候不值得加?
- 小项目、生命周期短
- 前后端完全一体化,字段约定绝对稳定
- 数据结构简单,没什么转换逻辑
五、降级策略
5.1 什么是降级
降级就是"主方案不行了,用备用方案顶上去",保证核心功能可用。
本项目的降级策略:
- 远程 API 请求失败 → 用 Mock 数据兜底
- 用户至少能看到内容,不会白屏
5.2 降级的实现
try {
const response = await this.httpClient.get<EthnicListResponse>('/ethnics');
if (response.success && response.data) {
return this.mapApiToModel(response.data.ethnics);
}
// 业务错误(success=false)也降级
hilog.warn(DOMAIN, 'ApiService', 'getEthnicList failed, fallback to mock');
return ETHNIC_GROUPS.slice(0, pageSize); // ← 降级
} catch (e) {
// 网络异常也降级
hilog.error(DOMAIN, 'ApiService', 'getEthnicList exception, fallback to mock');
return ETHNIC_GROUPS.slice(0, pageSize); // ← 降级
}
两种情况都会降级:
- 业务错误:请求成功了,但 success 是 false
- 网络异常:请求直接失败(没网、超时、服务器错误)
5.3 降级的用户体验
直接用 Mock 兜底,用户知道吗?
如果不告诉用户,用户可能会困惑——“为什么我搜不到最新的数据?”
更好的做法:
- 降级时返回数据,但同时标记"这是缓存/离线数据"
- UI 上给个提示:“当前网络不佳,显示的是离线内容”
- 让用户知道当前的状态
本项目简化处理了,直接返回 Mock 数据,没提示。
5.4 什么时候不该降级
不是所有接口都适合降级:
| 接口 | 能降级吗? | 原因 |
|---|---|---|
| 列表查询 | 可以 | 至少能看到历史/缓存数据 |
| 详情查询 | 可以 | 同上 |
| 登录 | 不行 | 身份验证不能用 Mock |
| 支付 | 不行 | 钱的事不能开玩笑 |
| 提交表单 | 不行 | 不能假装提交成功了 |
原则:读操作可以降级,写操作不能降级。
5.5 请求重试策略
一次请求失败了,要不要重试?怎么重试?这也是有讲究的。
重试三原则:
-
只重试可恢复的错误
- ✅ 可以重试:网络错误、超时、5xx、429
- ❌ 不要重试:400(参数错)、401(未登录)、403(无权限)
-
指数退避(Exponential Backoff)
- 第一次等 1 秒
- 第二次等 2 秒
- 第三次等 4 秒
- …
- 避免把服务器打挂了
-
最大重试次数
- 最多重试 2-3 次
- 不行就降级或报错,不能无限等
带重试的请求实现:
async requestWithRetry<T>(
requestFn: () => Promise<T>,
maxRetries: number = 2,
shouldRetry: (error: any) => boolean = isRetriableError
): Promise<T> {
let lastError: any;
for (let i = 0; i <= maxRetries; i++) {
try {
return await requestFn();
} catch (error) {
lastError = error;
// 判断是否应该重试
if (!shouldRetry(error) || i === maxRetries) {
break;
}
// 指数退避等待
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
function isRetriableError(error: any): boolean {
const retriableCodes = [429, 500, 502, 503, 504];
return retriableCodes.includes(error?.statusCode) || error?.isNetworkError;
}
什么时候不应该重试?
- 用户主动取消的请求
- 表单提交(可能重复提交)
- 支付等敏感操作
- 明确的业务错误(参数不对、权限不够)
5.6 熔断机制
如果一个接口连续失败很多次,说明服务可能挂了,这时候再继续请求也是浪费。这时候应该"熔断"——暂时停止请求,过一会儿再试。
熔断器的三种状态:
关闭(Closed)
│
│ 失败率超过阈值
▼
打开(Open)◄──────────┐
│ │
│ 等待冷却时间 │ 探测成功
▼ │
半开(Half-Open)──────┘
│
│ 又失败了
▼
继续打开
- Closed(关闭):正常请求,统计失败率
- Open(打开):直接拒绝请求,快速失败
- Half-Open(半开):放少量请求探探路,好了就恢复,不好继续打开
简单的熔断器实现:
class CircuitBreaker {
private state: 'closed' | 'open' | 'half-open' = 'closed';
private failureCount = 0;
private lastFailureTime = 0;
private static readonly FAILURE_THRESHOLD = 5; // 连续失败 5 次就熔断
private static readonly COOLDOWN_TIME = 30000; // 冷却 30 秒
private static readonly HALF_OPEN_MAX_CALLS = 1; // 半开状态只放 1 个请求
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
// 检查是否过了冷却时间
if (Date.now() - this.lastFailureTime > CircuitBreaker.COOLDOWN_TIME) {
this.state = 'half-open';
this.failureCount = 0;
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= CircuitBreaker.FAILURE_THRESHOLD) {
this.state = 'open';
}
}
}
熔断器可以有效地保护系统,避免雪崩效应。
六、缓存策略
接口请求要不要缓存?答案是:看情况。变化不频繁的数据缓存可以大幅提升性能、节省流量,变化频繁的数据缓存反而会导致数据不一致。
6.1 缓存的层级
缓存不是只有一种,而是有多层的:
┌─────────────────────────────────────────┐
│ 内存缓存(最快,重启丢失) │
│ Map / 对象,App 运行期间有效 │
├─────────────────────────────────────────┤
│ 磁盘缓存(较快,持久化) │
│ Preferences / 数据库 / 文件 │
│ App 重启还有 │
├─────────────────────────────────────────┤
│ HTTP 缓存(系统级) │
│ 浏览器 / 系统 HTTP 栈自动处理 │
│ 基于 Cache-Control 等头 │
└─────────────────────────────────────────┘
各层缓存的对比:
| 缓存层 | 速度 | 容量 | 持久化 | 适用场景 |
|---|---|---|---|---|
| 内存缓存 | 极快 | 小 | 否 | 高频访问、数据量小 |
| 磁盘缓存 | 快 | 中 | 是 | 中等频率、数据量中 |
| HTTP 缓存 | 快 | 大 | 是 | 静态资源、GET 请求 |
6.2 缓存策略有哪些
| 策略 | 说明 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Cache First | 先查缓存,有就用,没有再请求 | 快、省流量 | 数据可能不新 | 变化不频繁的数据 |
| Network First | 先请求网络,失败用缓存 | 数据新鲜 | 慢、耗流量 | 数据要新鲜 |
| Stale While Revalidate | 先返回缓存,同时后台更新 | 体验最好 | 实现复杂 | 平衡速度和新鲜度 |
| Network Only | 只用网络,不缓存 | 数据最新 | 慢、耗流量 | 实时性要求高 |
| Cache Only | 只用缓存,不请求 | 最快 | 可能没数据 | 纯离线场景 |
6.3 内存缓存的实现
最简单的内存缓存,用 Map 就行:
class MemoryCache<T> {
private cache: Map<string, { value: T; expireAt: number }> = new Map();
private defaultTtl: number = 5 * 60 * 1000; // 默认 5 分钟
get(key: string): T | null {
const item = this.cache.get(key);
if (!item) return null;
// 检查是否过期
if (item.expireAt < Date.now()) {
this.cache.delete(key);
return null;
}
return item.value;
}
set(key: string, value: T, ttl?: number): void {
this.cache.set(key, {
value,
expireAt: Date.now() + (ttl || this.defaultTtl)
});
}
invalidate(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}
在 ApiService 中使用:
export class ApiService {
private ethnicListCache = new MemoryCache<EthnicGroup[]>();
private ethnicDetailCache = new MemoryCache<EthnicGroup>();
async getEthnicList(page: number = 1, pageSize: number = 56): Promise<EthnicGroup[]> {
const cacheKey = `list_${page}_${pageSize}`;
// 先查缓存
const cached = this.ethnicListCache.get(cacheKey);
if (cached) {
return cached;
}
// 缓存没有,去请求
const result = await this.getEthnicListFromSource(page, pageSize);
// 写入缓存
if (result.length > 0) {
this.ethnicListCache.set(cacheKey, result, 10 * 60 * 1000); // 10 分钟
}
return result;
}
}
6.4 缓存失效策略
缓存最怕的就是"缓存了旧数据"。怎么处理缓存失效?
1. TTL(过期时间)
最简单的方式——设个过期时间,到点自动失效。
- 变化少的数据:TTL 长一点(1小时、1天)
- 变化多的数据:TTL 短一点(5分钟、10分钟)
- 实时数据:不缓存
2. 主动失效
数据更新了,主动把缓存删掉:
async updateEthnic(id: string, data: any): Promise<void> {
// 1. 先更新远程数据
await this.remoteUpdate(id, data);
// 2. 再删缓存
this.ethnicDetailCache.invalidate(id);
this.ethnicListCache.clear(); // 列表可能也受影响,全清了
}
3. 缓存穿透、击穿、雪崩
这三个是缓存的经典问题:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据,每次都打数据库 | 缓存空值、布隆过滤器 |
| 缓存击穿 | 热点 key 过期,大量请求同时打数据库 | 互斥锁、永不过期+后台更新 |
| 缓存雪崩 | 大量 key 同时过期,请求全打数据库 | 过期时间加随机值、多级缓存 |
对于民族图鉴这个项目,数据量不大,用户量也不大,这些问题暂时不用太担心。但知道这些概念,以后做大型项目的时候能想起来。
6.5 本项目的缓存规划
目前本项目因为是 Mock 数据,本来就在本地,所以没做缓存。
但如果以后接了真实 API,可以考虑:
| 数据类型 | 缓存策略 | TTL |
|---|---|---|
| 民族列表 | Cache First | 1 小时 |
| 民族详情 | Cache First | 30 分钟 |
| 每日冷知识 | Stale While Revalidate | 1 天 |
| 搜索结果 | 不缓存 | - |
| 用户相关 | 不缓存 | - |
缓存是一把双刃剑——用得好性能翻倍,用不好数据乱套。关键是根据数据特性选择合适的策略。
七、HttpClient 工具与远程 API 实现
ApiService 底层用了 HttpClient 工具类。让我们看看它的设计。
7.1 为什么封装 HttpClient
直接用系统的 HTTP 能力(比如 HarmonyOS 的 http 模块)也可以,但封装一层更好:
- 统一 baseUrl
- 统一请求头(Token、Content-Type 等)
- 统一错误处理
- 统一响应格式解析
- 方便以后替换底层实现
7.2 典型的 HttpClient 结构
// 伪代码:HttpClient 的典型结构
export class HttpClient {
private baseUrl: string = '';
private defaultHeaders: Record<string, string> = {};
init(baseUrl: string): void {
this.baseUrl = baseUrl;
}
// GET 请求
async get<T>(url: string, params?: Record<string, string>): Promise<ApiResponse<T>> {
// 1. 拼接 URL
// 2. 加请求头
// 3. 发送请求
// 4. 解析响应
// 5. 统一错误处理
}
// POST 请求
async post<T>(url: string, body?: object): Promise<ApiResponse<T>> {
// ...
}
// PUT 请求
async put<T>(url: string, body?: object): Promise<ApiResponse<T>> {
// ...
}
// DELETE 请求
async delete<T>(url: string): Promise<ApiResponse<T>> {
// ...
}
}
这是一个很经典的 HTTP 客户端封装模式。不管是 Axios(Web)、Dio(Flutter)还是自己封装,思路都是一样的。
7.3 拦截器机制
拦截器(Interceptor)是 HttpClient 的灵魂——在请求发出前和响应回来后,可以统一做一些处理。
请求 → 请求拦截器1 → 请求拦截器2 → 服务器 → 响应拦截器1 → 响应拦截器2 → 业务代码
请求拦截器可以做什么?
- 加请求头(Token、Content-Type)
- 加签名
- 打印请求日志
- 参数格式化
- 计算请求耗时
响应拦截器可以做什么?
- 统一解析响应格式
- 统一错误处理
- Token 过期自动刷新
- 打印响应日志
- 数据脱敏
拦截器的简单实现:
type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
type ResponseInterceptor = (response: any) => any | Promise<any>;
class HttpClient {
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.responseInterceptors.push(interceptor);
}
async request<T>(config: RequestConfig): Promise<T> {
// 1. 执行请求拦截器
for (const interceptor of this.requestInterceptors) {
config = await interceptor(config);
}
// 2. 发送请求
let response = await this.doRequest(config);
// 3. 执行响应拦截器
for (const interceptor of this.responseInterceptors) {
response = await interceptor(response);
}
return response as T;
}
}
实际项目中常用的拦截器:
// Token 拦截器
httpClient.addRequestInterceptor((config) => {
const token = getToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// 日志拦截器
httpClient.addRequestInterceptor((config) => {
console.info(`[HTTP] Request: ${config.method} ${config.url}`);
return config;
});
// 错误处理拦截器
httpClient.addResponseInterceptor((response) => {
if (!response.success) {
throw new ApiError(response.code, response.message);
}
return response.data;
});
拦截器机制让 HttpClient 非常灵活——想加什么功能就加什么拦截器,不用改核心代码。
7.4 请求取消
页面销毁了,请求还在继续——回来了更新一个不存在的页面,可能会报错。这时候需要请求取消。
AbortController:
现代浏览器(和鸿蒙)都支持 AbortController,可以用来取消请求:
// 使用示例
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被取消了');
}
});
// 取消请求
controller.abort();
在页面中使用:
@Component
struct MyPage {
private abortController: AbortController | null = null;
aboutToAppear() {
this.loadData();
}
aboutToDisappear() {
// 页面销毁时取消请求
this.abortController?.abort();
}
async loadData() {
this.abortController = new AbortController();
try {
const data = await apiService.getEthnicList({
signal: this.abortController.signal
});
// 处理数据
} catch (e) {
// 忽略取消的错误
}
}
}
对于民族图鉴这个规模的项目,请求取消不是必须的。但如果以后接口多了、页面复杂了,这是个很有用的优化。
7.5 鸿蒙 HTTP 请求的实现
在 HarmonyOS 中,使用 @kit.NetKit 的 http 模块来发请求:
import { http } from '@kit.NetKit';
// 创建 HTTP 请求
const httpRequest = http.createHttp();
// 发送 GET 请求
httpRequest.request(
'https://api.example.com/ethnics',
{
method: http.RequestMethod.GET,
header: {
'Content-Type': 'application/json',
},
connectTimeout: 10000,
readTimeout: 10000,
},
(err, data) => {
if (err) {
console.error('请求失败', err);
return;
}
console.info('请求成功', data.result);
}
);
鸿蒙的 HTTP 模块支持:
- 常用的 HTTP 方法(GET、POST、PUT、DELETE 等)
- 请求头设置
- 超时设置
- 响应头获取
- 文件上传下载
实际项目中,一般会把鸿蒙的 http 模块再封装一层,就是我们的 HttpClient。
八、API 服务的测试与 Mock
测试是保证质量的重要手段。API 服务的测试有它的特殊性——因为它依赖外部接口。
8.1 为什么 API 服务需要测试
API 服务是连接前后端的桥梁,这层出问题影响很大:
- 数据格式不对:页面渲染不出来,甚至崩溃
- 错误处理不好:用户看到一堆看不懂的错误
- 降级策略失效:后端挂了前端也挂了
- 边界情况没处理:空数据、超长数据、异常格式…
所以 API 服务也需要好好测试。
8.2 测试的分层
| 测试层级 | 测什么 | 怎么测 | 难度 |
|---|---|---|---|
| 单元测试 | 数据映射、工具函数 | 纯函数测试 | ⭐⭐ |
| 集成测试 | ApiService 整体流程 | Mock 底层 HTTP | ⭐⭐⭐ |
| 端到端测试 | 从页面到真实接口 | 用测试环境接口 | ⭐⭐⭐⭐ |
对于 API 服务层,重点是单元测试 + 集成测试。
8.3 Mock 数据的测试价值
Mock 数据不只是给前端开发用的,它还有测试价值:
- 稳定的测试数据:不用担心后端接口变化导致测试失败
- 可控的测试场景:可以模拟各种边界情况(空数据、错误、超时)
- 快速执行:不用发真实请求,测试跑得飞快
- 离线测试:没网也能测
8.4 单元测试示例:数据映射函数
数据映射(防腐层)是最适合写单元测试的——纯函数,输入输出明确。
// 测试 mapApiItemToModel
describe('mapApiItemToModel', () => {
it('should map all fields correctly', () => {
const apiItem: EthnicApiItem = {
id: 'han',
name: '汉族',
name_en: 'Han',
alias_name: '华夏',
population_num: '1,284,446,389',
};
const model = mapApiItemToModel(apiItem);
expect(model.id).toBe('han');
expect(model.name).toBe('汉族');
expect(model.nameEn).toBe('Han');
expect(model.alias).toBe('华夏');
expect(model.population).toBe('1,284,446,389');
});
it('should handle missing optional fields', () => {
const apiItem: Partial<EthnicApiItem> = {
id: 'test',
name: '测试',
};
const model = mapApiItemToModel(apiItem as EthnicApiItem);
expect(model.id).toBe('test');
expect(model.name).toBe('测试');
expect(model.nameEn).toBe('');
expect(model.alias).toBe('');
});
});
8.5 集成测试示例:ApiService
集成测试需要 Mock 底层的 HTTP 调用,只测 ApiService 的逻辑。
// Mock HttpClient
class MockHttpClient {
getMockData: any = null;
getError: any = null;
async get(url: string) {
if (this.getError) {
throw this.getError;
}
return this.getMockData;
}
}
describe('ApiService', () => {
let apiService: ApiService;
let mockHttp: MockHttpClient;
beforeEach(() => {
mockHttp = new MockHttpClient();
apiService = new ApiService();
// 注入 Mock 的 HttpClient
(apiService as any).httpClient = mockHttp;
apiService.setMode(DataSourceMode.REMOTE);
});
it('should return ethnic list on success', async () => {
// 准备 Mock 数据
mockHttp.getMockData = {
success: true,
data: {
ethnics: [
{ id: 'han', name: '汉族', name_en: 'Han' },
{ id: 'mongol', name: '蒙古族', name_en: 'Mongol' },
]
}
};
// 调用
const result = await apiService.getEthnicList(1, 20);
// 验证
expect(result.length).toBe(2);
expect(result[0].id).toBe('han');
expect(result[0].nameEn).toBe('Han');
});
it('should fallback to mock on network error', async () => {
// 模拟网络错误
mockHttp.getError = new Error('Network error');
// 调用
const result = await apiService.getEthnicList(1, 20);
// 验证降级到 Mock 了
expect(result.length).toBeGreaterThan(0);
});
});
8.6 测试边界情况
好的测试不能只测正常情况,还要测各种边界情况:
| 边界情况 | 测试点 |
|---|---|
| 空列表 | 返回空数组,不要报错 |
| 超长列表 | 分页是否正常 |
| 字段缺失 | 缺字段时给默认值 |
| 类型不对 | 后端返回类型错了能不能兜底 |
| 网络超时 | 超时错误能不能正确降级 |
| 404 | 资源不存在时返回 null |
| 500 | 服务器错误能不能降级 |
| 重复请求 | 短时间发多个请求会不会有问题 |
把这些边界情况都覆盖到,API 服务的质量就有保障了。
8.7 Mock 数据的质量保证
Mock 数据不只是"随便编点数据",它的质量也很重要:
- 真实性:数据要像真的,不能一眼看出是假的
- 完整性:所有字段都要有值,不能有 undefined
- 多样性:不能所有数据都长一样,要有变化
- 边界情况:要有超长文本、空值、特殊字符等
Mock 数据质量检查清单:
- 字段数量和类型和真实接口一致
- 字符串长度有长有短
- 列表有长有短(0 条、1 条、很多条)
- 特殊字符(中文、emoji、空格)
- 边界值(0、最大值、负数)
高质量的 Mock 数据,既能让开发体验好,也能让测试更有价值。
九、错误处理
9.1 错误分层
API 请求的错误可以分好几层:
| 层级 | 错误类型 | 处理方式 |
|---|---|---|
| 网络层 | 没网、超时、DNS 失败 | 抛异常,ApiService 捕获降级 |
| HTTP 层 | 404、500、401、403 | 解析状态码,转成业务错误 |
| 业务层 | success=false、错误码 | 根据错误码给用户提示 |
| 数据层 | 字段缺失、类型不对 | 转换时给默认值或报错 |
9.2 统一错误格式
interface ApiResponse<T> {
success: boolean;
code: number; // 业务错误码
message: string; // 错误提示
data: T; // 业务数据
}
统一的响应格式,让错误处理也能统一。
9.3 错误码处理
常见的错误码处理:
| 错误码 | 含义 | 处理方式 |
|---|---|---|
| 200 / 0 | 成功 | 正常返回 data |
| 401 | 未登录 / Token 过期 | 跳登录页 |
| 403 | 无权限 | 提示"没有权限" |
| 429 | 限流 | 提示"操作太频繁" |
| 500 | 服务器错误 | 提示"服务器开小差了" |
这些全局通用的错误处理,可以放在 HttpClient 的响应拦截器里统一处理,不用每个接口都写一遍。
十、架构设计总结
10.1 完整的层次结构
┌─────────────────────────────────────────────────────┐
│ 页面层(Pages) │
│ 只关心"我要什么数据",不关心从哪来 │
└───────────────────────┬─────────────────────────────┘
│ 调用
┌───────────────────────▼─────────────────────────────┐
│ ApiService(服务层) │
│ Repository 模式 / 策略模式 / 外观模式 │
│ 统一接口 / 模式切换 / 降级策略 / 缓存策略 │
│ 数据映射(防腐层) │
└───────┬───────────────────────┬─────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────────────┐
│ Mock 数据 │ │ HttpClient │
│ 本地静态数据 │ │ 拦截器 / 重试 / 取消 │
└───────────────┘ └───────────┬───────────┘
│
▼
┌───────────────────┐
│ 远程 API 服务 │
└───────────────────┘
┌───────────┴───────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 内存缓存 │ │ 磁盘缓存 │
└───────────────┘ └───────────────┘
10.2 设计模式盘点
| 模式 | 体现 |
|---|---|
| 单例模式 | ApiService 全局唯一实例 |
| 策略模式 | Mock / Remote 两种数据获取策略 |
| 外观模式 | 封装复杂的 HTTP 请求和数据转换 |
| 适配器模式 | 数据映射把 API 模型转换成业务模型 |
| 装饰器模式 | 缓存、日志、重试等能力可以叠加 |
| 降级模式 | Remote 失败时回退到 Mock |
| 熔断器模式 | 连续失败时熔断,保护系统 |
| 仓库模式 | Repository 模式抽象数据访问 |
10.3 设计原则
| 原则 | 体现 |
|---|---|
| 单一职责 | ApiService 只管 API 调用和数据转换 |
| 开闭原则 | 加新接口不用改现有接口,加新策略不用改旧代码 |
| 依赖倒置 | 页面依赖 ApiService 接口,不依赖具体 HTTP 实现 |
| 最小知识 | 页面只知道 ApiService,不知道 HttpClient 和 Mock |
| 面向接口编程 | Mock 和 Remote 实现相同的接口,可互换 |
十一、最佳实践
11.1 接口命名规范
// ✅ 好的命名:动词 + 名词
getEthnicList() // 获取民族列表
getEthnicById() // 根据ID获取民族详情
searchEthnics() // 搜索民族
createEthnic() // 创建民族
updateEthnic() // 更新民族
deleteEthnic() // 删除民族
// ❌ 不好的命名
ethnics() // 干什么?get 还是 set?
list() // 什么列表?
query() // 查询什么?
getData() // 太笼统了
命名要"见名知意",而且风格统一。
11.2 参数多少合适
// ✅ 好:参数多了就用对象
interface SearchParams {
keyword: string;
region?: string;
page?: number;
pageSize?: number;
}
searchEthnics(params: SearchParams): Promise<EthnicGroup[]>
// ❌ 不好:参数太多,记不住顺序
searchEthnics(keyword: string, region: string, page: number, pageSize: number)
一般超过 3 个参数就建议用对象了。好处:
- 不用记顺序
- 可选参数方便
- 以后加参数不破坏现有调用
11.3 接口版本管理
接口不是一成不变的,后端接口会升级。怎么处理版本?
常见的版本管理方式:
| 方式 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 版本 | /api/v1/ethnics |
直观、清晰 | URL 会变 |
| Header 版本 | Api-Version: v1 |
URL 不变 | 不直观 |
| 参数版本 | ?version=v1 |
简单 | 参数有点乱 |
推荐 URL 版本,最直观。
11.4 联调前后端协作
前后端联调是 API 层最容易出问题的地方。怎么让联调更顺畅?
建议:
- 先定接口再开发:前后端先把接口定义好,再各自开发
- Mock 先行:前端用 Mock 数据开发,后端写完了再对接
- 文档同步:接口有变化及时更新文档
- 统一约定:请求头、响应格式、错误码、分页参数… 都要约定好
前后端约定清单:
- 接口 URL 命名规范
- 请求方法(GET/POST/PUT/DELETE)
- 请求参数格式(query / body)
- 响应统一格式(code/message/data)
- 分页参数命名(page/pageSize 还是 offset/limit)
- 错误码定义
- Token 怎么传(Header?Cookie?)
- 时间格式(时间戳?ISO 字符串?)
把这些都约定清楚,联调就能少踩很多坑。
十二、小结
| 技术点 | 核心要点 | 难度 |
|---|---|---|
| Repository 模式 | 抽象数据访问,统一数据来源 | ⭐⭐⭐ |
| 策略模式 | Mock / Remote / CacheFirst 等策略切换 | ⭐⭐⭐ |
| 双模式架构 | Mock / Remote 无缝切换 | ⭐⭐⭐ |
| 统一接口 | 对外方法签名一致,页面不感知数据源 | ⭐⭐ |
| 防腐层 | DTO → 领域模型转换,隔离外部变化 | ⭐⭐⭐⭐ |
| 降级策略 | Remote 失败回退 Mock,保证可用 | ⭐⭐⭐ |
| 缓存策略 | 内存缓存 / 磁盘缓存 / 缓存失效 / TTL | ⭐⭐⭐⭐ |
| HttpClient | 统一请求封装,拦截器 / 重试 / 取消 | ⭐⭐⭐ |
| 拦截器机制 | 请求拦截 / 响应拦截,灵活扩展 | ⭐⭐⭐ |
| 错误处理 | 网络层 / HTTP层 / 业务层 / 数据层 | ⭐⭐⭐⭐ |
| 重试与熔断 | 指数退避重试,熔断器保护 | ⭐⭐⭐⭐ |
| 测试策略 | 单元测试 / 集成测试 / Mock 测试 | ⭐⭐⭐ |
| 设计模式 | 单例 / 策略 / 外观 / 适配器 / 装饰器 | ⭐⭐⭐ |
ApiService 是连接前端和后端的桥梁。这层设计得好,前后端可以并行开发、后端挂了前端还能跑、接口重构不影响业务代码。它的价值,项目越往后越能体现。
到这里,"数据与服务篇"的核心服务就讲得差不多了。下一篇我们做一个总结,回顾所有服务的设计模式和架构思想。
更多推荐



所有评论(0)