在这里插入图片描述

一、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 模式的核心价值

  1. 隔离变化:数据来源的变化(换接口、加缓存)不影响业务层
  2. 统一契约:所有数据访问都走相同的接口
  3. 易于测试:可以用 Mock 实现来测试业务逻辑
  4. 灵活切换:开发用 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 本身 持有策略引用,对外提供服务

为什么用策略模式?

  1. 算法可以自由切换:想 Mock 就 Mock,想远程就远程,改个配置就行
  2. 扩展性好:以后加个"缓存优先"策略,加个类就行,不用改现有代码
  3. 避免多重条件判断:不用到处写 if (mode === 'mock') { ... } else { ... }
  4. 单一职责:每个策略只干自己的事,Mock 不管网络,Remote 不管本地数据

策略模式的缺点

  1. 策略类会增多:每种策略一个类,策略多了类就多
  2. 客户端需要知道策略:客户端得知道有哪些策略,才能选
  3. 增加代码复杂度:简单的逻辑用策略模式反而显得重

什么时候用策略模式?

  • 一个系统有很多类,它们的区别只是行为不同
  • 需要在运行时动态选择算法
  • 算法需要独立于客户端而变化
  • 不想暴露复杂的、与算法相关的数据结构

对于 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
  }
}

这段代码体现了几个重要设计

  1. Mock 和 Remote 走不同分支,但返回类型一样
  2. Remote 模式失败时降级到 Mock——即使后端挂了,用户至少还能看到数据
  3. 分页参数统一: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);  // ← 降级
}

两种情况都会降级

  1. 业务错误:请求成功了,但 success 是 false
  2. 网络异常:请求直接失败(没网、超时、服务器错误)

5.3 降级的用户体验

直接用 Mock 兜底,用户知道吗?

如果不告诉用户,用户可能会困惑——“为什么我搜不到最新的数据?”

更好的做法:

  • 降级时返回数据,但同时标记"这是缓存/离线数据"
  • UI 上给个提示:“当前网络不佳,显示的是离线内容”
  • 让用户知道当前的状态

本项目简化处理了,直接返回 Mock 数据,没提示。

5.4 什么时候不该降级

不是所有接口都适合降级:

接口 能降级吗? 原因
列表查询 可以 至少能看到历史/缓存数据
详情查询 可以 同上
登录 不行 身份验证不能用 Mock
支付 不行 钱的事不能开玩笑
提交表单 不行 不能假装提交成功了

原则读操作可以降级,写操作不能降级

5.5 请求重试策略

一次请求失败了,要不要重试?怎么重试?这也是有讲究的。

重试三原则

  1. 只重试可恢复的错误

    • ✅ 可以重试:网络错误、超时、5xx、429
    • ❌ 不要重试:400(参数错)、401(未登录)、403(无权限)
  2. 指数退避(Exponential Backoff)

    • 第一次等 1 秒
    • 第二次等 2 秒
    • 第三次等 4 秒
    • 避免把服务器打挂了
  3. 最大重试次数

    • 最多重试 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 数据不只是给前端开发用的,它还有测试价值:

  1. 稳定的测试数据:不用担心后端接口变化导致测试失败
  2. 可控的测试场景:可以模拟各种边界情况(空数据、错误、超时)
  3. 快速执行:不用发真实请求,测试跑得飞快
  4. 离线测试:没网也能测

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 数据不只是"随便编点数据",它的质量也很重要:

  1. 真实性:数据要像真的,不能一眼看出是假的
  2. 完整性:所有字段都要有值,不能有 undefined
  3. 多样性:不能所有数据都长一样,要有变化
  4. 边界情况:要有超长文本、空值、特殊字符等

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 层最容易出问题的地方。怎么让联调更顺畅?

建议

  1. 先定接口再开发:前后端先把接口定义好,再各自开发
  2. Mock 先行:前端用 Mock 数据开发,后端写完了再对接
  3. 文档同步:接口有变化及时更新文档
  4. 统一约定:请求头、响应格式、错误码、分页参数… 都要约定好

前后端约定清单

  • 接口 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 是连接前端和后端的桥梁。这层设计得好,前后端可以并行开发、后端挂了前端还能跑、接口重构不影响业务代码。它的价值,项目越往后越能体现。

到这里,"数据与服务篇"的核心服务就讲得差不多了。下一篇我们做一个总结,回顾所有服务的设计模式和架构思想。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐