设计模式预计阅读 18 分钟7172 字1926 次浏览

设计模式修炼之路 ㉜ 服务定位器模式——服务只找一次,之后全靠缓存

查找与缓存一处收口、注册中心只被打扰 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、全应用一份)。第六节把这爷俩的账摊开算。

二、优点与缺点

优点:

  1. 查找逻辑一处收口:全应用只有定位器认得注册中心——业务代码里再没有 InitialContext、再没有"怎么找"的细节。反面教材里每家自己连注册中心的 N 份拷贝,在这里是一份。
  2. 缓存直取:注册中心只被打扰 N 次(N = 服务个数),之后全是内存级命中。lookup 一次 800ms 的痛,一次性付清。
  3. 重服务全局一份:连接池只建一套、支付通道只建一条。反面教材里 database 被查两次就建两套池——浪费的不只是两个 800ms,还有白白占着的 10 个连接。
  4. 按名解耦:客户端只认名字不认实现——注册中心那边 database 换个实现,客户端一行不用改。

缺点:

  1. 静态全局,测试难:单测想换个假 database?得先想办法清掉 static 缓存,或者接受拿真服务跑测试。依赖注入把依赖从构造函数送进来,天生好测——这是后来 DI 容器赢下江湖的核心一手。
  2. 依赖被藏起来了:构造函数干干净净,打开类体才发现 get("database")、get("payment")——这个类依赖了什么,签名上看不出来,只能全文搜。
  3. 名字是字符串,编译器不管: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 容器用"类型注入"治好了这一半——按类型给依赖,装错了编译期就报。

五、在框架里找它

  1. JNDI 原装:EJB 时代,InitialContext.lookup("java:comp/env/jdbc/OrderDS") + 一个 ServiceLocator 单例,是蓝皮书标准姿势;Tomcat 里配的 DataSource 至今还挂在 JNDI 树上,Spring 都留了 jndi-lookup 专门去取。

  2. SLF4J 的 LoggerFactory:getLogger("com.foo.OrderService")——按名取 logger,内部缓存,同名永远同一个实例。你天天在用服务定位器,只是没人叫过它这个名字。

  3. Spring ApplicationContext:getBean("orderService") 的本质就是一个超级服务定位器——按名取、有缓存(默认单例)。但 Spring 官方把"满地 getBean"明确定性为反模式,推崇 @Autowired 注入——同一个容器,取的方式决定它是模式还是反模式。

  4. java.util.ServiceLoader(SPI):按接口找实现并实例化——JDBC 驱动的自动加载就靠它。名字从"字符串"升级成"接口",细节三的病好了一半,思想同源:组件不用管实现从哪儿来。

  5. 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——按名取用,全球通吃

服务定位器把"服务从哪儿来"安排得明明白白——可服务到手之后,数据怎么回去?出日报查回来的汇总要递给调用方,顺着网络递对象,总不能把实体整个扔过去:它身上挂着行为、挂着一整窝依赖对象——㉘ 说过,组合实体住在服务器上。客户端要的只是一份干干净净、拎起来就能走的数据快照——传输对象模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论