Skip to content

参数放在哪里:请求数据、依赖与上下文 ​

一个方法有七个参数,评审意见是「参数太多」。于是其中几个被挪成了服务类的字段,调用前先 setXxx()。功能测试都通过了。上线后偶尔有用户看到了别人的名字。

「参数太多」是一个真实的信号,但它的处理方式取决于这些参数为什么出现在一起。有的属于同一个概念,应该组成一个对象;有的是长期不变的协作者,应该在构造时装配;有的每次请求都不同,必须显式传入。把它们搞混,减少的是签名的长度,换来的是生命周期和共享范围的错误。

本文把一个值的三种放置方式(参数、对象字段、线程上下文)各放错一次。实验见文末,交错顺序由闩锁固定。

方法参数活到这次调用结束只有调用方和被调用方看得到对象字段活到对象被回收单例:所有请求共享线程上下文活到被 remove线程池:下一个任务继承适合:每次请求不同的数据适合:长期不变的协作者、配置适合:横切的上下文,必须成对清理放错:alice 收到 hello bob放错:下一个任务读到 tenant-a参数太多时,先看它们为什么一起出现同属一个概念、有共同约束的几个值 → 参数对象,在构造时校验(from 晚于 to 时直接报错,而不是返回 0 条)长期不变的依赖 → 构造器注入;每次变化的请求数据 → 仍然显式传入布尔开关选择的是两种业务操作 → 两个有名字的方法
图 1 · 实测(JDK 25.0.4.1):一个值放在哪里,决定了它活多久、被谁共享。每次请求不同的数据放进单例的字段,就被所有请求共享:alice 的请求等待期间 bob 进来,alice 收到了「hello bob」。放进线程上下文,就跟着线程活,线程池复用线程时,上一个任务的租户留给了下一个任务。

一、每次请求不同的数据,不能放进共享对象的字段 ​

java
@Service
class GreetingService {
    private String currentUser;                 // 为了少传一个参数
    void setUser(String u) { currentUser = u; }
    String greet() { /* 中间有一次 I/O */ return "hello " + currentUser; }
}
text
单例服务把当前用户放在字段里,alice 的请求等 I/O 时 bob 的请求进来:alice 收到「hello bob」
作为参数传入:alice 收到「hello alice」

Spring 的 Bean 默认是单例,所有请求共用同一个对象。currentUser 字段是所有请求共享的,alice 设置完、在等 I/O 的时候,bob 的请求把它改成了 bob。这个问题只在并发时出现,单线程的功能测试永远发现不了;出现时又不报错,只是数据错了。开头的「看到别人的名字」就是这样来的。

判断一个值能不能放进字段,问它的生命周期:它和这个对象活得一样久吗?数据库连接池、配置、下游客户端,对象创建时确定、之后不变,适合放字段,通过构造器注入。当前用户、当前订单、本次请求的参数,每次调用都不同,只能作为参数传入。

把一个静态方法改成实例方法、把一个工具类改成可注入的对象时,同样要检查:原来是否依赖某个全局状态,改成实例之后,这个状态被多少调用方共享。构造器注入本身不保证对象只有一个实例,也不保证它是线程安全的。

二、参数多时,先看它们为什么一起出现 ​

属于同一个概念的,组成参数对象,并在构造时守住约束。

java
long count(Instant from, Instant to)          // 两个同类型参数
text
count(from, to) 正确调用 2 条;参数写反成 count(oct10, oct1):编译通过,返回 0 条,没有任何错误
换成参数对象 Period(from, to),写反时:构造时抛出 IllegalArgumentException(from 必须早于 to:2026-10-10T00:00:00Z / 2026-10-01T00:00:00Z)

两个 Instant 写反,编译器无法发现,查询返回 0 条,调用方以为真的没有数据。把它们组成 Period,「开始早于结束」这条约束就有了一个家,在构造时检查,所有用到时间段的地方都不必再各自检查一遍。

参数对象的价值来自它表达的概念和守住的约束,不来自减少参数个数。把七个不相干的参数塞进一个 RequestContext,签名短了,但调用方仍然要知道每个字段填什么,而且这个对象会越来越大,变成什么都能装的口袋。只有那些总是一起出现、有共同含义和约束的值才应该组在一起:时间段、金额与币种、地址的各个部分、分页的页码与大小。

长期不变的协作者,在构造时装配。 一个方法的参数里有 repository、clock、notifier,说明调用方在替被调用方组装依赖。把它们移到构造器,方法签名只剩业务参数,测试时也可以在构造时换成替身。依赖怎样装配、对象的作用域怎样确定,见对象创建与生命周期。

布尔开关选择的是两种操作时,拆成两个有名字的方法。

java
send(message, true);                     // true 是什么?
sendImmediately(message);                // 调用处就能读懂
scheduleForBusinessHours(message);

不是所有布尔参数都要拆。参数是一项数据(includeArchived、caseSensitive)时,它就是一个普通的参数;只有当 true 和 false 走的是两条完全不同的业务流程,调用方实际上在选择「做哪件事」时,两个方法更清楚。拆的时候共享的部分仍然可以放在一个私有方法里。

三、线程上下文:用完必须清理 ​

当前用户、租户、追踪 ID 这类横切的信息,一层层作为参数传下去很繁琐,常见的做法是放进 ThreadLocal。它的生命周期是线程,而线程池会复用线程:

text
线程池里的上一个任务设置了租户、没有清理;同一个线程上的下一个任务读到:tenant-a
改成 try/finally 里 remove:下一个任务读到 null

下一个任务根本没有设置租户,却读到了上一个任务的 tenant-a。如果它据此查询数据,就查到了别的租户的数据。

规则是设置与清理成对出现,清理放在 finally 里,并且由同一段代码负责。用 set(null) 代替 remove() 能清掉值,但线程的映射表里仍然留着这个条目。更好的做法是把设置与清理封装在一个方法里,调用方只传入要执行的代码:

java
static <T> T withTenant(String tenant, Supplier<T> body) {
    TENANT.set(tenant);
    try { return body.get(); } finally { TENANT.remove(); }
}

JDK 25 的 ScopedValue 把这种「在一段代码的范围内绑定一个值」做成了语言级的机制,范围结束自动解除。上下文跨线程池、异步回调时丢失的问题,见压测流量的标记,过了线程池就没了。

四、检查清单 ​

  • 每个字段都能说出它的生命周期,并且与所在对象一致;单例里没有请求级的数据。
  • 同类型、相邻的参数,考虑用参数对象或更具体的类型区分,并在构造时校验约束。
  • 参数对象表达一个概念,不是什么都装的上下文口袋。
  • 依赖在构造时注入;方法参数只放业务数据。
  • 选择不同业务流程的布尔参数,拆成有名字的方法。
  • ThreadLocal 的设置与清理成对出现,清理在 finally 里;能用 ScopedValue 时优先用它。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权