设计模式修炼之路 ㉜ 服务定位器模式——服务只找一次,之后全靠缓存
查找与缓存一处收口、注册中心只被打扰 N 次、缓存的是服务不是结果、种子从 ㉗ 长成 static 注册台、字符串名字没有编译器保护、与业务代表对账
目录
J2EE 模式第七站。
㉛ 结尾留了个问号:装配代码里,五个过滤器、两个控制器全是 new 出来摆好的。真实应用何止这几个角色——DAO、缓存、支付网关,谁需要谁自己造一份?有没有一个"服务注册处",登记一次、按名取用?
其实这颗种子 ㉗ 就埋下了:ServiceLookup 里那句"查到就缓存",当时还是代表私有的一个字段。本篇让它长大——把服务的查找与缓存收进一个全局注册台,第一次按名走注册中心,之后缓存直取——服务定位器模式,J2EE 八式里的服务注册处。
一、服务定位器模式是什么
J2EE 蓝皮书的定义大意:服务定位器(Service Locator)为服务的查找与创建提供全应用统一的入口,封装注册中心的访问细节,并把查到的服务缓存起来——客户端不必知道服务在哪儿、怎么建,按名取用即可。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Service(服务) | 被查找的对象:重服务,建一份全应用共用 | DatabaseService / PaymentService |
| Initial Context(初始上下文) | 注册中心的封装:按名查找,又慢又贵 | InitialContext(模拟 JNDI) |
| Service Locator(服务定位器) | 唯一入口:第一次走注册中心并缓存,之后直取 | ServiceLocator |
| Client(客户端) | 要服务只找定位器 | OrderService / ReportService |
一句话理解分工:客户端说"我要 database",定位器说"第一趟我去注册中心拿,之后问我就行",注册中心说"名字到服务,只此一家"。
它不是 GoF 二十三式,是 J2EE 蓝皮书成员——和 ㉗ 是直系血亲:㉗ 的 ServiceLookup 是它的种子(单字段、代表私有),㉜ 长大成全局注册台(static Map、全应用一份)。第六节把这爷俩的账摊开算。
二、优点与缺点
优点:
- 查找逻辑一处收口:全应用只有定位器认得注册中心——业务代码里再没有 InitialContext、再没有"怎么找"的细节。反面教材里每家自己连注册中心的 N 份拷贝,在这里是一份。
- 缓存直取:注册中心只被打扰 N 次(N = 服务个数),之后全是内存级命中。lookup 一次 800ms 的痛,一次性付清。
- 重服务全局一份:连接池只建一套、支付通道只建一条。反面教材里 database 被查两次就建两套池——浪费的不只是两个 800ms,还有白白占着的 10 个连接。
- 按名解耦:客户端只认名字不认实现——注册中心那边 database 换个实现,客户端一行不用改。
缺点:
- 静态全局,测试难:单测想换个假 database?得先想办法清掉 static 缓存,或者接受拿真服务跑测试。依赖注入把依赖从构造函数送进来,天生好测——这是后来 DI 容器赢下江湖的核心一手。
- 依赖被藏起来了:构造函数干干净净,打开类体才发现 get("database")、get("payment")——这个类依赖了什么,签名上看不出来,只能全文搜。
- 名字是字符串,编译器不管:get("databse") 拼错了照样编译通过,运行时才炸——细节三展开。
三、什么时候用
判断标准:服务来源在别处(注册中心/远程)、查找贵、服务全局一份——三条都占,定位器就值回票价。
- 典型场景:JNDI 时代的 EJB 查找——lookup 要连注册中心走网络,贵得理直气壮
- 日常场景:日志门面 LoggerFactory.getLogger——按名取 logger、内部缓存,你天天在用的服务定位器(第五节见)
- 反面对照:服务就在本地、直接 new 不贵——满地 ServiceLocator.get 是给简单问题套重壳。更现实的忠告:今天写新项目,同样的事首选依赖注入(Spring 把服务"送上门",而不是"你去取")——定位器是 DI 普及前的主流答案,它的三条缺点,恰好逐条定义了 DI 的优点
四、实现方式:注册台怎么搭
场景:一个迷你应用,两个重服务——database(连接池)和 payment(支付网关),住在注册中心后面,查一次 800ms。两个业务组件都要用:下单的要 database + payment,出日报的只要 database。
4.1 反面教材:每个组件自己找服务
/** 反面教材:业务代码里粘满"怎么找服务"的细节 */
public class BadOrderService {
public void placeOrder(String item) {
System.out.println(" [BadOrderService] 要用数据库了,自己去找:");
DatabaseService database = new InitialContext().lookup("database");
System.out.println(" [BadOrderService] 要用支付了,自己去找:");
PaymentService payment = new InitialContext().lookup("payment");
System.out.println(" [BadOrderService] 业务:下单 " + item);
database.save("订单:" + item);
payment.charge("订单:" + item);
}
}
BadReportService 同款,只要 database——又一次 lookup。运行输出:
========== 反面教材:每个组件自己找服务 ==========
-- OrderService 下单(u001 买一本书) --
[BadOrderService] 要用数据库了,自己去找:
[注册中心] 连接注册中心查找 database ……(耗时 800ms)
[DatabaseService] 初始化连接池(10 个连接)
[BadOrderService] 要用支付了,自己去找:
[注册中心] 连接注册中心查找 payment ……(耗时 800ms)
[PaymentService] 建立支付通道(对接银行)
[BadOrderService] 业务:下单 u001 买一本书
[DatabaseService] 入库:订单:u001 买一本书
[PaymentService] 扣款:订单:u001 买一本书
-- ReportService 出日报 --
[BadReportService] 要用数据库了,自己去找:
[注册中心] 连接注册中心查找 database ……(耗时 800ms)
[DatabaseService] 初始化连接池(10 个连接)
[BadReportService] 业务:出日报
[DatabaseService] 查询:昨天的订单汇总
同一个 database 被 lookup 了两次——注册中心被打扰两次,连接池也建了两套
-- 再来一单(u002 买两本书) --
[BadOrderService] 要用数据库了,自己去找:
[注册中心] 连接注册中心查找 database ……(耗时 800ms)
[DatabaseService] 初始化连接池(10 个连接)
[BadOrderService] 要用支付了,自己去找:
[注册中心] 连接注册中心查找 payment ……(耗时 800ms)
[PaymentService] 建立支付通道(对接银行)
[BadOrderService] 业务:下单 u002 买两本书
[DatabaseService] 入库:订单:u002 买两本书
[PaymentService] 扣款:订单:u002 买两本书
又是两次 lookup——服务明明全局一个,谁用谁新找一遍
病灶:服务是全局的,找服务的事却是每家自己干——同一个 database 被 lookup 了两次,连接池建了两套;u002 再下单,又是两次 lookup,前一次的成果没人记得。"查找贵 + 服务全局一份"这两个前提一叠加,"谁用谁自己找"就是结构性浪费。注意:功能一切正常,订单照入库、日报照出——反面教材从来不是功能错误,是结构错误。
4.2 正解:服务定位器
注册中心原样不动——定位器不改造世界,只是在它前面搭了个柜台:
public class InitialContext {
@SuppressWarnings("unchecked")
public <T> T lookup(String name) {
System.out.println(" [注册中心] 连接注册中心查找 " + name + " ……(耗时 800ms)");
switch (name) {
case "database":
return (T) new DatabaseService();
case "payment":
return (T) new PaymentService();
default:
throw new IllegalArgumentException("没有这个服务:" + name);
}
}
}
定位器——私有构造 + static 缓存,全应用唯一注册台:
public class ServiceLocator {
private static final Map<String, Object> cache = new HashMap<>();
private static final InitialContext context = new InitialContext();
private ServiceLocator() {
}
@SuppressWarnings("unchecked")
public static <T> T get(String name) {
if (!cache.containsKey(name)) {
System.out.println(" [服务定位器] 缓存没有 " + name + ",去注册中心拿");
cache.put(name, context.lookup(name));
System.out.println(" [服务定位器] " + name + " 已入缓存");
} else {
System.out.println(" [服务定位器] 缓存命中:" + name);
}
return (T) cache.get(name);
}
}
客户端——要用服务,一行 get:
public class OrderService {
public void placeOrder(String item) {
DatabaseService database = ServiceLocator.get("database");
PaymentService payment = ServiceLocator.get("payment");
System.out.println(" [OrderService] 业务:下单 " + item);
database.save("订单:" + item);
payment.charge("订单:" + item);
}
}
ReportService 同款,只要 database——一样的入口,一样的缓存。运行输出:
========== 正解:服务定位器 ==========
-- OrderService 下单(u001 买一本书) --
[服务定位器] 缓存没有 database,去注册中心拿
[注册中心] 连接注册中心查找 database ……(耗时 800ms)
[DatabaseService] 初始化连接池(10 个连接)
[服务定位器] database 已入缓存
[服务定位器] 缓存没有 payment,去注册中心拿
[注册中心] 连接注册中心查找 payment ……(耗时 800ms)
[PaymentService] 建立支付通道(对接银行)
[服务定位器] payment 已入缓存
[OrderService] 业务:下单 u001 买一本书
[DatabaseService] 入库:订单:u001 买一本书
[PaymentService] 扣款:订单:u001 买一本书
-- ReportService 出日报 --
[服务定位器] 缓存命中:database
[ReportService] 业务:出日报
[DatabaseService] 查询:昨天的订单汇总
-- 再来一单(u002 买两本书) --
[服务定位器] 缓存命中:database
[服务定位器] 缓存命中:payment
[OrderService] 业务:下单 u002 买两本书
[DatabaseService] 入库:订单:u002 买两本书
[PaymentService] 扣款:订单:u002 买两本书
注册中心全程只被打扰两次(database、payment 各一次),之后全是缓存直取
四处对照读出含金量:第一次下单,database、payment 各走一趟注册中心,800ms 只付一次;ReportService 要 database,缓存命中——注册中心没被打扰,连接池还是那一套;u002 再下单,两个都命中——注册中心全程只被打扰两次,服务有几个就打扰几次,之后永远缓存直取;再对照客户端代码——BadOrderService 四行找服务两行业务,OrderService 两行 get 两行业务,技术细节全进了定位器,业务方法里只剩业务。
4.3 三处细节
细节一:缓存的是服务本身,不是调用结果。 定位器缓存的是 DatabaseService 这个对象的引用——连接池、支付通道这类重服务,建好了全应用反复用;它不缓存 query() 的返回值——那叫业务缓存,是另一层的事。两层缓存搅在一起,是新手最常见的误会。
细节二:从"各存各的"到"全局注册台"。 ㉗ 的种子是单字段:private OrderService orderService——代表私有,每个代表各缓存各的,别人沾不到光;㉜ 长大成 static Map——全应用一份,跨组件共享,ReportService 才能命中 OrderService 存下的 database。缓存挪了个作用域,模式的身份就变了:从代表身上的一个技巧,变成全应用的公共设施。
细节三:泛型强转是运行时赌博。 PaymentService payment = ServiceLocator.get("database")——泛型方法照单全收,编译器一声不吭,运行时 ClassCastException 才炸。字符串名字天生没有编译期约束,拼错名字同罪;DI 容器用"类型注入"治好了这一半——按类型给依赖,装错了编译期就报。
五、在框架里找它
-
JNDI 原装:EJB 时代,InitialContext.lookup("java:comp/env/jdbc/OrderDS") + 一个 ServiceLocator 单例,是蓝皮书标准姿势;Tomcat 里配的 DataSource 至今还挂在 JNDI 树上,Spring 都留了 jndi-lookup 专门去取。
-
SLF4J 的 LoggerFactory:getLogger("com.foo.OrderService")——按名取 logger,内部缓存,同名永远同一个实例。你天天在用服务定位器,只是没人叫过它这个名字。
-
Spring ApplicationContext:getBean("orderService") 的本质就是一个超级服务定位器——按名取、有缓存(默认单例)。但 Spring 官方把"满地 getBean"明确定性为反模式,推崇 @Autowired 注入——同一个容器,取的方式决定它是模式还是反模式。
-
java.util.ServiceLoader(SPI):按接口找实现并实例化——JDBC 驱动的自动加载就靠它。名字从"字符串"升级成"接口",细节三的病好了一半,思想同源:组件不用管实现从哪儿来。
-
Fowler 的判词:《企业应用架构模式》(PoEAA)给它正名;后来同一人又在《IoC 容器与依赖注入》一文中亲自给它补了一刀——与 DI 相比,定位器把依赖藏进类体、单测难以替换。它输给 DI 的两条,恰好就是本文缺点一、二——一个模式的退位檄文,早就写在它的缺点里。
六、对账——㉗ 业务代表 vs ㉜ 服务定位器
㉗ 讲业务代表时,ServiceLookup 只是代表身上的一个零件;㉜ 把这个零件拎出来单独立户。爷俩摊开对账:
| 业务代表(㉗) | 服务定位器(㉜) | |
|---|---|---|
| 管什么 | 表示层与业务层之间的缓冲带 | 服务从哪儿来 |
| 一句话职责 | 重试、异常翻译——把远程说得像本地 | 查找、缓存——让注册中心只被打扰一次 |
| 缓存归谁 | 代表私有(每个代表各一份) | 全局 static(全应用一份) |
| 客户端拿到 | 业务话术方法(placeOrder) | 服务对象本身(get("database")) |
| 典型同框 | EJB 标准答案:Session Facade + Business Delegate + Service Locator 三件套,代表内部用定位器找服务 |
一句话:㉗ 管"怎么把远程服务说得像本地话",㉜ 管"服务从哪儿来、只找一次"——一个管通话质量,一个管通讯录。顺带一笔:定位器的 static Map 本质是 ② 单例思想——② 管"这个类全应用只有一个实例",㉜ 管"这些服务全应用只找一次";再往前走一步,Spring 容器就是"全局单例注册表 + 类型注入"的合体——㉜ 和 ②,在 DI 容器里会师。
写在最后
- 服务定位器 = 查找与缓存的统一收口:第一次按名走注册中心,之后缓存直取
- 疗效:注册中心只被打扰 N 次(N = 服务数),重服务全局一份,客户端一行 get
- 代价:静态全局测试难、依赖藏进类体、字符串名字编译器不查——三条缺点逐条定义了 DI 的优点
- 铁律:缓存的是服务引用,不是调用结果——和业务缓存是两层,别搅在一起
- 种子发芽:㉗ 代表私有的单字段缓存,长成 ㉜ 的 static 注册台;再与 ② 单例会师于 DI 容器
- 当代转世:JNDI、LoggerFactory.getLogger、getBean、ServiceLoader——按名取用,全球通吃
服务定位器把"服务从哪儿来"安排得明明白白——可服务到手之后,数据怎么回去?出日报查回来的汇总要递给调用方,顺着网络递对象,总不能把实体整个扔过去:它身上挂着行为、挂着一整窝依赖对象——㉘ 说过,组合实体住在服务器上。客户端要的只是一份干干净净、拎起来就能走的数据快照——传输对象模式,下一篇见。