第三章 服务设计与建模:服务粒度划分原则、服务接口定义语言(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 粒度划分的黄金法则

我总结了一个经验法则,叫「三个如果」:

  1. 如果两个功能总是同时被调用,那它们应该在一个服务里。
  2. 如果两个功能的数据模型高度重叠,那它们应该在一个服务里。
  3. 如果两个功能的变更频率不同,那它们应该拆开。

嗯,这里要注意第三点。我在项目中遇到过,把「诊断日志」和「实时数据」放在一起。结果诊断日志每周都要改格式,实时数据却很少动。每次改日志都要重新部署整个服务,实时数据也跟着受影响。后来拆开,世界清净了。

核心原则:服务粒度不是越细越好,也不是越粗越好。关键是找到那个「刚好」的点——让每个服务都能独立演进,又不会让调用方觉得麻烦。

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 依赖降级策略

依赖的服务挂了怎么办?不能整个系统都跟着挂。我常用的降级策略:

  1. 缓存兜底:读接口尽量加缓存,服务挂了就用缓存数据。
  2. 默认值返回:非核心功能,直接返回一个合理的默认值。
  3. 熔断机制:连续失败超过阈值,直接熔断,不再调用下游服务。

举个例子:

// 伪代码:带降级的服务调用
function getVehicleSpeed() {
    try {
        return signalService.getSpeed();
    } catch (ServiceUnavailableException e) {
        // 服务挂了,用缓存
        return cache.get("vehicle.speed");
    } catch (TimeoutException e) {
        // 超时了,返回默认值
        return 0.0;
    }
}

你看,这样即使下游服务挂了,上游也不会崩溃。用户体验可能差一点,但系统不会瘫痪。

总结一下:服务粒度要「高内聚低耦合」,接口定义要「清晰且兼容」,依赖管理要「分层且可降级」。这三件事做好了,你的SOA架构就稳了一大半。

好,这一章就到这里。下一章我们聊聊服务通信与协议,那是另一个有意思的话题。