设计模式综合实战九:基于Elasticsearch实现搜索引擎
搜索引擎这东西,听起来挺高大上的。其实说白了,就是把一堆数据倒腾进去,再按你的条件快速捞出来。我这些年做过好几个搜索相关的项目,从最早的Lucene裸写,到后来用Solr,再到现在的Elasticsearch,踩过的坑真不少。
今天这个实战案例,我打算把四个设计模式揉在一起——适配器、策略、模板方法、建造者。你可能会问:「有必要搞这么复杂吗?」嗯,等你看到后面就明白了。ES本身功能强大,但直接裸写代码,后期维护起来会非常痛苦。
整体架构思路
先说说我的设计理念。搜索引擎的核心流程其实很固定:建立索引 → 构建查询 → 执行搜索 → 处理结果。这个流程本身可以用模板方法模式来固定骨架。但每个环节的具体实现,比如不同的查询策略、不同的数据源适配,就需要策略模式和适配器模式来灵活应对。最后,构建复杂的查询请求时,建造者模式能帮我们写出更优雅的代码。
核心设计原则:把变化的部分封装起来,让稳定的部分保持稳定。ES的API版本升级频繁,如果代码耦合太紧,升级一次改一堆,那滋味可不好受。
下面这张图展示了整体的设计结构,我建议你先看一遍,有个全局印象:
适配器模式:隔离ES版本差异
我记得最早做ES项目时,从2.x升级到5.x,那叫一个痛苦。API全变了,代码改得面目全非。后来我学乖了,加一层适配器。
适配器模式在这里的作用,就是把ES原生的客户端API包装成我们自己的接口。这样即使ES版本升级,只需要改适配器内部实现,业务代码完全不用动。
// 定义统一接口
public interface SearchClientAdapter {
boolean index(String indexName, String id, String jsonDoc);
SearchResponse search(String indexName, QueryBuilder query);
boolean deleteIndex(String indexName);
}
// ES 7.x 适配器实现
public class ES7ClientAdapter implements SearchClientAdapter {
private RestHighLevelClient client;
public ES7ClientAdapter(String hosts) {
// 初始化ES 7.x客户端
this.client = new RestHighLevelClient(
RestClient.builder(HttpHost.create(hosts))
);
}
@Override
public boolean index(String indexName, String id, String jsonDoc) {
IndexRequest request = new IndexRequest(indexName).id(id).source(jsonDoc, XContentType.JSON);
try {
return client.index(request, RequestOptions.DEFAULT).getResult() == DocWriteResponse.Result.CREATED;
} catch (IOException e) {
throw new SearchRuntimeException("索引文档失败", e);
}
}
// 其他方法实现...
}
避坑指南:我曾经在适配器里直接返回ES的原生对象,结果上层代码还是跟ES耦合了。记住,适配器返回的必须是自定义的DTO,不能是ES的Response对象。
策略模式:灵活切换查询方式
搜索场景千变万化。有时候要精确匹配,有时候要模糊搜索,有时候要组合过滤。如果把这些逻辑全写在一个方法里,那代码会变成一坨浆糊。
策略模式就是干这个的。每种查询方式封装成一个策略类,运行时自由切换。
// 查询策略接口
public interface QueryStrategy {
QueryBuilder buildQuery(SearchRequest request);
}
// 精确匹配策略
public class TermQueryStrategy implements QueryStrategy {
@Override
public QueryBuilder buildQuery(SearchRequest request) {
return QueryBuilders.termQuery(request.getField(), request.getValue());
}
}
// 模糊搜索策略
public class FuzzyQueryStrategy implements QueryStrategy {
@Override
public QueryBuilder buildQuery(SearchRequest request) {
return QueryBuilders.fuzzyQuery(request.getField(), request.getValue())
.fuzziness(Fuzziness.AUTO);
}
}
// 组合查询策略
public class BoolQueryStrategy implements QueryStrategy {
@Override
public QueryBuilder buildQuery(SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (Condition condition : request.getConditions()) {
if (condition.isMust()) {
boolQuery.must(QueryBuilders.termQuery(condition.getField(), condition.getValue()));
} else {
boolQuery.should(QueryBuilders.termQuery(condition.getField(), condition.getValue()));
}
}
return boolQuery;
}
}
你想想看,如果以后要加一种新的查询方式,比如地理位置搜索,只需要新增一个策略类,完全不用改现有代码。这就是开闭原则的魅力。
模板方法模式:固定搜索流程
搜索的骨架是固定的:构建查询 → 执行搜索 → 处理结果。但每个步骤的具体实现可以不同。模板方法模式正好适合这种场景。
public abstract class SearchTemplate {
// 模板方法,定义算法骨架
public final SearchResult execute(SearchRequest request) {
// 1. 构建查询
QueryBuilder query = buildQuery(request);
// 2. 执行搜索(子类可覆盖)
SearchResponse response = doSearch(query, request);
// 3. 处理结果
return handleResults(response, request);
}
// 抽象方法,子类必须实现
protected abstract QueryBuilder buildQuery(SearchRequest request);
// 钩子方法,子类可选覆盖
protected SearchResponse doSearch(QueryBuilder query, SearchRequest request) {
// 默认实现:直接调用ES客户端
return clientAdapter.search(request.getIndexName(), query);
}
// 抽象方法
protected abstract SearchResult handleResults(SearchResponse response, SearchRequest request);
}
// 具体实现:商品搜索
public class ProductSearchTemplate extends SearchTemplate {
private QueryStrategy queryStrategy;
public ProductSearchTemplate(QueryStrategy queryStrategy) {
this.queryStrategy = queryStrategy;
}
@Override
protected QueryBuilder buildQuery(SearchRequest request) {
// 使用策略模式构建查询
return queryStrategy.buildQuery(request);
}
@Override
protected SearchResult handleResults(SearchResponse response, SearchRequest request) {
// 解析ES响应,转换为商品搜索结果
List<Product> products = new ArrayList<>();
for (SearchHit hit : response.getHits().getHits()) {
products.add(parseProduct(hit.getSourceAsString()));
}
return new SearchResult(products, response.getHits().getTotalHits().value);
}
}
关键点:模板方法用final修饰,防止子类修改流程。钩子方法提供默认实现,子类按需覆盖。这样既保证了流程一致性,又保留了灵活性。
建造者模式:优雅构建复杂查询
ES的查询请求参数非常多——索引名、查询条件、排序、分页、高亮、聚合……如果通过构造函数传参,那构造函数会变成灾难。建造者模式就是解决这个问题的。
public class SearchRequestBuilder {
private String indexName;
private QueryStrategy queryStrategy;
private int from = 0;
private int size = 10;
private List<SortOption> sorts = new ArrayList<>();
private List<String> fields = new ArrayList<>();
private boolean explain = false;
public SearchRequestBuilder index(String indexName) {
this.indexName = indexName;
return this;
}
public SearchRequestBuilder strategy(QueryStrategy strategy) {
this.queryStrategy = strategy;
return this;
}
public SearchRequestBuilder paginate(int page, int size) {
this.from = (page - 1) * size;
this.size = size;
return this;
}
public SearchRequestBuilder sort(String field, SortOrder order) {
this.sorts.add(new SortOption(field, order));
return this;
}
public SearchRequestBuilder fetchFields(String... fieldNames) {
this.fields.addAll(Arrays.asList(fieldNames));
return this;
}
public SearchRequestBuilder enableExplain() {
this.explain = true;
return this;
}
public SearchRequest build() {
// 校验必要参数
if (indexName == null || indexName.isEmpty()) {
throw new IllegalStateException("索引名不能为空");
}
if (queryStrategy == null) {
throw new IllegalStateException("查询策略不能为空");
}
SearchRequest request = new SearchRequest();
request.setIndexName(indexName);
request.setQueryStrategy(queryStrategy);
request.setFrom(from);
request.setSize(size);
request.setSorts(sorts);
request.setFields(fields);
request.setExplain(explain);
return request;
}
}
// 使用示例
SearchRequest request = new SearchRequestBuilder()
.index("products")
.strategy(new BoolQueryStrategy())
.paginate(1, 20)
.sort("price", SortOrder.ASC)
.fetchFields("name", "price", "description")
.enableExplain()
.build();
我个人习惯把建造者的build()方法设计成会做参数校验的。这样调用方拿到的请求对象一定是合法的,不会出现运行时才发现参数缺失的情况。
四种模式协同工作
这四种模式不是各自为战,而是紧密配合的。我画个表格帮你理清关系:
| 模式 | 职责 | 在搜索引擎中的角色 |
|---|---|---|
| 适配器模式 | 隔离外部依赖 | 封装ES客户端,屏蔽版本差异 |
| 策略模式 | 算法族封装 | 不同的查询/搜索/结果处理策略 |
| 模板方法模式 | 固定流程骨架 | 定义搜索的完整执行流程 |
| 建造者模式 | 复杂对象构建 | 构建包含多参数的搜索请求 |
实际运行时,流程是这样的:
- 客户端通过建造者构建
SearchRequest,指定索引、策略、分页等参数 - 调用模板方法的
execute()方法 - 模板方法内部调用策略的
buildQuery()构建ES查询 - 通过适配器执行搜索,与ES交互
- 模板方法调用策略处理结果,返回给客户端
注意:不要为了用设计模式而用设计模式。如果你的搜索场景很简单,就几个固定查询,那直接写ES API反而更高效。设计模式是为了应对复杂性和变化,不是用来炫技的。
避坑总结
最后分享几个我踩过的坑:
- 适配器不要泄露ES类型——所有入参出参都应该是自定义类型,否则适配器就失去了隔离的意义
- 策略要支持动态注册——我建议用工厂模式配合策略模式,通过配置或注解自动注册策略,而不是硬编码if-else
- 模板方法不要滥用钩子——钩子方法多了,子类实现起来很痛苦。保持模板方法简洁,只暴露必要的扩展点
- 建造者要线程安全——如果建造者会被多个线程共享,记得加同步或使用不可变对象
嗯,这套组合模式我在好几个生产项目中都用过,效果还不错。代码结构清晰,扩展方便,测试也好写。如果你正在做一个搜索相关的系统,不妨试试这个思路。
公众号:蓝海资料掘金营,微信deep3321