第三章 服务设计与建模:服务粒度划分原则、服务接口定义语言(IDL)、服务依赖关系管理
好,咱们进入第三章。这一章我打算聊聊服务设计与建模。说实话,这是整个SOA架构里最考验经验的地方。你想想看,服务粒度切得太粗,复用性差;切得太细,系统复杂度爆炸。我在好几个项目里都踩过这个坑,今天就把这些经验掰开揉碎了讲给你听。
3.1 服务粒度划分原则
服务粒度,说白了就是一个服务该管多少事。我个人的习惯是,先问自己三个问题:
- 这个服务能不能独立部署?
- 它有没有明确的业务边界?
- 它的接口会不会频繁变化?
如果三个答案都是「是」,那这个粒度基本靠谱。
3.1.1 高内聚低耦合
这是老生常谈了,但真正做到不容易。我见过一个项目,把「获取车辆状态」和「设置车辆状态」拆成两个服务。结果呢?每次改状态都得调两个接口,还要处理时序问题。其实这两个操作强相关,放在一个服务里更合理。
举个例子:
// 不推荐:拆得太碎
service VehicleStatusReader {
rpc GetSpeed(VehicleID) returns (Speed);
rpc GetBattery(VehicleID) returns (BatteryLevel);
}
service VehicleStatusWriter {
rpc SetSpeed(VehicleID, Speed) returns (Status);
rpc SetBattery(VehicleID, BatteryLevel) returns (Status);
}
// 推荐:按业务边界聚合
service VehicleStatusManager {
rpc GetStatus(VehicleID) returns (VehicleStatus);
rpc SetStatus(VehicleID, VehicleStatus) returns (Status);
rpc SubscribeStatusChange(VehicleID) returns (stream StatusEvent);
}
你看,后者把读、写、订阅都放在一起。调用方只需要知道一个服务地址,逻辑也清晰。
3.1.2 粒度划分的黄金法则
我总结了一个经验法则,叫「三个如果」:
- 如果两个功能总是同时被调用,那它们应该在一个服务里。
- 如果两个功能的数据模型高度重叠,那它们应该在一个服务里。
- 如果两个功能的变更频率不同,那它们应该拆开。
嗯,这里要注意第三点。我在项目中遇到过,把「诊断日志」和「实时数据」放在一起。结果诊断日志每周都要改格式,实时数据却很少动。每次改日志都要重新部署整个服务,实时数据也跟着受影响。后来拆开,世界清净了。
核心原则:服务粒度不是越细越好,也不是越粗越好。关键是找到那个「刚好」的点——让每个服务都能独立演进,又不会让调用方觉得麻烦。
3.2 服务接口定义语言(IDL)
接口定义语言,就是服务之间沟通的「合同」。我个人偏爱 Protocol Buffers(protobuf),它在车载场景下特别合适——序列化快、体积小、向前兼容好。
3.2.1 为什么需要IDL?
你想想看,没有IDL会怎样?
- 服务A说「我返回一个JSON,字段名叫 speed」
- 服务B说「我收到的是 speed,但类型是字符串还是数字?」
- 服务C说「我升级了,字段名改成 vehicle_speed 了」
乱套了。IDL就是用来解决这个问题的。它强制你定义清楚:接口叫什么、参数是什么类型、返回值是什么结构。
3.2.2 protobuf实战示例
我习惯这样定义服务接口:
syntax = "proto3";
package vehicle.signal.v1;
// 车辆信号服务
service SignalService {
// 获取单个信号值
rpc GetSignal(GetSignalRequest) returns (GetSignalResponse);
// 批量获取信号值
rpc BatchGetSignals(BatchGetSignalsRequest) returns (BatchGetSignalsResponse);
// 订阅信号变化
rpc SubscribeSignals(SubscribeSignalsRequest) returns (stream SignalEvent);
}
message GetSignalRequest {
string signal_id = 1; // 信号ID,如 "vehicle.speed"
string source = 2; // 数据源,如 "can_bus" 或 "sensor"
}
message GetSignalResponse {
string signal_id = 1;
double value = 2; // 信号值
int64 timestamp = 3; // 时间戳,毫秒
SignalQuality quality = 4; // 信号质量
}
enum SignalQuality {
SIGNAL_QUALITY_UNSPECIFIED = 0;
SIGNAL_QUALITY_VALID = 1; // 有效
SIGNAL_QUALITY_INVALID = 2; // 无效
SIGNAL_QUALITY_STALE = 3; // 过期
}
这里有几个设计要点:
- 字段编号要预留:1-15用高频字段,16以上留给未来扩展。我吃过亏,一开始把编号用满了,后来加字段只能加在最后,代码看着别扭。
- 枚举要有UNSPECIFIED:默认值0一定要是未指定,这样能区分「没设置」和「设置了但值是0」。
- 时间戳用int64:别用string存时间,解析起来麻烦,还容易有时区问题。
小技巧:定义接口时,尽量用「请求-响应」模式,而不是「直接暴露内部状态」。比如不要定义 rpc GetSpeed() returns (double),而是定义 rpc GetSignal(GetSignalRequest) returns (GetSignalResponse)。这样未来加参数、改结构都方便,不会破坏兼容性。
3.2.3 接口版本管理
接口一定会变。我见过最头疼的情况是:服务升级了,但客户端没升级,结果全线崩溃。
我的做法是:
- 向后兼容:永远不要删除字段,永远不要改变字段类型。要废弃字段,用 reserved 关键字。
- 版本号放在包名里:比如 vehicle.signal.v1、vehicle.signal.v2。这样新旧版本可以共存。
- 给客户端一个过渡期:v1废弃后,至少保留一个发布周期再彻底删除。
// 废弃字段的正确做法
message GetSignalRequest {
reserved 2; // 以前是 source 字段,已废弃
reserved "source";
string signal_id = 1;
// 新加的字段用新编号
string data_source = 3; // 替代 source
}
3.3 服务依赖关系管理
服务多了,依赖关系就复杂了。我曾经在一个项目里画依赖图,画到最后成了一团乱麻——A依赖B,B依赖C,C又依赖A。这就是循环依赖,SOA架构里的大忌。
3.3.1 依赖关系类型
我把依赖关系分成三类:
| 类型 | 说明 | 例子 |
|---|---|---|
| 强依赖 | 调用方必须等待被调用方返回 | 获取车辆VIN码 |
| 弱依赖 | 调用方可以容忍超时或降级 | 获取天气信息 |
| 异步依赖 | 调用方发送请求后不等待 | 上传行车日志 |
设计时,尽量把强依赖降到最低。我习惯的做法是:核心功能(比如车辆控制)用强依赖,非核心功能(比如娱乐推荐)用弱依赖或异步依赖。
3.3.2 避免循环依赖
循环依赖是架构的毒瘤。怎么避免?
- 分层设计:把服务分成基础服务、业务服务、聚合服务。基础服务不能依赖业务服务。
- 引入事件总线:如果A和B需要互相通信,别直接调接口,用事件解耦。
- 定期做依赖分析:我每个迭代都会跑一次依赖图,看看有没有出现循环。
警告:千万别觉得「就一个循环依赖没关系」。它会让你的部署顺序变得诡异,出问题时排查链路极长。我曾经在一个项目里,因为A依赖B、B依赖C、C依赖A,结果每次上线都要按特定顺序重启服务,少一步就全崩。后来花了两个迭代重构才解决。
3.3.3 依赖治理工具
光靠人脑记不住所有依赖关系。我推荐用工具来管理:
- 依赖图可视化:用 Graphviz 或类似的工具,把服务依赖画出来。一眼就能看出问题。
- 契约测试:每个服务发布时,自动检查接口是否兼容。不兼容就阻断发布。
- 调用链监控:用 OpenTelemetry 之类的工具,记录每次调用的链路。能帮你发现隐藏的依赖。
嗯,说到工具,我个人的习惯是:先用手动的方式把依赖关系理清楚,再用工具固化。别一上来就上工具,否则你连自己在查什么都搞不清楚。
3.3.4 依赖降级策略
依赖的服务挂了怎么办?不能整个系统都跟着挂。我常用的降级策略:
- 缓存兜底:读接口尽量加缓存,服务挂了就用缓存数据。
- 默认值返回:非核心功能,直接返回一个合理的默认值。
- 熔断机制:连续失败超过阈值,直接熔断,不再调用下游服务。
举个例子:
// 伪代码:带降级的服务调用
function getVehicleSpeed() {
try {
return signalService.getSpeed();
} catch (ServiceUnavailableException e) {
// 服务挂了,用缓存
return cache.get("vehicle.speed");
} catch (TimeoutException e) {
// 超时了,返回默认值
return 0.0;
}
}
你看,这样即使下游服务挂了,上游也不会崩溃。用户体验可能差一点,但系统不会瘫痪。
总结一下:服务粒度要「高内聚低耦合」,接口定义要「清晰且兼容」,依赖管理要「分层且可降级」。这三件事做好了,你的SOA架构就稳了一大半。
好,这一章就到这里。下一章我们聊聊服务通信与协议,那是另一个有意思的话题。