Ohhnews

分类导航

$ cd ..
foojay原文

如何使用 Java 和 MongoDB 构建搜索服务

#java#mongodb#atlas search#后端开发#搜索服务

目录

我们需要编写代码,打通从搜索框到搜索索引的路径。执行搜索并以可展示的方式渲染结果本身并不是一件难事:将用户的查询发送到搜索服务器,并将响应数据转换为某种用户界面技术。然而,有一些重要问题需要解决,例如安全性、错误处理、性能以及其他需要隔离和控制的问题。

典型的三层系统包含一个表现层,它将用户请求发送到中间层(即应用服务器),后者与后端数据服务进行交互。这些层分离了关注点,使得每一层都能专注于自身的职责。

[LOADING...]

如果您构建过用于管理数据库集合的应用程序,那么您无疑实现过增删改查(CRUD)功能,这些功能将业务逻辑隔离在中间应用层中。

搜索是一种略有不同类型的服务,因为它是只读的,访问非常频繁,必须快速响应才有用,并且通常返回的不仅仅是文档。搜索结果返回的额外元数据通常包括关键词高亮、文档得分、分面以及找到的结果数量。此外,搜索通常匹配的文档数量远超合理展示的范围,因此分页和过滤搜索是必要的功能。

我们的搜索服务通过以下方式提供了上述三层架构的好处:

  • 安全性:数据库连接字符串隔离到服务环境中。参数经过验证和清理。客户端/用户无法请求大量结果或进行深度分页。
  • 可扩展性:该服务是无状态的,可以轻松多次部署并进行负载均衡。
  • 更快的部署:服务端点可以进行版本控制,并在部署增强版本时保持运行。可以在不一定影响表现层或数据库及搜索索引配置的情况下修改行为。

在本文中,我们将详细介绍一个 HTTP Java 搜索服务,该服务设计为由表现层调用,并依次将请求转换为查询我们 Atlas 数据层的聚合管道。这纯粹是一个服务实现,没有最终用户 UI;用户界面留给读者作为练习。换句话说,作者在向用户界面提供搜索服务方面拥有丰富的经验,但他本人并不是 UI 开发人员。🙂

前置条件

本文的代码位于 GitHub 仓库 中。

该项目是使用以下工具构建的:

  • Gradle 8.5
  • Java 21

使用了标准的 Java 和 Servlet API,应该可以直接工作或轻松移植到较新的 Java 版本。

为了运行此处提供的示例,需要加载 Atlas 示例数据,并在 sample_mflix.movies 集合上创建 movies_index,如下所述。如果您是 Atlas Search 的新手,一个好的起点是 Using Atlas Search from Java

搜索服务设计

前端表现层提供搜索框,渲染搜索结果,并提供排序、分页和过滤控件。中间层通过 HTTP 请求验证并将搜索请求参数转换为聚合管道规范,然后将其发送到数据层。

搜索服务需要快速、可扩展,并处理以下基本参数:

  • 查询本身:这是用户在搜索框中输入的内容。
  • 要返回的结果数量:通常一次只需要 10 个左右的结果。
  • 搜索结果的起始点:这允许对搜索结果进行分页。

此外,高性能查询应该只搜索并返回少量字段,尽管搜索的字段和需要返回的字段不一定相同。例如,在搜索电影时,您可能想要搜索 fullplot 字段,但不返回可能很长的文本用于展示。或者,您可能希望在结果中包含电影发行的年份,但不搜索 year 字段。

此外,搜索服务必须提供一种将搜索结果限制在特定类别、类型或演员阵容的方法,而不影响结果的相关性排序。这种过滤功能也可用于强制执行访问控制,服务层是添加此类约束的理想场所,表现层可以依赖这些约束而无需自行管理。

搜索服务接口

现在让我们根据设计具体定义服务接口。我们的目标是支持一个请求,例如 针对 titleplot 字段查询 "purple rain",查找 "Music" 类型的电影,每次只返回五个结果,且仅包含字段 title、genres、plot 和 year。从我们表现层的角度来看,该请求是以下 HTTP GET 请求:

http://service_host:8080/search?q=purple%20rain&limit=5&skip=0&project=title,genres,plot,year&search=title,plot&filter=genres:Music

这些参数以及一个 debug 参数将在下表中详细说明:

参数描述
q这是一个全文查询,通常是用户在搜索框中输入的值。
search这是一个逗号分隔的字段列表,用于使用查询 (q) 参数进行搜索。
limit仅返回此最大数量的结果,限制最多返回 25 个结果。
skip返回跳过此数量结果后的结果(最多 limit 个结果),最多跳过 100 个结果。
project这是一个逗号分隔的字段列表,用于返回每个文档的字段。如果需要,请添加 _id_score 是一个“伪字段”,用于包含计算出的相关性得分。
filter<字段名>:<精确值> 语法;支持零个或多个 filter 参数。
debug如果为 true,则在响应中也包含完整的聚合管道 .explain() 输出。

返回结果

给定指定的请求,让我们定义响应 JSON 结构,以便在 docs 数组中返回匹配文档的请求字段(project)。此外,搜索服务返回一个 request 部分,显示用于构建 Atlas $search 管道的显式和隐式参数,以及一个 meta 部分,该部分将返回匹配文档的总数。这个结构完全是我们自己的设计,并不打算作为聚合管道响应的直接透传,这允许我们隔离、操作和映射响应,以最大程度地满足我们表现层的需要。

$ cat
{
  "request": {
    "q": "purple rain",
    "skip": 0,
    "limit": 5,
    "search": "title,plot",
    "project": "title,genres,plot,year",
    "filter": [
      "genres:Music"
    ]
  },
  "docs": [
    {
      "plot": "A young musician, tormented by an abusive situation at home, must contend with a rival singer, a burgeoning romance and his own dissatisfied band as his star begins to rise.",
      "genres": [
        "Drama",
        "Music",
        "Musical"
      ],
      "title": "Purple Rain",
      "year": 1984
    },
    {
      "plot": "Graffiti Bridge is the unofficial sequel to Purple Rain. In this movie, The Kid and Morris Day are still competitors and each runs a club of his own. They make a bet about who writes the ...",
      "genres": [
        "Drama",
        "Music",
        "Musical"
      ],
      "title": "Graffiti Bridge",
      "year": 1990
    }
  ],
  "meta": [
    {
      "count": {
        "total": 2
      }
    }
  ]
}
```## 搜索服务实现

代码!这才是关键所在。为了尽可能保持简单,以便我们的实现能适用于所有前端技术,我们正在实现一个使用标准 GET 请求参数并返回易于消化的 JSON 的 HTTP 服务。Java 是我们选择的语言,让我们开始吧。编程是一项带有主观色彩的工作,所以我们承认在 Java 和其他语言中有多种方法可以实现这一点——这里提供一种主观的(且经验丰富的)方法。

要运行此处展示的配置,一个很好的起点是运行文章《[Using Atlas Search from Java](https://www.mongodb.com/developer/products/atlas/atlas-search-java/?utm_campaign=devrel&utm_source=third-party-content&utm_medium=cta&utm_content=search-service-foojay&utm_term=erik.hatcher)》中的示例。一旦运行起来,创建一个名为 `movies_index` 的新索引,并使用以下 JSON 中指定的自定义索引配置:

```json
{
  "analyzer": "lucene.english",
  "searchAnalyzer": "lucene.english",
  "mappings": {
    "dynamic": true,
    "fields": {
      "cast": [
        { "type": "token" },
        { "type": "string" }
      ],
      "genres": [
        { "type": "token" },
        { "type": "string" }
      ]
    }
  }
}

这是实现的骨架,一个标准的 doGet servlet 入口点,用于获取我们指定的所有参数:

$ java
public class SearchServlet extends HttpServlet {
    private MongoCollection<Document> collection;
    private String index_name;
    private Logger logger;

    // ...
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String q = request.getParameter("q");
        String search_fields_value = request.getParameter("search");
        String limit_value = request.getParameter("limit");
        String skip_value = request.getParameter("skip");
        String project_fields_value = request.getParameter("project");
        String debug_value = request.getParameter("debug");
        String[] filters = request.getParameterMap().get("filter");
        // ...
    }
}

请注意,已经定义了一些实例变量,它们在标准的 servlet init 方法中根据 web.xml 部署描述符中指定的值以及 ATLAS_URI 环境变量进行初始化:

$ java
@Override
public void init(ServletConfig config) throws ServletException {
    super.init(config);
    logger = Logger.getLogger(config.getServletName());
    String uri = System.getenv("ATLAS_URI");
    if (uri == null) {
        throw new ServletException("ATLAS_URI must be specified");
    }
    String database_name = config.getInitParameter("database");
    String collection_name = config.getInitParameter("collection");
    index_name = config.getInitParameter("index");
    // ... log the details ...
    MongoClient mongo_client = MongoClients.create(uri);
    MongoDatabase database = mongo_client.getDatabase(database_name);
    collection = database.getCollection(collection_name);
}

为了最大程度地保护我们的 ATLAS_URI 连接字符串,我们在环境中定义它,这样它既不会被硬编码,也不会在应用程序初始化之外可见,而我们在标准的 web.xml 部署描述符中指定数据库、集合和索引名称,这允许我们为想要支持的每个索引定义端点。这是一个基本的 web.xml 定义:

$ xml
<web-app>
  <servlet>
    <servlet-name>SearchServlet</servlet-name>
    <servlet-class>com.mongodb.atlas.SearchServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
    <!-- The connection string must be defined in the `ATLAS_URI` environment variable -->
    <init-param>
      <param-name>database</param-name>
      <param-value>sample_mflix</param-value>
    </init-param>
    <init-param>
      <param-name>collection</param-name>
      <param-value>movies</param-value>
    </init-param>
    <init-param>
      <param-name>index</param-name>
      <param-value>movies_index</param-value>
    </init-param>
  </servlet>
  <servlet-mapping>
    <servlet-name>SearchServlet</servlet-name>
    <url-pattern>/search</url-pattern>
  </servlet-mapping>
</web-app>

获取搜索结果

请求搜索结果是一个无状态操作,对数据库没有副作用,并且作为一个简单的 HTTP GET 请求工作得很好,因为查询本身不应该是一个非常长的字符串。我们的前端层可以适当地限制长度。如果需要,可以通过调整为 POST/getPost 来支持更大的请求。

幕后的聚合管道

最终,为了支持我们要返回的信息(如上面的示例响应所示),上面显示的请求示例被转换为这个聚合管道请求:

$ cat
[
  {
    "$search": {
      "compound": {
        "must": [
          {
            "text": {
              "query": "purple rain",
              "path": [
                "title",
                "plot"
              ]
            }
          }
        ],
        "filter": [
          {
            "equals": {
              "path": "genres",
              "value": "Music"
            }
          }
        ]
      },
      "index": "movies_index",
      "count": {
        "type": "total"
      }
    }
  },
  {
    "$facet": {
      "docs": [
        {
          "$skip": 0
        },
        {
          "$limit": 5
        },
        {
          "$project": {
            "title": 1,
            "genres": 1,
            "plot": 1,
            "year": 1,
            "_id": 0
          }
        }
      ],
      "meta": [
        {
          "$replaceWith": "$$SEARCH_META"
        },
        {
          "$limit": 1
        }
      ]
    }
  }
]

关于这个生成的聚合管道,有几个方面值得进一步解释:

  • 查询 (q) 被转换为指定 search 字段上的 text 操作符。这两个参数在此实现中都是必需的。
  • filter 参数被转换为使用 equals 操作符的非评分 filter 子句。equals 操作符要求将字符串字段索引为 token 类型;这就是为什么你会看到 genrescast 字段被设置为 stringtoken 两种类型。这两个字段可以进行全文搜索(通过 text 或其他支持字符串类型的操作符),也可以用作精确匹配的 equals 过滤器。
  • 在 $search 中请求匹配文档的计数,该计数在 $$SEARCH_META 聚合变量中返回。由于此元数据不是特定于文档的,因此需要特殊处理才能从聚合调用返回到我们的搜索服务器。这就是利用 $facet 阶段的原因,以便将此信息拉入我们服务响应的 meta 部分。

使用 $facet 是一个有点棘手的技巧,这也为我们的聚合管道响应提供了未来扩展的空间。

侧边栏(> markdown)部分:$facet 聚合阶段的名称与 Atlas Search 的 facet 收集器同名,这容易引起混淆。搜索结果分面给出匹配搜索结果中组的标签和该组的计数。例如,对 genres 进行分面(这需要调整此处的索引配置)除了提供符合搜索条件的文档外,还会提供这些搜索结果中所有 genres 的列表以及每个的数量。将 facet 操作符添加到此搜索服务已列入下面提到的路线图。

代码中的 $search

给定查询 (q)、搜索字段列表 (search) 和过滤器(零个或多个 filter 参数),使用 Java 驱动程序的便捷方法以编程方式构建 $search 阶段非常简单:

$ java
// $search
List<SearchPath> search_path = new ArrayList<>();
for (String search_field : search_fields) {
    search_path.add(SearchPath.fieldPath(search_field));
}

CompoundSearchOperator operator = SearchOperator.compound()
    .must(List.of(SearchOperator.text(search_path, List.of(q))));

if (filter_operators.size() > 0)
    operator = operator.filter(filter_operators);

Bson searchStage = search(
    operator,
    searchOptions()
        .option("scoreDetails", debug)
        .index(index_name)
        .count(SearchCount.total())
);

debug=true 时,我们添加了 Atlas Search 的 scoreDetails 功能,允许我们仅在需要时检查详细的 Lucene 评分细节;请求评分细节会对性能造成轻微影响,并且对于我们大多数人来说通常太底层了。

字段投影

我们服务实现的最后一个有趣的部分涉及字段投影。返回 _id 字段或不返回,需要特殊处理。我们的服务代码会检查 project 参数中是否存在 _id,如果未指定则显式将其关闭。我们还添加了一项功能,如果需要,可以通过查看 project 参数中指定的特殊 _score 伪字段来包含文档的计算相关性分数。以编程方式构建投影阶段如下所示:

$ java
List<String> project_fields = new ArrayList<>();
if (project_fields_value != null) {
    project_fields.addAll(List.of(project_fields_value.split(",")));
}

boolean include_id = false;
if (project_fields.contains("_id")) {
    include_id = true;
    project_fields.remove("_id");
}

boolean include_score = false;
if (project_fields.contains("_score")) {
    include_score = true;
    project_fields.remove("_score");
}

// $project
List<Bson> projections = new ArrayList<>();
projections.add(include(project_fields));

if (include_id) {
    projections.add(include("_id"));
} else {
    projections.add(excludeId());
}

if (debug) {
    projections.add(meta("_scoreDetails", "searchScoreDetails"));
}

if (include_score) {
    projections.add(metaSearchScore("_score"));
}

Bson projection = fields(projections);

聚合与响应

在参数处理和阶段构建的最后,非常简单直接,我们构建完整的管道,调用 Atlas,构建 JSON 响应,并将其返回给调用客户端。这里唯一独特的事情是,当 debug=true 时添加 .explain() 调用,以便我们的客户端可以从 Atlas 的角度看到发生了什么的完整图景:

$ java
AggregateIterable<Document> aggregation_results = collection.aggregate(List.of(
    searchStage, facet_stage
));

Document response_doc = new Document();
response_doc.put("request", new Document()
    .append("q", q)
    .append("skip", skip)
    .append("limit", limit)
    .append("search", search_fields_value)
    .append("project", project_fields_value)
    .append("filter", filters==null ? Collections.EMPTY_LIST : List.of(filters)));

if (debug) {
    response_doc.put("debug", aggregation_results.explain().toBsonDocument());
}

// When using $facet stage, only one "document" is returned,
// containing the keys specified above: "docs" and "meta"
Document results = aggregation_results.first();
for (String s : results.keySet()) {
    response_doc.put(s,results.get(s));
}

response.setContentType("text/json");
PrintWriter writer = response.getWriter();
writer.println(response_doc.toJson());
writer.close();

投入生产

这是一个标准的 Java servlet 扩展,旨在 Tomcat、Jetty 或其他符合 servlet API 的容器中运行。构建运行 Gretty,它平稳地允许开发人员运行 jettyRuntomcatRun 来启动此示例 Java 搜索服务。

为了构建可以部署到生产环境的发行版,请运行:

$ bash
./gradlew buildProduct

未来路线图

我们的搜索服务目前对于基本的搜索用例来说足够健壮,但仍有改进空间。以下是服务未来演变的一些想法:

  • 添加否定过滤器。目前,我们支持使用 filter=field:value 参数的肯定过滤器。否定过滤器前面可以有一个减号。例如,为了排除“Drama”(剧情)电影,可以实现支持 filter=-genres:Drama
  • 支持高亮显示,以返回匹配查询词的字段值片段。
  • 实现分面。
  • 等等……请参阅 问题列表 以获取更多想法并添加你自己的想法。

由于服务层是一个中间层,可以独立部署,而不必一定更改前端或数据层,因此其中一些功能可以在不更改这些层的情况下添加。

结论

实现中间层搜索服务提供了许多好处,从安全性到可伸缩性,再到能够隔离更改和部署,而不受表示层和其他搜索客户端的影响。此外,搜索服务允许客户端使用标准的 HTTP 和 JSON 技术轻松利用复杂的搜索功能。

有关将 Java 与 Atlas Search 结合使用的基础知识,请查看 Using Atlas Search from Java | MongoDB。当你开始利用 Atlas Search 时,请务必查看 Query Analytics 功能以帮助改进你的搜索结果。