设计模式与搜索引擎:适配器集成搜索源、策略模式选择搜索算法、模板方法定义搜索流程

搜索引擎这东西,说起来大家都用过。但真要自己动手写一个聚合搜索系统,坑还是挺多的。我几年前做过一个企业内部的知识库搜索平台,当时要对接好几个不同的数据源——有 Elasticsearch、有数据库全文索引、还有第三方的 API 接口。每个源的查询方式都不一样,返回的数据结构也五花八门。

那时候我就想,能不能用设计模式把这套东西理清楚?后来还真让我找到了一个比较舒服的组合方式:适配器模式管接入,策略模式管算法,模板方法模式管流程。今天就跟大家聊聊这套打法。

一、适配器模式:统一搜索源的接入方式

先说说最头疼的问题——搜索源不统一。

你想想看,Elasticsearch 用的是 RESTful API,传 JSON 查询体;MySQL 的全文索引得拼 SQL 语句;第三方的搜索服务可能又要求 XML 格式。每个源都有自己的脾气,调用方要是直接跟它们打交道,代码里全是 if-else,维护起来简直要命。

适配器模式就是干这个的。它把不同搜索源的接口,统一成我们想要的「标准搜索接口」。

核心思路:定义一个统一的 SearchSource 接口,每个搜索源都用自己的适配器去实现这个接口。

我举个例子。假设我们定义了一个标准接口:

// 统一搜索源接口
public interface SearchSource {
    SearchResult search(SearchRequest request);
}

然后针对 Elasticsearch,我们写一个适配器:

public class ElasticsearchAdapter implements SearchSource {
    private ElasticsearchClient client;

    public ElasticsearchAdapter(ElasticsearchClient client) {
        this.client = client;
    }

    @Override
    public SearchResult search(SearchRequest request) {
        // 将统一的 SearchRequest 转换成 ES 的查询 DSL
        String esQuery = buildEsQuery(request);
        String response = client.query(esQuery);
        // 将 ES 返回的结果转换成统一的 SearchResult
        return parseEsResponse(response);
    }

    private String buildEsQuery(SearchRequest request) {
        // 构建 ES 查询 JSON
        return "{ \"query\": { \"match\": { \"content\": \"" 
               + request.getKeyword() + "\" } } }";
    }

    private SearchResult parseEsResponse(String response) {
        // 解析 ES 返回的 JSON,转换成统一格式
        return new SearchResult(/* ... */);
    }
}

同样,MySQL 全文索引也可以写一个适配器:

public class MysqlFulltextAdapter implements SearchSource {
    private JdbcTemplate jdbcTemplate;

    public MysqlFulltextAdapter(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public SearchResult search(SearchRequest request) {
        String sql = "SELECT * FROM documents WHERE MATCH(content) AGAINST(? IN BOOLEAN MODE)";
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, request.getKeyword());
        return convertToSearchResult(rows);
    }
}

这样一来,调用方根本不用关心背后是 ES 还是 MySQL,它只需要对着 SearchSource 接口编程就行。这就是适配器模式的价值——让不兼容的东西变得兼容

我的经验:适配器里别塞太多业务逻辑。它只负责「翻译」,不负责「加工」。我曾经在一个适配器里加了排序和过滤逻辑,结果换源的时候全得重写,教训深刻。

二、策略模式:灵活切换搜索算法

搜索源统一了,但搜索算法呢?

不同的场景可能需要不同的搜索策略。比如精确匹配、模糊搜索、语义搜索,甚至混合搜索。这些算法之间可以互相替换,而且未来还可能增加新的算法。

策略模式正好派上用场。它把算法封装成独立的策略类,让算法可以独立于使用它的客户端变化。

我们定义一个搜索算法接口:

public interface SearchStrategy {
    List<SearchResult> execute(List<SearchSource> sources, SearchRequest request);
}

然后实现几种具体的策略:

// 精确匹配策略
public class ExactMatchStrategy implements SearchStrategy {
    @Override
    public List<SearchResult> execute(List<SearchSource> sources, SearchRequest request) {
        List<SearchResult> results = new ArrayList<>();
        for (SearchSource source : sources) {
            SearchResult result = source.search(request);
            // 精确匹配:只保留完全匹配的结果
            result.getItems().removeIf(item -> !item.getTitle().equals(request.getKeyword()));
            results.add(result);
        }
        return results;
    }
}

// 模糊搜索策略
public class FuzzySearchStrategy implements SearchStrategy {
    @Override
    public List<SearchResult> execute(List<SearchSource> sources, SearchRequest request) {
        List<SearchResult> results = new ArrayList<>();
        for (SearchSource source : sources) {
            // 模糊搜索:使用通配符或编辑距离
            SearchRequest fuzzyRequest = new SearchRequest(request.getKeyword(), true);
            results.add(source.search(fuzzyRequest));
        }
        return results;
    }
}

// 混合搜索策略(加权合并多个源的结果)
public class HybridSearchStrategy implements SearchStrategy {
    @Override
    public List<SearchResult> execute(List<SearchSource> sources, SearchRequest request) {
        List<SearchResult> allResults = new ArrayList<>();
        for (SearchSource source : sources) {
            allResults.add(source.search(request));
        }
        // 按权重合并、去重、排序
        return mergeAndRank(allResults);
    }
}

使用的时候,客户端只需要选择策略:

SearchStrategy strategy = new FuzzySearchStrategy();
List<SearchResult> results = strategy.execute(sources, request);
注意:策略模式虽然灵活,但别滥用。如果只有一两种算法,硬编码可能更简单。我见过有人为了「优雅」给三个方法写了五个策略类,反而增加了理解成本。

三、模板方法模式:固定搜索流程,允许子类定制

搜索源和搜索算法都搞定了,但整个搜索流程其实是有固定套路的:

  1. 接收请求
  2. 预处理(如分词、过滤敏感词)
  3. 选择搜索源
  4. 执行搜索
  5. 后处理(如排序、分页、高亮)
  6. 返回结果

这个流程的骨架是固定的,但某些步骤可以有不同的实现。比如预处理阶段,有的系统需要做中文分词,有的不需要。后处理阶段,有的要按时间排序,有的要按相关性排序。

模板方法模式就是干这个的——在父类中定义流程骨架,把可变的部分留给子类去实现

public abstract class AbstractSearchEngine {

    // 模板方法:定义搜索流程
    public final SearchResponse search(SearchRequest request) {
        // 1. 预处理
        SearchRequest processedRequest = preProcess(request);

        // 2. 选择搜索源
        List<SearchSource> sources = selectSources(processedRequest);

        // 3. 选择搜索策略
        SearchStrategy strategy = selectStrategy(processedRequest);

        // 4. 执行搜索
        List<SearchResult> rawResults = strategy.execute(sources, processedRequest);

        // 5. 后处理
        SearchResponse response = postProcess(rawResults);

        return response;
    }

    // 子类可覆盖的钩子方法
    protected SearchRequest preProcess(SearchRequest request) {
        // 默认实现:不做任何处理
        return request;
    }

    protected abstract List<SearchSource> selectSources(SearchRequest request);

    protected abstract SearchStrategy selectStrategy(SearchRequest request);

    protected SearchResponse postProcess(List<SearchResult> results) {
        // 默认实现:简单合并
        return new SearchResponse(results);
    }
}

然后我们可以实现具体的搜索引擎:

public class DocumentSearchEngine extends AbstractSearchEngine {

    @Override
    protected SearchRequest preProcess(SearchRequest request) {
        // 文档搜索需要做中文分词
        String segmented = ChineseSegmenter.segment(request.getKeyword());
        return new SearchRequest(segmented, request.isFuzzy());
    }

    @Override
    protected List<SearchSource> selectSources(SearchRequest request) {
        // 文档搜索只查 ES 和数据库
        return Arrays.asList(esSource, dbSource);
    }

    @Override
    protected SearchStrategy selectStrategy(SearchRequest request) {
        // 文档搜索用模糊匹配
        return new FuzzySearchStrategy();
    }

    @Override
    protected SearchResponse postProcess(List<SearchResult> results) {
        // 按文档更新时间排序
        results.sort((a, b) -> b.getUpdateTime().compareTo(a.getUpdateTime()));
        return new SearchResponse(results);
    }
}

你看,模板方法把流程固定了,但每个步骤都可以灵活定制。这就是它的魅力——复用骨架,定制细节

四、三种模式如何协同工作

这三种模式不是孤立的,它们在实际系统中是协同工作的。我画了一张图来说明它们的关系:

三种设计模式协同工作 模板方法模式:AbstractSearchEngine ① 预处理 preProcess() ② 选择搜索源 selectSources() ③ 选择策略 selectStrategy() ④ 执行搜索 适配器模式:统一搜索源 ElasticsearchAdapter MysqlFulltextAdapter 实现 SearchSource 接口 实现 SearchSource 接口 策略模式:选择搜索算法 ExactMatch FuzzySearch Hybrid 实现 SearchStrategy 实现 SearchStrategy 实现 SearchStrategy 统一 SearchResponse 返回结果 模板方法(流程骨架) 适配器(统一接入) 策略(算法切换)

从这张图可以看得很清楚:

  • 模板方法在最上层,定义了「预处理 → 选源 → 选策略 → 执行 → 后处理」这个固定流程。
  • 适配器模式在中间层,把各种异构的搜索源包装成统一的 SearchSource 接口。
  • 策略模式也在中间层,封装了不同的搜索算法,让模板方法可以灵活选择。

三者的关系是:模板方法「用」适配器和策略,适配器「管」接入,策略「管」算法,各司其职。

五、避坑指南

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

我曾经在一个项目里把适配器和策略混在一起写。一个类既做数据源适配,又做算法选择。结果后来要加一个新搜索源,发现算法逻辑也被牵连了。记住:适配器只做接口转换,策略只做算法封装,别越界
另一个教训:模板方法里的钩子方法别太多。我见过一个模板类里有七八个钩子方法,子类重载的时候根本不知道哪些该重写。控制在 3-5 个以内,多了就拆类。

嗯,这套组合打法我后来在好几个项目里复用,效果都不错。设计模式这东西,单独看每个都挺简单,但组合起来用,才是真正的功力所在。