清晰设计,强大客户端:Elasticsearch Java SDK的设计之道
Java拥有庞大的API生态系统,但并非所有API都有效或易于学习。开发一个好的API并非易事:误设计关键元素、定义简单抽象和线程模型是必须解决的主题之一。官方的Elasticsearch Java SDK是一个在设计上付出努力来解决这些问题的项目。
最近,这个项目让我感到惊讶,我试图研究使其有趣和有效的设计思想,同时也有一些需要注意的不可避免的权衡。
代码生成和单一数据源
Elastic Java SDK并非完全手写:它是从用TypeScript开发的规范API生成的,但也有手工制作的产物。客户端生成管道从这个规范中产生Java模型类、构建器、序列化器和顶级命名空间方法。这种生成的方法解释了数百个端点和多种语言客户端在命名、形状和覆盖范围方面的一致性。手工制作(由Elastic工程师)的部分包括:
与低级REST客户端(LLRC)的传输集成
核心基础设施:认证、TLS、重试、测试框架、持续集成、JSON映射器设置
API人体工程学:构建器模式、可空性约定、命名
ADR驱动的决策:手工制作的部分由架构决策记录支持,记录了为什么做出某些设计选择
构建器模式做得(大部分)正确
Elasticsearch SDK大量依赖构建器模式:它通过一个基础的ObjectBuilder<T>接口表达,该接口只有一个build()`方法。你正在构建搜索查询、索引映射、批量操作等。没有构建器,那将是一片混乱。
SDK提供构建器-lambda重载(例如,() -> ObjectBuilder<T>),这样嵌套对象可以通过类型安全的闭包内联构造;这些重载在公共Javadocs中作为函数类型的构建器设置器可见。在内部,生成的构建器继承自ObjectBuilderBase,当需要调用build()方法时,它通过_checkSingleUse()`强制单次使用。
一旦你调用build(),就完成了。重用构建器被认为是不安全的,因为内部结构,特别是集合,可能在构建器和构建的对象之间共享。改变其中一个可能会悄悄地破坏另一个。所以他们在构造后关闭了大门,合同很清楚:配置一次,构建,然后忘记。每个请求和响应类都遵循这种模式。
- SearchRequest request = SearchRequest.of(s -> s
- .index("products")
- .query(q -> q
- .match(m -> m
- .field("name")
- .query("laptop")
- )
- )
- );
乍一看,这像是一个lambda重载的混乱。但它实际上在做的是链接一组类型化的嵌套DSL构造。of(...)是一个static快捷方式,它实例化构建器,应用配置函数,并完成构建。你正在编写直接镜像Elasticsearch查询DSL的声明性Java代码。
真正有效的DSL风格Lambda
这些lambda提供了有效且IDE友好的实现,使深度嵌套的DSL结构更容易编写,同时保持强类型。Java不是函数式语言:支持不同的习语,但其本质是面向对象的语言。SDK仍然设法使用lambda创建惯用语法,这使得开发者意图的声明变得容易。
- client.search(s -> s
- .index("products")
- .query(q -> q
- .bool(b -> b
- .must(m -> m
- .match(t -> t
- .field("description")
- .query("wireless")
- )
- )
- )
- )
- );
每个lambda代表一个嵌套的配置步骤,具有类型安全的闭包。你不是传入字符串或魔法映射。你正在组合一个静态类型的树。这就是胜利。它避免了一个常见的反模式:在数十个可变对象上链接.withX()、.setY()方法,null到处潜伏。在这里,每个级别都是作用域化的、聚焦的和不可变的。
标记联合和类型安全的变体
让我们谈论多态性。Elasticsearch查询不是平面结构——它们是变体类型。一个Query可以是MatchQuery、BoolQuery、RangeQuery等。SDK使用标记联合模式对此进行建模。
标记联合是一种可以保存不同数据类型值的数据结构,但一次只能保存一种。它类似于常规联合,但包含一个标记(或判别器),显示当前存储的是哪种数据类型。这个标记允许对存储值的类型安全访问,防止意外的数据误用。
客户端为许多这些变体域(查询、聚合、分析器等)实现了通用的TaggedUnion模式。TaggedUnion暴露当前的kind和强类型值;构建器为每个变体暴露显式方法,使发现和正确性更容易。这种模式用少量间接换取编译器强制的详尽性以获得IDE中更好的可发现性。
每个这样的联合实现:
- public interface TaggedUnion<Tag extends Enum>, BaseType> {
- Tag _kind();
- BaseType _get();
- }
你检查_kind()来找出它是什么,然后调用_get()并安全地转换它:
- Query query = Query.of(q -> q
- .match(m -> m.field("title").query("elasticsearch"))
- );
- if (query._kind() == Query.Kind.Match) {
- MatchQuery match = (MatchQuery) query._get();
- // 安全地使用match
- }
这种设计解决方案允许在没有最新版本Java语法支持的情况下对联合构造进行推理,同时保持与已经在以前安装中使用Elastic产品的人的向后兼容性。从Java 16+开始,我们有了结构模式匹配的味道,可以在未来用于SDK的演进;例如,第一步可能是:
- switch (query._kind()) {
- case Match -> {
- MatchQuery match = (MatchQuery) query._get();
- // 使用match
- }
- case Term -> {
- TermQuery term = (TermQuery) query._get();
- // 使用term
- }
- }
模块化设计:命名空间客户端模式
Elasticsearch的API很宽泛。它有搜索、索引管理、映射、摄取管道、安全、集群健康等更多端点。把所有这些塞进一个类将是一场噩梦。
相反,SDK按域来划分事物:
- ElasticsearchClient client = new ElasticsearchClient(transport);
- client.indices().create(c -> c.index("catalog"));
- client.search(s -> s
- .index("products")
- .query(q -> q.match(m -> m.field("name").query("laptop")))
- );
每个子DSL节点(indices()、search()等)只暴露其上下文相关的操作。这对IDE和我们的大脑都是有效的支持。它直接映射到REST API结构(/_search、/_indices等),使推理什么去哪里变得更容易。它也使SDK高度可维护。添加新的API组变得简单:没有巨大的上帝接口需要重构。
针对一个功能齐全的Java网络SDK,必须支持:
每连接配置(认证、头、超时)
多个并发客户端
良好的可测试性和DI
人体工程学的开发者体验
命名空间实例客户端模式是一个理性的权衡。你面临稍微更多的仪式和许多小类,但在引擎盖下支持模块化,同时给用户一个可发现的根对象。
不可变性和传输抽象
请求对象不改变。构建器构建一次。你传递数据,而不是行为:SDK本质上是线程安全和可预测的。
传输关注点与类型化模型分离:默认情况下,Java客户端将协议处理委托给RestClientTransport,它本身使用低级HTTP客户端(例如,Apache HTTP客户端)来管理连接、池化、重试和节点发现。这种分离使Java客户端能够专注于类型化请求/响应建模和(反)序列化,而传输处理操作关注点。
传输是可插拔的,允许客户端配置在需要时适应不同的HTTP堆栈。
- ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
- ElasticsearchClient client = new ElasticsearchClient(transport);
这种关注点分离使SDK更容易测试、扩展和调试。
能做得更好吗?
许多不可变元素的存在影响内存使用。因此,有必要了解如何避免构建太多对象,这些对象推动开发者考虑产品的高级构造,如捆绑包、数据共享、临时实体的使用和/或数据本身的分页。
不可变性和单次使用构建器的可测量权衡是短期对象分配。在大多数应用程序中,这种开销是可忽略的;在非常热的循环或高吞吐量管道中,你应该进行基准测试,如有必要,构建可重用的不可变片段或调整批量/批处理策略。还要考虑序列化的开销:如果你需要自定义解析或发送预序列化的有效载荷,客户端提供钩子来使用不同的JsonpMapper实现(例如,基于Jackson的映射器)。
今天,Java提供了更多构造:记录、带密封类的结构模式匹配和instanceof的解构;这也意味着SDK通过追逐Java特性而持续重构。
这个SDK在持久性和开发者友好性之间取得了平衡,而不改变以前Elastic产品的知识。
结论
你能从这个SDK中学到什么?这里是主要概念的摘要表,对你的下一个项目可能有用。如果你正在构建客户端库——甚至只是面向公众的API——你可能不会比借用这个的一些想法做得更糟。没有魔法。只是好的设计...但要注意:上下文是王!
|
模式
|
它解决的问题
|
|
构建器(单次使用)
|
更安全的不可变构造,避免共享可变状态
|
|
流畅接口(Lambdas)
|
声明性、嵌套、类型安全的请求定义
|
|
标记联合模式
|
安全且明确地建模Elasticsearch变体类型
|
|
命名空间客户端模式
|
逻辑API分组与REST结构对齐
|
|
传输抽象
|
可交换的HTTP和序列化层
|
|
函数式思维
|
更少的副作用、更少的临时变量、更多的声明性代码
|
