API 层架构设计 — 从 RESTful 到 GraphQL 的范式演进

写在前面

API 设计是软件工程中的一门艺术。好的 API 像一个设计精良的工具:用起来顺手,意图清晰,不容易用错。糟糕的 API 则像一个布满隐患的黑盒:调用者不知道该传什么,不知道返回什么,出了问题也不知道是谁的责任。

本篇从 API 设计的哲学出发,深入 RESTful 的理论基础与实践边界,分析 GraphQL 解决了什么问题又带来了什么新问题,介绍 BFF(Backend for Frontend)模式如何消解前端的 API 痛点,最后从领域驱动设计(DDD)的视角看 API 的组织方式。这些思考,不只是"怎么写 api/user.js",而是"如何设计一个前后端都满意的 API 层"。

参考资料:Roy Fielding 博士论文GraphQL 官方文档JSON:API 规范tRPC 文档


目录

  • [1. API 设计的哲学:契约优先思维](#1. API 设计的哲学:契约优先思维)
  • [2. RESTful 深度解析:六大约束的真正含义](#2. RESTful 深度解析:六大约束的真正含义)
  • [3. REST 的实践边界:什么时候 REST 不够用](#3. REST 的实践边界:什么时候 REST 不够用)
  • [4. GraphQL:声明式数据获取的革命](#4. GraphQL:声明式数据获取的革命)
  • [5. tRPC:端到端类型安全的新范式](#5. tRPC:端到端类型安全的新范式)
  • [6. BFF 模式:为前端量身定制的 API 层](#6. BFF 模式:为前端量身定制的 API 层)
  • [7. API 版本策略:如何优雅地演进接口](#7. API 版本策略:如何优雅地演进接口)
  • [8. 领域驱动设计(DDD)视角下的 API 组织](#8. 领域驱动设计(DDD)视角下的 API 组织)
  • [9. 契约测试:前后端联调的工程保障](#9. 契约测试:前后端联调的工程保障)
  • [10. 前端 API 层的完整工程实现](#10. 前端 API 层的完整工程实现)
  • [11. 接口安全设计](#11. 接口安全设计)
  • [12. 常见坑深度解析](#12. 常见坑深度解析)
  • 小结

1. API 设计的哲学:契约优先思维

API 是什么

API(Application Programming Interface,应用程序编程接口)的本质是模块间的契约(Contract)。这个契约规定:

  • 我接受什么输入(请求参数)
  • 我保证什么输出(响应格式)
  • 我在什么情况下会失败(错误码)

契约一旦建立,双方都不能单方面违反。后端改了响应字段名,前端代码立刻出 bug;前端传了错误参数,后端应该给出清晰的错误说明,而不是静默失败。

契约优先(Contract First)vs 代码优先(Code First)

代码优先(Code First):先写后端代码,再从代码生成文档。

javascript 复制代码
// 后端写了这个接口
app.get('/users', async (ctx) => {
  const users = await db.findAll()
  ctx.body = { code: 200, data: users }
})
// 然后前端来问:这个接口返回什么格式?
// 后端:你自己看代码吧......或者等我写文档

契约优先(Contract First):先用 OpenAPI/GraphQL Schema 等描述语言定义接口契约,前后端都基于契约实现。

yaml 复制代码
# openapi.yaml - 先定义契约
paths:
  /users:
    get:
      summary: 获取用户列表
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1 }
      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserListResponse'

契约优先的好处:

  • 前后端并行开发(前端 Mock + 契约,不用等后端)
  • 接口文档和实现永远一致(由工具保证)
  • 变更有明确的破坏性变更检测

好 API 的四个标准

1. 一致性(Consistency):命名规则、响应格式、错误码在所有接口中保持一致,减少认知负担。

2. 可发现性(Discoverability) :通过 API 名称就能猜到用法,不需要查文档。getUserListfetchData 更好,因为前者可发现。

3. 正向激励(Pit of Success):API 的"显然用法"应该是正确的用法,要把用户"引导"进好的实践,而不是"陷阱"。

4. 向后兼容(Backward Compatibility):新版本接口不应该破坏已有的调用方,变更需要有策略地进行。


2. RESTful 深度解析:六大约束的真正含义

Roy Fielding 在 2000 年的博士论文中定义了 REST 架构,它有六大约束(不是六大"最佳实践"):

约束 1:客户端-服务器(Client-Server)

复制代码
关注点分离:
  客户端负责 UI 和用户体验
  服务端负责数据存储和业务逻辑
  两者通过统一接口通信,互不依赖内部实现

意义:
  前端可以独立演进(换 React、换 Vue),不影响后端
  后端可以独立扩展(换数据库、换语言),不影响前端

约束 2:无状态(Stateless)

复制代码
每个请求必须包含处理所需的全部信息,服务器不保存客户端状态

意义:
  服务器无需维护会话,可以水平扩展(任意节点都能处理请求)
  负载均衡不需要"粘性会话"(Sticky Session)
  
实践:
  使用 JWT(自包含用户信息)而非 Session(服务器需要存储)
  翻页参数由客户端携带(page=1&pageSize=20),不由服务器记录"当前页"

约束 3:可缓存(Cacheable)

复制代码
响应必须标注是否可缓存,允许客户端/中间代理缓存响应

实践:
  GET /users/list → 可缓存(数据不经常变化)
    Cache-Control: public, max-age=60, stale-while-revalidate=30
    
  POST /users → 不可缓存(写操作,结果不确定)
    Cache-Control: no-store
    
  GET /users/123 → 有条件缓存(资源可能被修改)
    ETag: "abc123"  ← 资源的指纹
    Last-Modified: Mon, 01 Jan 2024 00:00:00 GMT
    
客户端条件请求:
  If-None-Match: "abc123"   → 如果 ETag 匹配,返回 304 Not Modified(节省带宽)
  If-Modified-Since: ...    → 如果没有修改,返回 304

约束 4:统一接口(Uniform Interface)

这是 REST 最核心的约束,包含四个子约束:

资源标识:每个资源有唯一 URI

复制代码
/users/123       ← 用户 123
/users/123/orders  ← 用户 123 的所有订单
/users/123/orders/456  ← 用户 123 的订单 456

通过表现层操纵资源:客户端通过获取资源的"表现"(JSON/XML/HTML)来理解和操纵资源

复制代码
GET /users/123 → { id: 123, name: 'Alice', _links: { orders: '/users/123/orders' } }
// _links 是 HATEOAS(超媒体)的体现,告诉客户端"下一步可以做什么"

自描述消息:每个消息包含足够信息来描述如何处理

复制代码
Content-Type: application/json; charset=utf-8
// 告诉接收方如何解析消息体

HATEOAS(超媒体作为应用状态引擎):

json 复制代码
// 响应中包含相关操作的链接,客户端通过链接导航
{
  "userId": "123",
  "userName": "Alice",
  "_links": {
    "self": { "href": "/users/123" },
    "orders": { "href": "/users/123/orders" },
    "delete": { "href": "/users/123", "method": "DELETE" }
  }
}
// 实践中很少完全实现 HATEOAS,但"接口中包含关联资源链接"的思想是有价值的

约束 5-6:分层系统 & 按需代码

复制代码
分层系统:客户端不需要知道是直接与服务器通信,还是通过代理/网关
  意义:CDN、API Gateway、负载均衡器对客户端透明

按需代码(可选):服务器可以下发可执行代码(如 JavaScript)扩展客户端功能
  实践:WebAssembly 下发、Service Worker 更新

3. REST 的实践边界:什么时候 REST 不够用

REST 并不是万能的,理解它的局限有助于在复杂场景做出正确选择:

问题 1:Over-fetching(过度获取)

复制代码
场景:用户列表页只需要显示 name 和 avatar,
但 GET /users 返回了每个用户完整的 50 个字段
→ 浪费带宽,前端还要过滤数据

问题 2:Under-fetching(获取不足,N+1 问题)

复制代码
场景:显示用户列表,每行显示用户名和其所在部门名称
REST 解决方案:
  1. GET /users → 100 个用户
  2. GET /departments/1 → 部门 1 名称
  3. GET /departments/2 → 部门 2 名称
  ......
  101. GET /departments/100 → 部门 100 名称
  
共 101 个请求!这就是 N+1 问题。

解决 N+1 的 REST 方案:

复制代码
方案 1:后端做 JOIN
  GET /users?include=department → 用户 + 部门数据一次返回
  问题:不通用,每种组合都要专门的接口

方案 2:前端批量请求
  GET /departments?ids=1,2,3,4...
  问题:URL 长度有限制(2000 字符左右),IDs 太多会报错

问题 3:版本管理复杂

复制代码
REST API 演进时,破坏性变更(删除字段、改字段类型)如何处理?
  /v1/users → 保持兼容,长期维护
  /v2/users → 新版本
  
同时维护多个版本,维护成本随时间线性增长。

这些问题催生了 GraphQL 的诞生。


4. GraphQL:声明式数据获取的革命

GraphQL 的核心思想

GraphQL 由 Facebook 工程师在 2012 年设计,2015 年开源。它的核心是一个强类型的查询语言,让客户端精确指定需要什么数据:

graphql 复制代码
# 客户端精确描述需要的数据
query GetUserList {
  users(page: 1, pageSize: 10) {
    id
    name           ← 只要名字
    avatar         ← 只要头像
    department {   ← 一次性获取关联的部门
      name         ← 只要部门名字
    }
    # 不需要 email、phone、address 等字段,就不写
  }
}

服务器只返回查询中指定的字段:

json 复制代码
{
  "data": {
    "users": [
      {
        "id": "1",
        "name": "Alice",
        "avatar": "https://...",
        "department": { "name": "工程部" }
      }
    ]
  }
}

GraphQL vs REST 的核心差异

维度 REST GraphQL
端点数量 多个(每个资源一个) 通常只有一个(/graphql)
数据获取 服务端决定返回什么 客户端决定需要什么
Over/Under-fetching 常见问题 天然解决
类型系统 无(依赖文档) 强类型 Schema
实时数据 需要 Polling/SSE/WebSocket 内置 Subscription
N+1 问题 需要手动优化 DataLoader 解决
学习曲线 中高
后端复杂度 高(Resolver 实现)

GraphQL 的三种操作

graphql 复制代码
# 1. Query(查询,类似 GET)
query {
  user(id: "123") {
    name
    email
  }
}

# 2. Mutation(修改,类似 POST/PUT/DELETE)
mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
  }
}

# 3. Subscription(订阅,实时推送)
subscription OnUserUpdated {
  userUpdated {
    id
    name
    updatedAt
  }
}
# WebSocket 连接,服务器有变更时主动推送

Vue 中使用 GraphQL(Apollo Client)

javascript 复制代码
// main.js
import { ApolloClient, InMemoryCache } from '@apollo/client/core'
import { provideApolloClient } from '@vue/apollo-composable'

const apolloClient = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache(),
  headers: { Authorization: `Bearer ${token}` }
})

provideApolloClient(apolloClient)

// 组件中使用
import { useQuery, useMutation } from '@vue/apollo-composable'
import gql from 'graphql-tag'

const GET_USERS = gql`
  query GetUsers($page: Int!) {
    users(page: $page) {
      id
      name
      department { name }
    }
  }
`

const { result, loading, error } = useQuery(GET_USERS, { page: 1 })
// result.value.users → 用户列表(自动更新)

GraphQL 的适用场景判断

复制代码
适合 GraphQL 的场景:
  ✅ 数据关系复杂(多对多、深层嵌套)
  ✅ 多端(Web/App/小程序)共用同一 API,数据需求差异大
  ✅ 前端团队需要更多的数据获取自主权
  ✅ 产品迭代快,数据需求频繁变化

不适合 GraphQL 的场景:
  ❌ 简单的 CRUD 后台(REST 足够,GraphQL 过于复杂)
  ❌ 文件上传(GraphQL 需要特殊处理 multipart)
  ❌ 团队对 GraphQL 不熟悉,学习成本难以接受
  ❌ 后端不愿意重新实现 Resolver

本课程项目:REST 足够,不引入 GraphQL

5. tRPC:端到端类型安全的新范式

tRPC 是一个让前后端共享 TypeScript 类型的 RPC 框架,无需 schema 定义(像 GraphQL 的 Schema),类型从代码自动推断:

typescript 复制代码
// 后端定义(server/router.ts)
import { initTRPC } from '@trpc/server'

const t = initTRPC.create()

export const appRouter = t.router({
  getUserList: t.procedure
    .input(z.object({ page: z.number(), pageSize: z.number() }))
    .query(async ({ input }) => {
      return await db.users.findMany({ skip: (input.page - 1) * input.pageSize })
      // 返回类型:User[] (TypeScript 自动推断)
    }),
    
  createUser: t.procedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return await db.users.create({ data: input })
    })
})

export type AppRouter = typeof appRouter  // 导出类型(给前端用)
typescript 复制代码
// 前端使用(不需要手写 API 方法!)
import { createTRPCClient } from '@trpc/client'
import type { AppRouter } from '../server/router'  // 导入后端类型!

const trpc = createTRPCClient<AppRouter>({
  links: [/* 配置 */]
})

// 类型完全推断!IDE 有完整提示
const users = await trpc.getUserList.query({ page: 1, pageSize: 10 })
//    ^^^^^ 类型:User[],从后端自动推断,无需手写

await trpc.createUser.mutate({ name: 'Alice', email: 'alice@example.com' })
// 如果传了错误的字段,TypeScript 编译时就会报错,不需要等到运行时

tRPC 的核心价值:消灭前后端接口类型不同步的问题。后端改了返回类型,前端立刻得到编译错误,而不是等到运行时。

适用条件:前后端都是 TypeScript,且在同一个代码库(Monorepo)中。


6. BFF 模式:为前端量身定制的 API 层

BFF 是什么

BFF(Backend For Frontend,面向前端的后端)是一种架构模式:在后端微服务和前端之间,增加一个专门为前端服务的聚合层。

复制代码
没有 BFF 的架构:
  前端 → 直接调用 N 个微服务
          微服务 A(用户服务)
          微服务 B(订单服务)
          微服务 C(库存服务)
  
  问题:
  - 前端需要知道哪些微服务提供什么数据
  - 前端需要聚合多个微服务的数据(发多个请求)
  - 不同前端(Web/App)对同一数据有不同格式需求
  - 前端直接暴露后端微服务地址,安全问题

有 BFF 的架构:
  Web 前端 → Web BFF → 微服务 A、B、C
  App 前端 → App BFF → 微服务 A、B、C
  
  BFF 的职责:
  - 聚合多个微服务的数据(1 次调用替代 N 次)
  - 为特定前端裁剪数据格式
  - 认证鉴权(统一 Token 验证)
  - 限流熔断(保护下游微服务)

BFF 解决的具体问题

问题 1:用户详情页聚合

javascript 复制代码
// 没有 BFF:前端需要发 3 个请求
const [user, orders, permissions] = await Promise.all([
  userService.getUser(userId),
  orderService.getUserOrders(userId),
  permissionService.getUserPermissions(userId)
])

// 有 BFF:前端只发 1 个请求
const userDetail = await bff.getUserDetail(userId)
// BFF 内部:并行调用 3 个微服务,聚合数据返回

问题 2:移动端数据裁剪

javascript 复制代码
// Web 端需要完整用户数据(展示所有字段)
web-bff: GET /users/123 → { id, name, email, phone, address, bio, avatar, ... }

// App 端只需要关键信息(屏幕小,减少流量)
app-bff: GET /users/123 → { id, name, avatar }
// 同样的微服务,不同的 BFF 返回不同的数据结构

什么时候需要 BFF

复制代码
适合引入 BFF 的情况:
  ✅ 微服务架构,前端需要聚合多个服务的数据
  ✅ 多端(Web/App/小程序)对数据格式有差异化需求
  ✅ 需要前端特定的认证/鉴权逻辑
  ✅ 需要在前后端之间增加缓存层

单体应用/本课程项目:
  ❌ 不需要 BFF,一个后端服务足够
  直接用 Axios + API 模块即可

7. API 版本策略:如何优雅地演进接口

为什么 API 需要版本

复制代码
API 演进的困境:
  你有 100 个客户端使用 GET /users 接口
  你需要修改响应格式(把 userName 字段改名为 name)
  
  如果直接改:
  → 所有旧客户端立刻出 bug
  
  需要版本化:
  → GET /v1/users → 返回 { userName: 'Alice' }(维持旧格式)
  → GET /v2/users → 返回 { name: 'Alice' }(新格式)
  → 客户端按需迁移,新客户端直接用 v2

四种版本化策略

策略 1:URL 路径版本(最常见)

复制代码
GET /api/v1/users
GET /api/v2/users

优点:直观,版本在 URL 中一目了然,易于调试
缺点:URL 设计纯粹主义者认为 URL 应该是资源地址,版本不是资源的一部分
适用:大多数场景,包括本课程项目

策略 2:Query 参数版本

复制代码
GET /api/users?version=1
GET /api/users?version=2

优点:URL 路径干净
缺点:版本容易被忽略,缓存策略复杂

策略 3:Header 版本(语义纯粹)

复制代码
GET /api/users
Accept: application/vnd.company.v2+json

优点:URL 不包含版本,符合 REST 精神
缺点:浏览器无法直接测试,调试不方便
适用:追求 REST 纯粹性的场景(如公共 API)

策略 4:自动兼容(理想方案,实现难)

javascript 复制代码
// 后端:在同一接口中同时支持旧字段和新字段
{
  "id": "123",
  "name": "Alice",          // 新字段
  "userName": "Alice"       // 旧字段(废弃,但保留一段时间)
}
// 同时支持旧客户端(用 userName)和新客户端(用 name)
// 优点:无需维护多版本
// 缺点:API 会越来越"臃肿"

破坏性变更的判定

复制代码
破坏性变更(需要版本升级):
  ❌ 删除字段
  ❌ 重命名字段
  ❌ 改变字段类型(string → number)
  ❌ 改变 URL 路径
  ❌ 改变 HTTP 方法(GET → POST)
  ❌ 改变状态码含义

非破坏性变更(可以不升版本):
  ✅ 新增字段(旧客户端忽略未知字段)
  ✅ 废弃字段(保留字段,只是文档中标注 deprecated)
  ✅ 新增可选参数
  ✅ 性能优化(响应更快,但格式相同)

8. 领域驱动设计(DDD)视角下的 API 组织

DDD 的核心思想与 API 的关系

领域驱动设计(Domain-Driven Design,DDD)由 Eric Evans 在 2003 年的同名书籍中提出。其核心思想是:软件的复杂度来自于领域的复杂度,代码应该映射领域中的概念。

有界上下文(Bounded Context):DDD 中把复杂业务域划分为若干独立的"上下文",每个上下文内部有统一的语言(Ubiquitous Language)和模型。

复制代码
典型后台系统的有界上下文划分:

用户上下文(User Context):
  - 用户管理(CRUD)
  - 角色权限管理
  - 部门组织结构

订单上下文(Order Context):
  - 订单创建、支付、退款
  - 订单状态流转

库存上下文(Inventory Context):
  - 商品管理
  - 库存变动

审批上下文(Approval Context):
  - 请假申请、审批流
  - 工作流引擎

有界上下文 → API 模块的映射

复制代码
src/api/
├── user/                    # 用户上下文
│   ├── user.api.ts          # 用户 CRUD
│   ├── role.api.ts          # 角色管理
│   └── dept.api.ts          # 部门管理
│
├── order/                   # 订单上下文
│   ├── order.api.ts
│   └── payment.api.ts
│
└── approval/                # 审批上下文
    ├── application.api.ts
    └── workflow.api.ts

为什么按有界上下文而不是按页面组织 API?

复制代码
按页面组织(❌ 反模式):
  api/userListPage.ts
  api/userDetailPage.ts
  api/orderListPage.ts
  
  问题:页面变了,API 文件也要重命名;
        多个页面共用同一接口时,放哪里?

按领域上下文组织(✅ 推荐):
  api/user/user.api.ts (用户领域的所有用户相关接口)
  api/order/order.api.ts
  
  好处:接口聚合清晰,领域内聚,跨域依赖关系明显

9. 契约测试:前后端联调的工程保障

为什么需要契约测试

复制代码
传统联调方式的问题:
  1. 后端接口就绪前,前端无法测试
  2. 前端靠 Mock 数据开发,联调时发现实际格式不一致
  3. 后端修改了字段,没有通知前端,前端出 bug
  4. "这个接口没问题,肯定是你前端的问题"的扯皮
  
契约测试(Contract Testing):
  基于双方约定的契约(API 描述文档)自动验证实现
  后端每次部署前自动验证:实现是否符合契约?
  前端每次打包前自动验证:Mock 数据是否符合契约?

MSW(Mock Service Worker):现代前端 Mock 方案

typescript 复制代码
// mocks/handlers.ts - 定义 Mock 规则
import { http, HttpResponse } from 'msw'
import type { User } from '@/types/user'

export const handlers = [
  http.get('/api/users/list', ({ request }) => {
    const url = new URL(request.url)
    const page = Number(url.searchParams.get('page') ?? 1)
    
    return HttpResponse.json({
      code: 200,
      data: {
        list: generateMockUsers(10),
        total: 100,
        page,
        pageSize: 10
      }
    })
  }),
  
  http.post('/api/users', async ({ request }) => {
    const body = await request.json() as Partial<User>
    
    // 验证必填字段(契约验证)
    if (!body.userName || !body.email) {
      return HttpResponse.json(
        { code: 400001, message: '用户名和邮箱不能为空' },
        { status: 400 }
      )
    }
    
    return HttpResponse.json({
      code: 200,
      data: { ...body, userId: crypto.randomUUID() }
    })
  }),
]

// mocks/browser.ts - 浏览器环境启动
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
typescript 复制代码
// main.ts
if (import.meta.env.VITE_USE_MOCK === 'true') {
  const { worker } = await import('./mocks/browser')
  await worker.start({ onUnhandledRequest: 'bypass' })
}

MSW 的优势:工作在 Service Worker 层,真实拦截网络请求,比 Axios Mock 更接近真实网络行为,切换真实 API 时不需要改代码。


10. 前端 API 层的完整工程实现

目录结构(按 DDD 有界上下文组织)

复制代码
src/api/
├── types/                   # 类型定义
│   ├── common.ts            # 通用类型(分页、响应格式)
│   ├── user.ts              # 用户领域类型
│   └── order.ts             # 订单领域类型
│
├── user/
│   ├── user.api.ts          # 用户 CRUD
│   └── dept.api.ts          # 部门管理
│
├── order/
│   └── order.api.ts
│
└── index.ts                 # 统一导出

types/common.ts

typescript 复制代码
// 统一响应类型
export interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
}

// 分页响应
export interface PageResult<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

// 分页查询参数基类
export interface PageQuery {
  page: number
  pageSize: number
}

types/user.ts

typescript 复制代码
// 用户领域类型(严格对应后端响应)
export interface User {
  userId: string
  userName: string
  email: string
  phone?: string
  avatar?: string
  status: 'active' | 'inactive'
  roleIds: string[]
  deptId: string
  createTime: string
  updateTime: string
}

// 创建用户 DTO(Data Transfer Object)
export interface CreateUserDto {
  userName: string
  password: string
  email: string
  phone?: string
  roleIds: string[]
  deptId: string
}

// 更新用户 DTO(大部分字段可选)
export type UpdateUserDto = Partial<Omit<CreateUserDto, 'password'>>

// 用户列表查询参数
export interface UserListQuery extends PageQuery {
  userName?: string
  status?: User['status']
  deptId?: string
  roleId?: string
}

api/user/user.api.ts

typescript 复制代码
import request from '@/utils/request'
import type { User, CreateUserDto, UpdateUserDto, UserListQuery } from '@/api/types/user'
import type { PageResult } from '@/api/types/common'

const BASE = '/users'

/**
 * 用户管理 API
 * 
 * 命名规范:动词 + 名词(Pascal Case)
 * getUserList → 查询列表(总是 Query 而非 Fetch)
 * getUserDetail → 查询详情
 * createUser → 新增
 * updateUser → 全量更新(对应 PUT)
 * patchUser → 部分更新(对应 PATCH)
 * deleteUser → 删除
 */

export function getUserList(query: UserListQuery): Promise<PageResult<User>> {
  return request({ url: `${BASE}/list`, method: 'get', params: query })
}

export function getUserDetail(userId: string): Promise<User> {
  return request({ url: `${BASE}/${userId}`, method: 'get' })
}

export function createUser(dto: CreateUserDto): Promise<User> {
  return request({ url: BASE, method: 'post', data: dto })
}

export function updateUser(userId: string, dto: UpdateUserDto): Promise<User> {
  return request({ url: `${BASE}/${userId}`, method: 'put', data: dto })
}

export function deleteUser(userId: string): Promise<void> {
  return request({ url: `${BASE}/${userId}`, method: 'delete' })
}

export function batchDeleteUsers(userIds: string[]): Promise<void> {
  return request({ url: `${BASE}/batch`, method: 'delete', data: { userIds } })
}

// 文件上传示例
export function importUsers(file: File): Promise<{ imported: number; failed: number }> {
  const formData = new FormData()
  formData.append('file', file)
  return request({
    url: `${BASE}/import`,
    method: 'post',
    data: formData
    // 不设置 Content-Type,让 Axios 自动设置 multipart/form-data + boundary
  })
}

// 文件下载示例
export async function exportUsers(query: Partial<UserListQuery>): Promise<void> {
  const response = await request({
    url: `${BASE}/export`,
    method: 'get',
    params: query,
    responseType: 'blob'
  })
  
  // 触发浏览器下载
  const filename = decodeURIComponent(
    response.headers['content-disposition']?.split('filename=')[1] || '用户列表.xlsx'
  )
  const url = URL.createObjectURL(response.data)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  a.click()
  URL.revokeObjectURL(url)
}

11. 接口安全设计

常见的 API 安全威胁

SQL 注入(后端问题,但前端有义务了解):

javascript 复制代码
// ❌ 用户输入直接拼接到 SQL
`SELECT * FROM users WHERE name = '${userInput}'`
// 如果 userInput = "' OR 1=1 --",SQL 会返回所有用户

// 前端防护:参数化传递,不在前端拼接 SQL
// 后端防护:使用 ORM、参数化查询

XSS(跨站脚本攻击)

javascript 复制代码
// 危险:把用户输入直接渲染为 HTML
element.innerHTML = userInput  // ❌

// 如果 userInput = "<script>steal(document.cookie)</script>"
// 就是 XSS 攻击

// 防护:使用 textContent 而非 innerHTML(或用 DOMPurify 清理)
element.textContent = userInput  // ✅
// Vue 的 {{ }} 语法默认是安全的(不会执行 HTML),只有 v-html 需要小心

IDOR(不安全的直接对象引用)

javascript 复制代码
// ❌ 前端直接用 URL 参数引用资源,后端不验证所有权
GET /api/users/123  // 如果用户 A 把 123 改成 456,能否看到用户 456 的数据?

// 后端防护:每个查询都验证"当前用户是否有权访问该资源"
// 不只是"是否存在",还要验证"是否属于你"

12. 常见坑深度解析

坑 1:接口地址硬编码散落在组件中

javascript 复制代码
// ❌ URL 字符串直接写在组件
async fetchUsers() {
  const res = await axios.get('/api/user/list')  // 还是 /users/list?
}

// 后果:接口路径改了,需要全局搜索替换;拼写错误在运行时才发现

// ✅ 所有 URL 只出现在 API 模块中
// api/user/user.api.ts
export const getUserList = (query) => request({ url: '/users/list', params: query })

// 组件:只用函数名,不涉及 URL
import { getUserList } from '@/api/user/user.api'
const { list } = await getUserList({ page: 1 })

坑 2:大数字精度丢失

javascript 复制代码
// MongoDB 的 ObjectId 是 24 位十六进制,转为十进制可能超过 JS 安全整数范围
// 示例:{ "orderId": 9007199254740993 }
// JavaScript 接收后:9007199254740992(最后一位丢失!)

// 根本原因:IEEE 754 双精度浮点数只有 53 位精度,
// 最大安全整数 Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9007199254740991

// 解决方案 1(推荐):后端改为返回字符串 ID
{ "orderId": "9007199254740993" }

// 解决方案 2(前端):引入 json-bigint 处理大数
import JSONbig from 'json-bigint'
service.defaults.transformResponse = [
  data => {
    try { return JSONbig({ storeAsString: true }).parse(data) }
    catch { return JSON.parse(data) }
  }
]

坑 3:并发请求的错误处理

javascript 复制代码
// ❌ 其中一个失败,其他请求的结果也丢失
try {
  const [users, depts] = await Promise.all([getUserList(), getDeptList()])
} catch {
  // users 和 depts 都拿不到了
}

// ✅ 用 Promise.allSettled,分别处理每个请求的成功/失败
const [usersResult, deptsResult] = await Promise.allSettled([
  getUserList(),
  getDeptList()
])

const users = usersResult.status === 'fulfilled'
  ? usersResult.value.list
  : []

const depts = deptsResult.status === 'fulfilled'
  ? deptsResult.value.list
  : []

小结

API 层是前后端之间的契约界面,好的 API 层设计包含三个维度:

设计维度(How to design)

复制代码
RESTful 是主流但有边界,GraphQL 解决 over/under-fetching,tRPC 提供端到端类型安全
BFF 是微服务和多端场景的架构答案
API 版本策略保证向后兼容
DDD 有界上下文指导 API 的组织划分

工程维度(How to implement)

复制代码
按领域(有界上下文)而非按页面组织 API 文件
TypeScript 类型约束请求参数和响应格式
契约测试(MSW Mock + OpenAPI 验证)保障联调质量

运维维度(How to maintain)

复制代码
接口文档(Swagger/Apifox)是前后端协作的基础设施
版本化接口让迭代不破坏现有功能
接口安全(XSS、IDOR、大数精度)不能只靠前端

整个 HTTP 通信层的完整视图

复制代码
组件
  → API 函数(语义化的业务操作)
  → Axios 实例(拦截器:认证/错误处理)
  → HTTP 协议(GET/POST/PUT/DELETE)
  → 代理层(开发:Vite proxy,生产:Nginx)
  → 后端服务(认证 → 业务逻辑 → 数据库)
  → 响应返回
  → 响应拦截器(数据解包/错误处理)
  → API 函数(数据格式化)
  → 组件(纯净的业务数据)
相关推荐
落木萧萧8252 小时前
从架构视角看 MyBatis Plus 的设计缺陷
后端
Moment2 小时前
AI全栈入门指南:使用 NestJs 创建第一个后端项目
前端·javascript·后端
希望永不加班2 小时前
SpringBoot 定时任务:@Scheduled 基础与动态定时
java·spring boot·后端·spring
我叫黑大帅2 小时前
如何设计应用层 ACK 来补充 TCP 的不足?
后端·面试·go
AIUCE2 小时前
我给 AI 装了个"秦始皇":11 层架构解决 AI 黑箱问题
后端
蜡台2 小时前
Vue3 props ref router 数据通讯传输等使用记录
前端·javascript·vue.js·vue3·router·ref
SimonKing2 小时前
每天白送4000万Token!这款“龙虾”AI神器,微信就能操控电脑
java·后端·程序员
ego.iblacat2 小时前
Flask 框架
后端·python·flask
鬼先生_sir2 小时前
SpringCloud-openFeign(服务调用)
后端·spring·spring cloud