设计模式综合实战九:基于Elasticsearch实现搜索引擎

搜索引擎这东西,听起来挺高大上的。其实说白了,就是把一堆数据倒腾进去,再按你的条件快速捞出来。我这些年做过好几个搜索相关的项目,从最早的Lucene裸写,到后来用Solr,再到现在的Elasticsearch,踩过的坑真不少。

今天这个实战案例,我打算把四个设计模式揉在一起——适配器、策略、模板方法、建造者。你可能会问:「有必要搞这么复杂吗?」嗯,等你看到后面就明白了。ES本身功能强大,但直接裸写代码,后期维护起来会非常痛苦。

整体架构思路

先说说我的设计理念。搜索引擎的核心流程其实很固定:建立索引 → 构建查询 → 执行搜索 → 处理结果。这个流程本身可以用模板方法模式来固定骨架。但每个环节的具体实现,比如不同的查询策略、不同的数据源适配,就需要策略模式适配器模式来灵活应对。最后,构建复杂的查询请求时,建造者模式能帮我们写出更优雅的代码。

核心设计原则:把变化的部分封装起来,让稳定的部分保持稳定。ES的API版本升级频繁,如果代码耦合太紧,升级一次改一堆,那滋味可不好受。

下面这张图展示了整体的设计结构,我建议你先看一遍,有个全局印象:

搜索引擎设计模式架构图 SearchTemplate(模板方法) buildQuery() executeSearch() handleResults() QueryStrategy(策略) SearchStrategy(策略) ResultStrategy(策略) ESClientAdapter(适配器) QueryBuilder(建造者)

适配器模式:隔离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客户端,屏蔽版本差异
策略模式 算法族封装 不同的查询/搜索/结果处理策略
模板方法模式 固定流程骨架 定义搜索的完整执行流程
建造者模式 复杂对象构建 构建包含多参数的搜索请求

实际运行时,流程是这样的:

  1. 客户端通过建造者构建SearchRequest,指定索引、策略、分页等参数
  2. 调用模板方法execute()方法
  3. 模板方法内部调用策略buildQuery()构建ES查询
  4. 通过适配器执行搜索,与ES交互
  5. 模板方法调用策略处理结果,返回给客户端

注意:不要为了用设计模式而用设计模式。如果你的搜索场景很简单,就几个固定查询,那直接写ES API反而更高效。设计模式是为了应对复杂性和变化,不是用来炫技的。

避坑总结

最后分享几个我踩过的坑:

  • 适配器不要泄露ES类型——所有入参出参都应该是自定义类型,否则适配器就失去了隔离的意义
  • 策略要支持动态注册——我建议用工厂模式配合策略模式,通过配置或注解自动注册策略,而不是硬编码if-else
  • 模板方法不要滥用钩子——钩子方法多了,子类实现起来很痛苦。保持模板方法简洁,只暴露必要的扩展点
  • 建造者要线程安全——如果建造者会被多个线程共享,记得加同步或使用不可变对象

嗯,这套组合模式我在好几个生产项目中都用过,效果还不错。代码结构清晰,扩展方便,测试也好写。如果你正在做一个搜索相关的系统,不妨试试这个思路。


公众号:蓝海资料掘金营,微信deep3321