去年我接手一个订单重构项目,第一周差点被一条调用链逼疯:查订单配送状态,代码长这样——order.getCustomer().getAddress().getShippingRegion().getDispatchStation().getStatus()。改一个字段,下游十几个地方跟着爆红。后来同事甩过来四个字:迪米特法则。也就是 LoD(Law of Demeter),中文常译作最少知识原则。它解决的就是这个问题:对象之间到底能打听多少。
这条原则很多人在面试里背过"不和陌生人说话",但真到了代码里,怎么算陌生人、怎么算朋友,边界特别模糊。我见过不少人用得非常极端,把代码改得全是透传方法,反而更难维护;也见过更多的人一看到链式调用就头疼,却说不清到底哪一步是问题根因。这篇文章就围绕这条"朋友边界"展开,我尽量把定义、实战重构、合理例外和检查手段都讲透。适合写业务代码的工程师、做架构评审的人,以及正准备面试又想把设计原则讲明白的人。
1. 问题从哪来:一条穿过五个对象的调用链
1.1 贴切的场景:你跑到柜台背后找主管签章
先打个比方。你去银行柜台办业务,柜员核对完资料,正常流程是他拿着单子找主管签字,然后回来告诉你"办好了"。如果柜员跟你说:"你自己去后面第三个工位找主管,他签完字再回来找我盖个章",你会不会觉得这银行流程很离谱?
但这套离谱的逻辑在代码里到处都是。ShippingStatusService 想查订单的配送状态,理论上它只需要问订单"你的物流状态到哪一步了",但它偏偏要自己按图索骥:从订单拿到客户,从客户拿到地址,从地址拿到配送区域,从区域拿到配送站,最后才拿到状态。这条链路上每一步都在向"朋友的朋友"打听消息,跟自己去柜台背后找主管没有任何区别。
这个例子的高明之处在于它非常日常。Order、Customer、Address、ShippingRegion、DispatchStation 这五个类在业务上确实是层层包含的,真实项目里这种结构到处都是。但"包含关系"不等于"可以顺着导航一路摸到底"。类之间的关联是数据上的联系,而方法调用是行为上的协作,这两者被链路式代码混为一谈时,灾祸就开始了。
1.2 受害者视角:为什么这条链这么难维护
我把这条调用链的几个典型症状列一下,大家可以对号入座:
- 改动传导:有一天你想把
ShippingRegion里dispatchStation的获取方式改掉,或者干脆让Address直接持有配送站引用,所有经过getShippingRegion().getDispatchStation()的调用方都要跟着改。链路越长,波及面越大。 - 依赖范围膨胀:
ShippingStatusService表面上只需要订单,实际上你的 import 里躺着五六个类。单元测试时要 mock 一条Order → Customer → Address → Region → Station的完整链条,少一个环节就空指针。 - 复用性归零:链路中的每一段都是为这条路径定制的,任何一个中间对象想被其他模块复用,都会背着这条链路的隐性依赖。
- 业务规则失焦:真正的业务规则是"配送站返回状态码,订单根据状态码决定显示文案",但代码表达的是对象导航路径,读者看到的是一张对象关系地图,不是业务意图。
这种代码是典型的面向结构编程,不是面向行为编程。迪米特法则正是冲着这个问题来的:它不要求你消灭对象关系,而是要求你尊重对象的行为边界,别把别人家里的抽屉挨个拉开看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 朋友名单与陌生人清单:迪米特法则的精确边界
2.1 形式化定义:一份可以照抄的"可通信名单"
迪米特法则的原始提法是,一个对象的方法只能向以下几类对象发送消息:
- this 自身:本对象自己的方法。
- 方法参数传入的对象:方法签名里收到的对象。
- 方法内部创建的对象:
new出来的局部对象。 - 直接持有的成员对象:当前对象的属性。
- 全局对象:比如单例、静态工厂返回的对象(这一条在不同的教科书里有争议,主流实践一般也把它列入)。
一个很关键、很多人忽略的补充是:从上述任何朋友的 getXxx() 方法里返回出来的对象,不是朋友。这就是标题里"朋友的朋友"的意思。所以:
java复制public void sendReceipt(Order order) {
Customer customer = order.getCustomer(); // 参数对象 order 的朋友:返回了你一个 customer
String phone = customer.getPhone(); // 对 customer 发出消息,而 customer 不是你的“参数朋友”
send(phone);
}
这段代码严格来说是违规的:你拿着 order 这个参数,继续给 order.getCustomer() 的结果发消息。如果你只想跟 order 对话,正确姿势是让 order 给你一个结果,或者把 customer 作为参数传进来,而不是自己下钻。
2.2 一份直观的对照表
为了让你在代码评审的时候能快速判断,我把"可以"和"不可以"整理成一张表:
| 场景 | 是否允许 | 说明 |
|---|---|---|
this.getId() |
允许 | 自己问自己 |
customer.getName() 其中 customer 是方法参数 |
允许 | 直接跟参数朋友对话 |
new EmailService().send() |
允许 | 在方法内创建的对象 |
this.logger.info() |
允许 | 成员属性,直接持有 |
order.getCustomer().getPhone() |
不允许 | 拿到了参数朋友的返回值后继续深挖 |
region.getStation().getStatus() |
不允许 | 中间对象只是跳板 |
order.getItems().stream()... 遍历订单明细 |
灰色 | 见第 4 节,看终点和意图 |
这张表比背诵定义管用得多。实际评审时,我一般只看"点号"后面跟的是不是一个直接的参数、属性或新建对象,如果是"参数点出来的对象再点一下",基本就要开始警觉了。
2.3 为什么这条线要画得这么死
有人可能会问:order.getCustomer().getPhone() 这种写法很自然啊,为什么不让调?
根源在于你不知道 getCustomer() 背后是什么,你依赖的是一个"摸黑操作"的结果。getCustomer() 可能返回 null,可能返回一个代理,可能返回一个你根本不该看到权限的实体,也可能每次调用都新建一份拷贝。在这些不确定性面前,你顺着返回值继续调用,就是把你的代码和 Customer 类的内部结构绑死了,而 Customer 内部结构的走向完全不受你控制。
这就像你问朋友"你认识会修水管的人吗",朋友告诉你"老张会修",你立刻去老张家砸门。问题在于:你对老张的了解全靠朋友转述,老张哪天搬家了、脾气不好、要价高了,你都得承受,而你的朋友根本不知情。更好的做法是让朋友帮你协调,或者你明确授权朋友去替你处理。
把"聊天"变成"委托",是迪米特法则最核心的思维转变。它不是禁止交流,而是禁止"隔着人喊话"。
3. 重构实录:把"朋友的朋友"改造为"朋友代办"
3.1 先把三条重构策略摆上桌
回到开头那个配送状态查询。假设项目是 Java + Spring,代码长这样:
java复制@Component
public class ShippingStatusService {
public String queryStatus(Order order) {
return order.getCustomer()
.getAddress()
.getShippingRegion()
.getDispatchStation()
.getStatus();
}
}
针对这条链,我见过三种主流改造方案。没有绝对正确,只有取舍,我按实战中出现的频率一个个说。
方案 A:逐层委托,把行为上移
让真正拥有数据的对象自己回答"配送状态是什么",每层只委托给自己的直接朋友:
java复制public class Order {
private Customer customer;
public String shippingStatus() {
return customer.shippingStatus();
}
}
public class Customer {
private Address address;
public String shippingStatus() {
return address.shippingStatus();
}
}
public class Address {
private ShippingRegion region;
public String shippingStatus() {
return region.shippingStationStatus();
}
}
public class ShippingRegion {
private DispatchStation station;
public String shippingStationStatus() {
return station.status();
}
}
从形式上看,这条链上每一层都只跟自己的直接属性对话,严格符合迪米特法则。但我必须说,这种方案只在极少数情况下好用。它的致命问题是概念污染:Customer 和 Address 本来跟物流配送状态没有半点关系,现在每个类都多了个 shippingStatus() 方法。如果以后还有快递、退货、调拨等多种状态查询,这些类会变成一堆"状态中转站",比原来还难维护。实践中我已经见过太多这种"为了守规则而守规则"的转发地狱。
方案 B:引入应用服务 + 查询模型/快照
换个思路:配送状态本质上是物流域的概念,不应该通过订单的聚合结构去一个字段一个字段地摸。我们应该一次性把订单配送信息查出来,用快照或数据传输对象包装好,再交给后续逻辑:
java复制@Component
public class ShippingStatusQueryService {
private final OrderRepository orderRepository;
private final DispatchStationClient stationClient;
public ShippingStatusDto queryByOrderId(String orderId) {
OrderDetail detail = orderRepository.findDetailById(orderId);
// detail 是一个查询模型,已经在仓储层做过数据装配
String status = stationClient.queryStatus(detail.getAddressRegionCode());
return new ShippingStatusDto(detail.getOrderNo(), status);
}
}
这个方案的关键变化有两点:
OrderDetail是一个为查询而生的对象,里面存了订单号、地址区域码等已经装配好的数据,调用方不需要也没办法顺着对象图继续往下钻。ShippingStatusQueryService是应用服务,它负责跨模块的流程编排,但它也只跟OrderDetail和DispatchStationClient两个"直接朋友"对话。
这样一来,如果配送区域的查询规则变了,只会影响 OrderRepository 或 DispatchStationClient;如果状态码的展示规则变了,只改 ShippingStatusDto。链路被收纳到了数据装配层,而不是散落在所有调用方。
方案 C:事件驱动,从源头解耦
如果"查配送状态"这个动作不应该由订单流程主动发起,而是物流系统自己更新后推送过来,那事件是更彻底的解法:
java复制@Component
public class OrderCreatedEventListener {
@EventListener
public void on(OrderCreatedEvent event) {
String status = dispatchStationClient.queryStatus(event.getRegionCode());
orderStatusUpdater.update(event.getOrderId(), status);
}
}
订单创建后发布事件,物流模块监听并把状态回填。这样 ShippingStatusService 压根不需要知道 DispatchStationClient,耦合从类级别降到了事件级别。代价是引入异步、消息可靠性等分布式问题,简单项目别轻易上。
3.2 三种方案怎么选
| 维度 | 方案 A 逐层委托 | 方案 B 应用服务+快照 | 方案 C 事件驱动 |
|---|---|---|---|
| 改动成本 | 中低,但每个类都要动 | 中,需要设计查询模型 | 高,需要消息基础设施 |
| 职责清晰度 | 容易造成概念污染 | 职责边界清晰 | 最清晰 |
| 耦合程度 | 形式上低,实际上链路仍在 | 低,链路被封装 | 最低 |
| 适用场景 | 行为本就属于领域对象时 | 跨模块读取、报表聚合 | 跨系统异步协作 |
| 单元测试难度 | 中等,需层层 mock | 低,只需 mock 仓储和客户端 | 中等,需要发事件或 mock broker |
我个人在业务系统里最常用方案 B。它不改领域对象的结构,也不引入额外的中间件,只是把"对象导航"改成了"查询服务"。这个改造对老代码的侵入最小,也是最能立刻见效的一步。
我见过太多人一提到迪米特法则就急着给所有对象加"委托方法",结果 Customer 里塞满了 orderCount()、shippingStatus()、lastPaymentAmount() 这类完全不属于客户概念的方法,类越来越胖,职责越来越模糊。迪米特法则真正要你做的是重新定位"谁最清楚这件事",而不是机械地"每层只降一跳"。最后一节我会专门讲怎么判断"谁最清楚"。
4. 什么时候可以"违规":Builder、DTO 与领域模型的分寸
4.1 先泼一盆冷水:不是所有链式调用都是屎
迪米特法则讲多了,很容易给人形成一种错觉:代码里不能出现 .getA().getB().getC(),凡是超过一个点号的调用都是坏的。
不对。原则是用来服务的,不是用来制造新的教条的。我先把几个常见且合理的链式场景挑出来说明白。
Builder 模式:
java复制Pizza pizza = new Pizza.Builder()
.size(12)
.cheese(true)
.pepperoni(true)
.build();
这条链上每个方法都作用于同一个 Builder 对象,builder 是 new 出来的局部对象,属于"可以在方法内部创建的对象"。链式调用只是让多个方法串在一起,沟通对象始终是同一个老朋友,完全符合迪米特法则。
Stream 管道:
java复制List<String> names = orders.stream()
.filter(o -> o.getStatus() == PAID)
.map(o -> o.getCustomerName())
.collect(toList());
严格按教科书来评判,filter 返回一个新的 Stream,map 是对这个新返回对象发消息,形式上有"对返回值调用"的嫌疑。但实践中没人会去拆这种代码,原因在于 Stream 管道是一条一次性的短表达式,它的中间对象(Stream)承载的不是长期依赖,而是函数式管道的流转状态。它没有像 order.getCustomer().getAddress() 那样跨方法、跨模块地传递对象图。
DTO / 纯数据对象:
java复制OrderDTO dto = orderMapper.toDTO(order);
String city = dto.getCustomer().getAddress().getCity();
这个在真实项目里太常见了。DTO 是纯数据载体,没有行为,你拿到它的唯一目的就是读数据。只要 DTO 是"叶子结构",读完之后不再继续往里钻(比如从 city 再去拿 city.getWeather()),这种链式取值其实是安全的。
判断标准很简单:你是在"读取一组已装配好的数据",还是在"顺着对象图去发现新的依赖"。前者安全,后者危险。
4.2 领域模型里的争论:聚合根能不能暴露内部实体
这是 DDD(领域驱动设计)社区经常吵的点。聚合根 Order 里有一个 getOrderItems() 返回 List<OrderItem>,外部拿到 OrderItem 后继续调用它的方法,这算不算违反迪米特法则?
严格说,算。但很多真实项目就是这么写的,原因在于 OrderItem 本身也是领域对象,外部对它的操作有业务含义。DDD 更推荐的写法是订单聚合根直接暴露领域行为,比如 order.calculateItemDiscount(orderItemId),而不是让外部先把 OrderItem 拽出来,再自己算折扣。
我的实践经验是,这取决于"是否对外暴露了内部结构"。如果 OrderItem 是订单内部不可分割的构件,外部不应该拿到引用后做任意操作,那就应该收敛到聚合根方法里;如果 OrderItem 本身就是一个可独立理解的查询结果(比如只读列表),那遍历它反而更自然。
关键不是"能不能点",而是"点了之后,外部有没有被绑定到你不希望它绑定的结构"。
4.3 一张判定表:拿不准的时候查它
| 场景 | 链路终点 | 调用是否重复 | 改动是否会传导 | 结论 |
|---|---|---|---|---|
| Builder 链 | this 自身 | 否 | 否 | 安全 |
| Stream 管道 | 一次性中间流 | 否 | 否 | 安全 |
| DTO 链式读值 | 叶子值 | 经常 | 很少(DTO 由 Mapper 统一装配) | 可接受 |
| 领域对象导航链 | 另一个对象 | 多处出现 | 会,且影响大 | 应重构 |
| 跨服务返回体深层导航 | 另一服务的概念 | 多处出现 | 会 | 应重构 |
我见过太多团队把时间浪费在"消灭链式调用"上,结果改出了一堆转房东。如果你发现链条的每一步都是叶子值(字符串、数字、枚举),而且中间对象本身不会被传出当前方法,那就别折腾了;真正要警惕的是"链的终点是另一个对象,且这个对象的内部结构还会继续被你或别人摸下去"。
5. 从类到系统:模块、服务和前端组件也在讲"朋友边界"
5.1 模块之间的"门面"就是聚类的迪米特法则
迪米特法则的适用范围绝不止于类方法。模块划分的时候,一个模块对外暴露的 API 就是这个模块的"朋友圈"。
设想你有一个 OrderModule 和一个 CustomerModule。如果 OrderModule 对外把 Order 实体直接暴露给 CustomerModule,而 Customer 模块里通过 order.getCustomerId() 拿到数据后再去查 CustomerRepository,那这两个模块就纠缠在一起了。更合理的做法是 OrderModule 提供一个 OrderSummary 这样的门面对象,只包含对方真正需要的字段。
这就是门面模式(Facade)的本质:它就是迪米特法则在模块层面的实例。你不需要认识模块里的每一个类,只需要认识它的门面。
5.2 微服务之间:别把返回体当成数据库游标
微服务场景下有一个特别常见的错误。订单服务返回一个 JSON:
json复制{
"orderId": "A001",
"customer": {
"id": "C001",
"profile": {
"avatarUrl": "https://...",
"vipLevel": 3
}
}
}
下游服务拿到这个 JSON 后,顺着 customer.profile.vipLevel 一直往下摸,甚至根据 profile 里的 id 再发起一次对 CustomerService 的调用,这就在服务级别复刻了 order.getCustomer().getProfile().getVipLevel() 的调用链。
服务间的朋友边界,核心是两条:
- 返回体尽量扁平:能直接给
vipLevel就千万别嵌套一个customer.profile。 - 不要根据返回体里的 ID 去"顺藤摸瓜"再调别人的接口:如果需要调用方做二次组装,说明接口的粒度不对,应该加一个聚合端点,让服务内部把数据装配好再吐出来。
微服务的模块边界比类边界更难改,因为跨网络部署,改一次要发多个服务、对口径、排查链路,代价极大。所以这个层面的朋友边界更值得提前设计。
5.3 分层架构与前端组件:Controller 别当导航员
分层架构里最常见的违规是 Controller 层直接穿透 Service 返回的对象结构:
java复制// 反例
@GetMapping("/orders/{id}/detail")
public OrderDetailVO detail(@PathVariable String id) {
Order order = orderService.getById(id);
User user = order.getUser(); // Controller 开始访问 Service 返回对象的属性
String nickname = user.getProfile().getNickname(); // 又开始往下钻
return new OrderDetailVO(order.getId(), nickname);
}
Controller 本来只该负责协议适配,现在它知道了 Order、User、Profile 三层对象的结构,这就是在"打听朋友的朋友"。
正确姿势是让 Service 层直接返回一个组装好的 VO,或者提供一个专门的查询服务:
java复制// 正例
@GetMapping("/orders/{id}/detail")
public OrderDetailVO detail(@PathVariable String id) {
return orderQueryService.getDetailVO(id);
}
前端组件同理。React 或 Vue 里,父组件通过 props 把 profile 对象传给了子组件,子组件在 render 函数里写了 profile.company.address.city。表面上是"数据流单向",实际上子组件已经被父组件的数据结构绑架了。父组件一旦调整 profile 结构,所有依赖这个深层路径的子组件全挂。更健康的方式是父组件传一个已经格式化好了的字符串,或者用 selector 把数据统一收敛到 store 层。
这些场景的共同点是什么?调用方依赖了它本不该知道的内部结构。不管是 Controller、微服务下游还是前端子组件,它们真正需要的是"一个答案",不是"一张对象导航地图"。
6. 评审自查:三个坏味道信号与一套重构顺序
6.1 三个信号:代码评审时一眼识破
很多团队知道迪米特法则,但代码评审的时候不知道从哪看起。我给三个比较醒目的信号,看到基本就在调用链这条线上有风险了:
信号一:点号超过三个。 a.b.c.d 这种,链上大概率有"路过"的跳板对象。尤其链上还带着 get、find 这类字眼,基本就是在导航对象图。
信号二:链路的终点不是值,而是另一个对象的处理方法。 order.getCustomer().getAddress().getRegion().getDispatchStation().getStatus() 里,getStatus() 返回的是 String,看着是值,但实际上你是从 DispatchStation 这个对象上拿的,如果以后 DispatchStation 被拆成 DispatchNode + StationDetail,这段代码全废。
信号三:修改一个字段要改很多文件。 如果你发现改一个 Address 的字段,下游的 12 个调用方都出现编译错误,大概率它们都在用 getAddress() 导航链,而不是通过一个查询服务拿到装配好的数据。
6.2 一套可以落地的重构顺序
我一般按下面的步骤来,每一步都能验证,不会一上来就推倒重写:
- 先列清单:用 IDE 的 Find Usages 或 grep 把所有链式调用列出来,按"链路长度 × 调用次数"排序,优先处理影响面最大的。
- 看链路终点:问自己"这个终点的数据,谁最清楚如何获取"。如果清楚的是某一个领域对象,就把方法上移或下放;如果清楚的是一个外部系统,就引入客户端或查询服务。
- 引入查询模型或快照:把链路中的一部分数据装配到查询模型里,让调用方只依赖这个模型,不再依赖原对象图。
- 替换调用点:一个个替换,每替换一个跑一遍相关测试。不要一次性大改,否则出了问题没法定位。
- 删转头发送方法:第 4 节说的中间人反模式,重构后如果发现某些类的方法完全变成了转发,考虑把这类方法直接删掉或合并到查询模型里。迪米特法则不是让你把问题横向铺开,而是把问题收拢到该在的地方。
6.3 用 ArchUnit 之类工具把这条规则固化成 CI 检查
光靠人评审,总会有漏网之鱼。我比较推荐在 Java 项目里引入 ArchUnit 这类静态架构测试工具,把依赖规则写进测试里:
java复制@AnalyzeClasses(packages = "com.example.order")
public class DependencyRuleTest {
@Test
void service_should_not_depend_on_inner_classes_of_domain() {
classes()
.that().resideInAPackage("..service..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..service..", "..domain..", "..common..")
.check(new ClassFileImporter().importPackages("com.example.order"));
}
}
团队里一旦有这种规则,代码评审就不用反复念叨"你这个地方违反迪米特了",CI 直接会拦下来。不过 ArchUnit 只能拦"包依赖",拦不住"对象图导航",所以它更多是兜底,核心判断还是得靠人看。
6.4 你最该带走的一句判断标准
迪米特法则的落地,我认为不需要背定义,只需要在写代码、评审代码的时候反复问一句:
"我现在要拿的这个值,负责回答它的人是我直接认识的朋友吗?如果不是,我该让那个朋友去问,还是该让一个专门的查询服务来告诉我?"
这句话比任何形式化定义都实用。它逼你去思考职责归属,而不是机械地数点号。真正的朋友边界,是靠职责边界画出来的,不是靠 getter 层级画出来的。
我自己在重构中踩过几次坑之后最大的体会是:过度设计委托方法比不设计更痛苦。有一次我把一条六层链路拆成了五个转发方法,结果 Customer、Address 全被配送状态污染了,后来不得不回滚重来,改成查询服务 + 快照才真正解决问题。迪米特法则不是让你把所有类都变成传话筒,而是让你把"谁该知道什么"想明白。写代码前先想清楚这一点,你的每个方法就会自然只跟朋友说话。
