第三讲:统一数据结构的四大设计原则
设计 Schema 的时候,我见过太多人一上来就开干。字段堆了一堆,接口调着调着就发现——哎?这个字段怎么既表示状态又表示类型?那个结构怎么改了 A 服务 B 服务就崩了?
说白了,这些问题根子都在设计原则上没想清楚。今天我们就聊聊四个最核心的原则:单一职责、开闭原则、接口隔离、依赖倒置。它们不是空话,是能直接帮你少踩坑的。
核心观点:这四个原则不是互相独立的。单一职责管“一个结构该干什么”,开闭原则管“怎么扩展不伤旧代码”,接口隔离管“调用方该看到什么”,依赖倒置管“谁依赖谁”。合在一起,就是一套稳定的 Schema 设计方法论。
3.1 单一职责原则:一个 Schema 只做一件事
单一职责,英文叫 Single Responsibility Principle,简称 SRP。放在 Schema 设计里,意思很简单:一个数据结构只描述一个业务概念。
我在项目中遇到过这样一个案例:有个团队把“用户信息”和“用户配置”塞到了同一个 JSON 对象里。结果前端只需要展示用户名,却不得不拉下来一整套配置数据。后来配置结构变了,用户信息接口也跟着改,两边都受影响。
我的习惯:写 Schema 之前,先问自己三个问题——这个结构描述的是什么业务实体?它会不会被多个场景共用?如果拆开会不会更清晰?
举个例子,一个典型的错误设计:
// ❌ 错误:用户信息 + 配置 + 权限混在一起
{
"userId": "u1001",
"name": "张三",
"email": "zhangsan@example.com",
"theme": "dark",
"language": "zh-CN",
"notifyEmail": true,
"notifySms": false,
"role": "admin",
"permissions": ["read", "write", "delete"]
}
这个结构至少包含了三个职责:用户基本信息、用户偏好配置、用户权限。任何一个职责变化,都会导致整个结构变更。
正确的做法是拆成三个独立的 Schema:
// ✅ 正确:按职责拆分
// 用户基本信息
{
"userId": "u1001",
"name": "张三",
"email": "zhangsan@example.com"
}
// 用户配置
{
"userId": "u1001",
"theme": "dark",
"language": "zh-CN",
"notifyEmail": true,
"notifySms": false
}
// 用户权限
{
"userId": "u1001",
"role": "admin",
"permissions": ["read", "write", "delete"]
}
你想想看,这样拆完之后,每个结构只关心自己的事。配置变了不影响权限,权限改了不波及基本信息。维护起来轻松多了。
3.2 开闭原则:对扩展开放,对修改封闭
开闭原则,Open-Closed Principle,OCP。意思是:你的 Schema 应该允许新增字段,但不应该因为新增字段而修改已有的结构。
嗯,这里要注意。很多人理解成“不能改已有的字段”,其实不是。开闭原则的核心是:通过扩展来应对变化,而不是通过修改。
我曾经维护过一个订单系统。一开始订单结构很简单:
{
"orderId": "ord_001",
"amount": 99.00,
"status": "paid"
}
后来业务方说,要加一个“优惠券信息”。如果直接在原结构上加字段:
{
"orderId": "ord_001",
"amount": 99.00,
"status": "paid",
"couponId": "cpn_001", // 新加
"discountAmount": 10.00 // 新加
}
看起来没问题对吧?但问题是,所有消费这个 Schema 的服务都得重新编译、重新部署。哪怕它们根本不关心优惠券。
更好的做法是使用 扩展点 或 可选容器:
{
"orderId": "ord_001",
"amount": 99.00,
"status": "paid",
"extensions": { // 扩展容器
"coupon": {
"couponId": "cpn_001",
"discountAmount": 10.00
}
}
}
避坑指南:我曾经在一个项目里用“扩展字段”过度了,结果 extensions 里塞了几十个 key,变成了一个“大垃圾箱”。开闭原则不是让你把所有东西都塞进扩展里,而是让你有节制地扩展。能用标准字段表达的,就别往扩展里塞。
3.3 接口隔离原则:不给调用方不需要的东西
接口隔离原则,Interface Segregation Principle,ISP。放在 Schema 里就是:一个接口只暴露调用方真正需要的字段。
说白了,就是别让前端拉一堆用不上的数据。也别让下游服务收到一堆它不关心的字段。
我见过一个典型的反面教材:一个“获取用户信息”的接口,返回了 50 多个字段。前端只需要用户名和头像,却要解析整个大 JSON。后来这个接口的响应体从 2KB 涨到了 15KB,移动端用户叫苦连天。
解决方案是:按场景定义视图。
| 视图名称 | 适用场景 | 包含字段 |
|---|---|---|
UserBasicView |
列表展示、头像显示 | userId, name, avatar |
UserDetailView |
个人中心、编辑页面 | userId, name, email, phone, avatar, bio |
UserAdminView |
后台管理、权限分配 | userId, name, role, permissions, status, createTime |
每个视图只包含对应场景需要的字段。调用方按需选择,谁也不多拿。
我的建议:在 GraphQL 或 RESTful API 中,可以用 ?fields= 参数让客户端指定需要的字段。但更稳妥的做法是在 Schema 层面就定义好视图,这样后端可以针对不同视图做优化。
3.4 依赖倒置原则:抽象不依赖细节
依赖倒置原则,Dependency Inversion Principle,DIP。原文是“高层模块不应该依赖低层模块,两者都应该依赖抽象”。
翻译成 Schema 设计语言就是:你的数据结构不应该依赖具体的实现细节,而应该依赖抽象的业务概念。
举个例子。假设你要设计一个“支付结果”的 Schema。如果直接依赖具体的支付渠道:
// ❌ 错误:依赖具体实现
{
"paymentId": "pay_001",
"wechatPayResult": "success", // 支付结果
"alipayResult": "success", // 支付宝结果
"unionPayResult": "pending" // 银联结果
}
这个结构的问题很明显:每增加一个支付渠道,就得加一个新字段。而且调用方得知道所有渠道的名字。
正确的做法是抽象出一个“支付结果”的概念:
// ✅ 正确:依赖抽象
{
"paymentId": "pay_001",
"channel": "wechat", // 支付渠道
"status": "success", // 支付状态
"amount": 99.00,
"paidAt": "2024-01-15T10:30:00Z"
}
这样不管底层是、支付宝还是银联,上层接口看到的都是统一的“支付结果”。新增渠道时,只需要在 channel 字段里加一个枚举值,不需要改结构。
关键点:依赖倒置的核心是“面向接口编程”。在 Schema 层面,这个“接口”就是抽象的业务模型。你设计的每个结构,都应该是对业务概念的建模,而不是对某个具体实现的映射。
3.5 四个原则的关系与落地
这四个原则不是孤立的。我习惯把它们看作一个整体:
- 单一职责 决定了结构的边界——一个结构只做一件事。
- 开闭原则 决定了结构的扩展方式——通过扩展而非修改。
- 接口隔离 决定了结构的暴露范围——只给调用方需要的。
- 依赖倒置 决定了结构的抽象层次——依赖抽象而非具体。
下面这张图展示了它们之间的关系:
在实际项目中,我通常这样落地:
- 先画业务边界——用单一职责把不同业务概念拆开。
- 再定扩展策略——用开闭原则设计扩展字段或容器。
- 然后切分视图——用接口隔离原则定义不同场景的视图。
- 最后抽象模型——用依赖倒置原则确保结构不绑定具体实现。
注意:这四个原则不是银弹。过度设计比没有设计更可怕。我见过有人为了“开闭”,每个结构都加一个 extensions 字段,结果代码里到处都是 extensions["xxx"],可读性极差。原则是工具,不是教条。用的时候多想想:这个场景真的需要吗?
好了,这一讲就到这里。四个原则听起来简单,但真正用好需要反复练习。下次设计 Schema 的时候,不妨拿这四个原则逐个检查一遍,你会发现很多问题在写代码之前就能暴露出来。