Hibernate-搜索示例-全-

Hibernate 搜索示例(全)

原文:zh.annas-archive.org/md5/5084F1CE5E9C94A43DE0A69E72C391F6

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

在过去的十年里,用户已经期望在搜索数据时软件能够高度智能。仅仅使搜索不区分大小写、作为子字符串查找关键词或其他基本的 SQL 技巧已经不够了。

如今,当用户在电子商务网站上搜索产品目录时,他或她期望关键词能在所有数据点上进行评估。无论一个术语与电脑的型号号还是书的 ISBN 相匹配,搜索都应该找到所有可能性。为了帮助用户筛选大量结果,搜索应该足够智能,以某种方式按相关性对它们进行排名。

搜索应该能够解析单词并理解它们可能如何相互连接。如果你搜索单词development,那么搜索应该能够理解这个词与developer有关联,尽管这两个单词都不是彼此的子字符串。

最重要的是,搜索应该要友好。当我们在网上论坛中发布东西,把“there”、“they're”和“their”这几个单词弄错了,人们可能只会批评我们的语法。相比之下,搜索应该能够理解我们的拼写错误,并且对此保持冷静!当搜索能够令人愉快地给我们带来惊喜,似乎比我们自己更理解我们在寻找的真实含义时,搜索表现得最好。

这本书的目的是介绍和探索Hibernate Search,这是一个用于向我们的自定义应用程序添加现代搜索功能的软件包,而无需从头开始发明。因为程序员通常通过查看真实代码来学习最佳,所以这本书围绕一个示例应用程序展开。我们将随着书的进展而坚持这个应用程序,并在每个章节中引入新概念时丰富它。

Hibernate Search 是什么?

这个搜索功能的真正大脑是 Apache Lucene,这是一个用于数据索引和搜索的开源软件库。Lucene 是一个有着丰富创新历史的成熟 Java 项目,尽管它也被移植到了其他编程语言中。它被广泛应用于各行各业,从迪士尼到推特的知名用户都采用了它。

Lucene 经常与另一个相关项目 Apache Solr 交替讨论。从一个角度来看,Solr 是基于 Lucene 的独立搜索服务器。然而,依赖关系可以双向流动。Solr 的子组件通常与 Lucene 捆绑在一起,以便在嵌入其他应用程序时增强其功能。

注意

Hibernate Search 是 Lucene 和可选 Solr 组件的薄层封装。它扩展了核心的 Hibernate ORM,这是 Java 持久性最广泛采用的对象/关系映射框架。

下面的图表展示了所有这些组件之间的关系:

Hibernate Search 是什么?

最终,Hibernate Search 扮演两个角色:

  • 首先,它将 Hibernate 数据对象转换为 Lucene 可以用来构建搜索索引的信息

  • 朝着相反的方向前进,它将 Lucene 搜索的结果转换成熟悉的 Hibernate 格式

从一个程序员的角度来看,他或她正以通常的方式使用 Hibernate 映射数据。搜索结果以与正常 Hibernate 数据库查询相同的格式返回。Hibernate Search 隐藏了与 Lucene 的大部分底层管道。

本书涵盖内容

第一章, 你的第一个应用, 直接深入创建一个 Hibernate Search 应用,一个在线软件应用目录。我们将创建一个实体类并为其准备搜索,然后编写一个 Web 应用来执行搜索并显示结果。我们将逐步了解如何设置带有服务器、数据库和构建系统的应用程序,并学习如何用其他选项替换这些组件。

第二章, 映射实体类, 在示例应用程序中添加了更多的实体类,这些类通过注解来展示 Hibernate Search 映射的基本概念。在本章结束时,您将了解如何为 Hibernate Search 使用映射最常见的实体类。

第三章, 执行查询, 扩展了示例应用程序的查询,以使用新的映射。在本章结束时,您将了解 Hibernate Search 查询的最常见用例。到这个阶段,示例应用程序将具备足够的功能,类似于许多 Hibernate Search 生产环境的用途。

第四章, 高级映射, 解释了 Lucene 和 Solr 分析器之间的关系,以及如何为更高级的搜索配置分析器。它还涵盖了在 Lucene 索引中调整字段的权重,以及在运行时确定是否索引实体。在本章结束时,您将了解如何精细调整实体索引。您将品尝到 Solr 分析器框架,并掌握如何自行探索其功能。示例应用程序现在将支持忽略 HTML 标签的搜索,以及查找相关单词的匹配。

第五章, 高级查询, 更深入地探讨了在第第三章,执行查询中介绍的查询概念,解释了如何通过投影和结果转换获得更快的性能。本章探讨了分面搜索,以及原生 Lucene API 的介绍。到本章结束时,您将对 Hibernate Search 提供的查询功能有更坚实的基础。示例市场应用程序现在将使用更轻量级的、基于投影的搜索,并支持按类别组织搜索结果。

第六章,系统配置和索引管理,介绍了 Lucene 索引管理,并提供了一些高级配置选项的概览。本章详细介绍了其中一些更常见的选项,并提供了足够的背景知识,使我们能够独立探索其他选项。在本章结束时,你将能够执行标准的管理任务,对 Hibernate Search 使用的 Lucene 索引进行管理,并理解通过配置选项为 Hibernate Search 提供额外功能的能力。

第七章,高级性能策略,重点关注通过代码以及服务器架构来提高 Hibernate Search 应用程序的运行时性能。在本章结束时,你将能够做出明智的决定,关于如何按需对 Hibernate Search 应用程序进行扩展。

本书需要什么

使用本书中的示例代码,你需要一台安装有 Java 开发工具包(版本 1.6 或更高)的计算机。你还需要安装 Apache Maven,或者安装有 Maven 插件的 Java 集成开发环境(IDE),如 Eclipse。

本书适合谁

本书的目标读者是希望为他们的应用程序添加搜索功能的 Java 开发者。本书的讨论和代码示例假设读者已经具备了 Java 编程的基本知识。对Hibernate ORMJava Persistence APIJPA 2.0)或 Apache Maven 的先验知识会有帮助,但不是必需的。

约定

在本书中,你会发现有几种不同信息的文本样式。以下是一些这些样式的示例及其含义的解释。

文本中的代码词汇如下所示:"id字段被同时注解为@Id@GeneratedValue"。

一段代码如下所示:

public App(String name, String image, String description) {
   this.name = name;
   this.image = image;
   this.description = description;
}

当我们希望引起你对代码块中的某个特定部分的关注时,相关的行或项目被设置为粗体:

@Column(length=1000)
@Field
private String description;

任何命令行输入或输出如下所示:

mvn archetype:generate -DgroupId=com.packpub.hibernatesearch.chapter1 -DartifactId=chapter1 -DarchetypeArtifactId=maven-archetype-webapp 

注意

警告或重要说明以这样的盒子出现。

提示

小贴士和小技巧如下所示。

读者反馈

来自我们读者的反馈总是受欢迎的。让我们知道你对这本书的看法——你喜欢或可能不喜欢的地方。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

如果您想给我们发送一般性反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在消息主题中提及书名。

如果你在某个主题上有专业知识,并且对撰写或贡献书籍感兴趣,请查看我们在www.packtpub.com/authors上的作者指南。

客户支持

既然你已经拥有了一本 Packt 书籍,我们有很多东西可以帮助你充分利用你的购买。

下载示例代码

您可以在 Packt 出版社购买的任何书籍的示例代码文件,可以通过您账户中的www.packtpub.com下载。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support注册,以便将文件直接通过电子邮件发送给您。

勘误表

虽然我们已经尽一切努力确保我们内容的准确性,但是错误在所难免。如果您在我们的书中发现任何错误——可能是文本或代码中的错误——我们将非常感谢您能向我们报告。这样做,您可以节省其他读者的挫折感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/support,选择您的书籍,点击勘误表提交****表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误表部分现有的勘误列表中。

盗版问题

互联网上版权材料的盗版是一个持续存在的问题,涵盖所有媒体。在 Packt,我们对保护我们的版权和许可证非常重视。如果您在互联网上发现我们作品的任何非法副本,无论以何种形式,请立即提供给我们位置地址或网站名称,以便我们可以寻求解决方案。

如果您发现有侵犯版权的材料,请联系我们<copyright@packtpub.com>,并提供涉嫌侵权材料的位置链接。

我们感谢您在保护我们的作者和我们提供有价值内容的能力方面所提供的帮助。

问题反馈

如果您在阅读本书的过程中遇到任何问题,可以通过<questions@packtpub.com>联系我们,我们会尽最大努力解决问题。

第一章:你的第一个应用程序

为了探索Hibernate Search的能力,我们将使用对经典“Java 宠物店”示例应用程序的一个变化。我们版本,“VAPORware Marketplace”,将是一个在线软件应用程序目录。想想苹果、谷歌、微软、Facebook 以及……好吧,现在几乎所有其他公司都在运营这样的商店。

我们的应用程序市场将给我们提供大量以不同方式搜索数据的机会。当然,像大多数产品目录一样,有标题和描述。然而,软件应用程序涉及一组更广泛的数据点,如类型、版本和支持的设备。这些不同的方面将让我们看看 Hibernate Search 提供的许多功能。

在高层次上,在应用程序中整合 Hibernate Search 需要以下三个步骤:

  1. 向你的实体类中添加信息,以便 Lucene 知道如何索引它们。

  2. 在应用程序的相关部分编写一个或多个搜索查询。

  3. 设置你的项目,以便在最初就拥有 Hibernate Search 所需的依赖和配置。

在未来的项目中,在我们有了相当基本的了解之后,我们可能从这个第三个项目点开始。然而,现在,让我们直接进入一些代码!

创建实体类

为了保持简单,我们这个应用程序的第一个版本将只包括一个实体类。这个App类描述了一个软件应用程序,是所有其他实体类都将与之关联的中心实体。不过,现在,我们将给一个“应用程序”提供三个基本数据点:

  • 一个名称

  • marketplace 网站上显示的图片

  • 一段长描述

下面的 Java 代码:

package com.packtpub.hibernatesearch.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class App {

 @Id
 @GeneratedValue
   private Long id;

 @Column
   private String name;

 @Column(length=1000)
   private String description;

 @Column
   private String image;

   public App() {}

   public App(String name, String image, String description) {
      this.name = name;
      this.image = image;
      this.description = description;
   }

   public Long getId() {
      return id;
   }
   public void setId(Long id) {
      this.id = id;
   }
   public String getName() {
      return name;
   }
   public void setName(String name) {
      this.name = name;
   }
   public String getDescription() {
      return description;
   }
   public void setDescription(String description) {
      this.description = description;
   }
   public String getImage() {
      return image;
   }
   public void setImage(String image) {
      this.image = image;
   }
}

这个类是一个基本的普通旧 Java 对象POJO),只有成员变量和用于处理它们的 getter/setter 方法。然而,请注意突出显示的注解。

注意

如果你习惯了 Hibernate 3.x,请注意版本 4.x 废弃了许多 Hibernate 自己的映射注解,转而使用它们的Java 持久化 APIJPA)2.0 对应物。我们将在第三章,执行查询中进一步讨论 JPA。现在,只需注意这里的 JPA 注解与它们的本地 Hibernate 注解基本相同,除了属于javax.persistence包。

该类本身用@Entity注解标记,告诉 Hibernate 将该类映射到数据库表。由于我们没有明确指定一个表名,默认情况下,Hibernate 将为App类创建一个名为APP的表。

id字段被注释为@Id@GeneratedValue。前者简单地告诉 Hibernate 这个字段映射到数据库表的主键。后者声明当新行被插入时值应该自动生成。这就是为什么我们的构造方法不填充id的值,因为我们期待 Hibernate 为我们处理它。

最后,我们用@Column注解我们的三个数据点,告诉 Hibernate 这些变量与数据库表中的列相对应。通常,列名与变量名相同,Hibernate 会关于列长度、是否允许空值等做出一些合理的假设。然而,这些设置可以显式声明(就像我们在这里做的那样),通过将描述的列长度设置为 1,000 个字符。

为 Hibernate Search 准备实体

现在 Hibernate 知道了我们的领域对象,我们需要告诉 Hibernate Search 插件如何用Lucene管理它。

我们可以使用一些高级选项来充分利用 Lucene 的的全部力量,随着这个应用程序的发展,我们会的。然而,在基本场景下使用 Hibernate Search 只需添加两个注解那么简单。

首先,我们将添加@Indexed注解到类本身:

...
import org.hibernate.search.annotations.Indexed;
...
@Entity
@Indexed
public class App implements Serializable {
...

这简单地声明了 Lucene 应该为这个实体类建立并使用索引。这个注解是可选的。当你编写一个大规模的应用程序时,其中许多实体类可能与搜索无关。Hibernate Search 只需要告诉 Lucene 那些可搜索的类型。

其次,我们将用@Field注解声明可搜索的数据点:

...
import org.hibernate.search.annotations.Field;
...
@Id
@GeneratedValue
private Long id;
@Column
@Field
private String name;

@Column(length=1000)
@Field
private String description;

@Column
private String image;
...

注意我们只把这个注解应用到namedescription成员变量上。我们没有注释image,因为我们不在乎通过图片文件名搜索应用程序。同样,我们也没有注释id,因为你要找一个数据库表行通过它的主键,你不需要一个强大的搜索引擎!

注意

决定注解什么是一个判断 call。你注释的索引实体越多,作为字段注释的成员变量越多,你的 Lucene 索引就会越丰富、越强大。然而,如果我们仅仅因为可以就注解多余的东西,那么我们就让 Lucene 做不必要的功,这可能会影响性能。

在第七章,高级性能策略,我们将更深入地探讨这些性能考虑。现在,我们已经准备好通过名称或描述来搜索应用程序。

加载测试数据

为了测试和演示目的,我们将使用一个内嵌数据库,每次启动应用程序时都应该清除并刷新它。在 Java Web 应用程序中,调用启动时间内的代码的一个简单方法是使用ServletContextListener。我们只需创建一个实现此接口的类,并用@WebListener注解它:

package com.packtpub.hibernatesearch.util;

import javax.servlet.ServletContextEvent;
import javax.servlet.annotation.WebListener;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
import org.hibernate.service.ServiceRegistry;
import org.hibernate.service.ServiceRegistryBuilder;
import com.packtpub.hibernatesearch.domain.App;

@WebListener
public class StartupDataLoader implements ServletContextListener {
   /** Wrapped by "openSession()" for thread-safety, and not meant to be accessed directly. */
   private static SessionFactorysessionFactory;

 /** Thread-safe helper method for creating Hibernate sessions. */
   public static synchronized Session openSession() {
      if(sessionFactory == null) {
         Configuration configuration = new Configuration();
         configuration.configure();
         ServiceRegistryserviceRegistry = new
           ServiceRegistryBuilder().applySettings(
              configuration.getProperties()).buildServiceRegistry();
         sessionFactory =
            configuration.buildSessionFactory(serviceRegistry);
      }
      return sessionFactory.openSession();
   }

   /** Code to run when the server starts up. */
   public void contextInitialized(ServletContextEvent event) {
      // TODO: Load some test data into the database
   }

   /** Code to run when the server shuts down. */
   public void contextDestroyed(ServletContextEvent event) {
      if(!sessionFactory.isClosed()) {
         sessionFactory.close();
      }
   }
}

现在,contextInitialized 方法将在服务器启动时自动调用。我们将使用此方法设置 Hibernate 会话工厂,并向数据库填充一些测试数据。contextDestroyed 方法同样会在服务器关闭时自动调用。我们将使用这个方法在完成时显式关闭我们的会话工厂。

我们应用程序中的多个地方将需要一个简单且线程安全的手段来打开到数据库的连接(即,Hibernate Session 对象)。因此,我们还添加了一个名为 openSession()public static synchronized 方法。该方法作为创建单例 SessionFactory 的线程安全守门员。

注意

在更复杂的应用程序中,您可能会使用依赖注入框架,如 Spring 或 CDI。这在我们的小型示例应用程序中有些分散注意力,但这些框架为您提供了一种安全机制,用于无需手动编码即可注入 SessionFactorySession 对象。

在具体化 contextInitialized 方法时,我们首先获取一个 Hibernate 会话并开始一个新事务:

...
Session session = openSession();
session.beginTransaction();
...
App app1 = new App("Test App One", "image.jpg",
 "Insert description here");
session.save(app1);

// Create and persist as many other App objects as you like…
session.getTransaction().commit();
session.close();
...

在事务内部,我们可以通过实例化和持久化 App 对象来创建所有我们想要的数据样本。为了可读性,这里只创建了一个对象。然而,在 www.packtpub.com 可下载的源代码中包含了一个完整的测试示例集合。

编写搜索查询代码

我们的 VAPORware Marketplace 网络应用程序将基于 Servlet 3.0 控制器/模型类,呈现 JSP/JSTL 视图。目标是保持事情简单,这样我们就可以专注于 Hibernate Search 方面。在审阅了这个示例应用程序之后,应该很容易将相同的逻辑适配到 JSF 或 Spring MVC,甚至更新的基于 JVM 的框架,如 Play 或 Grails。

首先,我们将编写一个简单的 index.html 页面,包含一个用户输入搜索关键词的文本框:

<html >
<head>
   <title>VAPORware Marketplace</title>
</head>
<body>
   <h1>Welcome to the VAPORware Marketplace</h1>
   Please enter keywords to search:
   <form action="search" method="post">
      <div id="search">
         <div>
         <input type="text" name="searchString" />
         <input type="submit" value="Search" />
         </div>
      </div>
   </form>
</body>
</html>

这个表单通过 CGI 参数 searchString 收集一个或多个关键词,并将其以相对 /search 路径提交给一个 URL。我们现在需要注册一个控制器 servlet 来响应这些提交:

package com.packtpub.hibernatesearch.servlet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("search")
public class SearchServletextends HttpServlet {
   protected void doPost(HttpServletRequest request,
         HttpServletResponse response) throws ServletException,
         IOException {

      // TODO: Process the search, and place its results on
      // the "request" object

      // Pass the request object to the JSP/JSTL view
      // for rendering
 getServletContext().getRequestDispatcher(
 "/WEB-INF/pages/search.jsp").forward(request, response);
   }

   protected void doGet(HttpServletRequest request,
         HttpServletResponse response) throws ServletException,
         IOException {
      this.doPost(request, response);
   }

}

@WebServlet 注解将这个 servlet 映射到相对 URL /search,这样提交到这个 URL 的表单将调用 doPost 方法。这个方法将处理一个搜索,并将请求转发给一个 JSP 视图进行渲染。

现在,我们来到了问题的核心——执行搜索查询。我们创建了一个 FullTextSession 对象,这是 Hibernate Search 的一个扩展,它用 Lucene 搜索功能包装了一个普通的 Session

...
import org.hibernate.Session;
import org.hibernate.search.FullTextSession;
import org.hibernate.search.Search;
...
Session session = StartupDataLoader.openSession();
FullTextSessionfullTextSession =   
   Search.getFullTextSession(session);
fullTextSession.beginTransaction();
...

现在我们有了 Hibernate Search 会话可以使用,我们可以获取用户的关键词并执行 Lucene 搜索:

...
import org.hibernate.search.query.dsl.QueryBuilder;
...
String searchString = request.getParameter("searchString");

QueryBuilderqueryBuilder =
fullTextSession.getSearchFactory()
   .buildQueryBuilder().forEntity( App.class ).get();
org.apache.lucene.search.QueryluceneQuery =
 queryBuilder
 .keyword()
 .onFields("name", "description")
 .matching(searchString)
   .createQuery();
...

正如其名称所示,QueryBuilder 用于构建涉及特定实体类的查询。在这里,我们为我们的 App 实体创建了一个构建器。

请注意,在前面的代码的第三行中,有一个很长的方法调用链。从 Java 的角度来看,我们是在调用一个方法,在对象返回后调用另一个方法,并重复这个过程。然而,从简单的英语角度来看,这个方法调用链就像一个句子:

构建一个关键词类型的查询,在实体字段"name"和"description"上,匹配"searchString"中的关键词。

这种 API 风格是有意为之的。因为它本身就像是一种语言,所以被称为 Hibernate Search DSL领域特定语言)。如果你曾经使用过 Hibernate ORM 中的条件查询,那么这里的视觉感受对你来说应该是非常熟悉的。

现在我们已经创建了一个org.apache.lucene.search.Query对象,Hibernate Search 在幕后将其转换为 Lucene 搜索。这种魔力是双向的!Lucene 搜索结果可以转换为标准的org.hibernate.Query对象,并且像任何正常的数据库查询一样使用:

...
org.hibernate.Query hibernateQuery =
   fullTextSession.createFullTextQuery(luceneQuery, App.class);
List<App> apps = hibernateQuery.list();
request.setAttribute("apps", apps);
...

使用hibernateQuery对象,我们获取了在搜索中找到的所有App实体,并将它们放在 servlet 请求中。如果你还记得,我们方法的最后一行将这个请求转发到一个search.jsp视图以供显示。

这个 JSP 视图将始于非常基础的内容,使用 JSTL 标签从请求中获取App结果并遍历它们:

<%@ page language="java" contentType="text/html;
   charset=UTF-8" pageEncoding="UTF-8"%>
<%@ tagliburi="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html>
<head>
   <title>VAPORware Marketplace</title>
</head>
<body>
   <h1>Search Results</h1>
   <table>
   <tr>
      <td><b>Name:</b></td>
      <td><b>Description:</b></td>
   </tr>
 <c:forEachvar="app" items="${apps}">
   <tr>
      <td>${app.name}</td>
      <td>${app.description}</td>
   </tr>
   </c:forEach>
</table>
</body>
</html>

选择一个构建系统

到目前为止,我们以某种逆序的方式对待我们的应用程序。我们基本上跳过了初始项目设置,直接进入代码,这样一旦到达那里,所有的管道都会更有意义。

好了,现在我们已经到达目的地!我们需要将所有这些代码整合到一个有序的项目结构中,确保所有的 JAR 文件依赖项都可用,并建立一个运行 Web 应用程序或将其打包为 WAR 文件的过程。我们需要一个项目构建系统。

一种我们不会考虑的方法是全部手动完成。对于一个使用原始 Hibernate ORM 的小型应用程序,我们可能只需要依赖六个半的 JAR 文件。在这个规模上,我们可能会考虑在我们的首选 IDE(例如 Eclipse、NetBeans 或 IntelliJ)中设置一个标准项目。我们可以从 Hibernate 网站获取二进制分发,并手动复制必要的 JAR 文件,让 IDE 从这里开始。

问题是 Hibernate Search 在幕后有很多东西。等你完成了 Lucene 甚至最基本的 Solr 组件的依赖项添加,依赖项列表会被扩大几倍。即使在这里的第一章,我们的非常基础的 VAPORware Marketplace 应用程序已经需要编译和运行超过三十六个 JAR 文件。这些库之间高度相互依赖,如果你升级了它们中的一个,避免冲突可能真的是一场噩梦。

在这个依赖管理级别,使用自动化构建系统来解决这些问题变得至关重要。在本书中的代码示例中,我们将主要使用 Apache Maven 进行构建自动化。

Maven 的两个主要特点是对基本构建的约定优于配置的方法,以及管理项目 JAR 文件依赖的强大系统。只要项目符合标准结构,我们甚至不必告诉 Maven 如何编译它。这被认为是模板信息。另外,当我们告诉 Maven 项目依赖于哪些库和版本时,Maven 会为我们找出整个依赖层次结构。它确定依赖项本身依赖于哪些库,依此类推。为 Maven 创建了标准仓库格式(参见 search.maven.org 获取最大的公共示例),这样常见的库都可以自动检索,而无需寻找它们。

Maven 确实有自己的批评者。默认情况下,它的配置是基于 XML 的,这在最近几年已经不再流行了。更重要的是,当开发者需要做超出模板基础的事情时,有一个学习曲线。他或她必须了解可用的插件、Maven 构建的生命周期以及如何为适当的生命周期阶段配置插件。许多开发者都有过在学习曲线上的沮丧经历。

最近创建了许多其他构建系统,试图以更简单的形式 harness Maven 的相同力量(例如,基于 Groovy 的 Gradle,基于 Scala 的 SBT,基于 Ruby 的 Buildr 等)。然而,重要的是要注意,所有这些新系统仍然设计为从标准 Maven 仓库获取依赖项。如果您希望使用其他依赖管理和构建系统,那么本书中看到的概念将直接适用于这些其他工具。

为了展示一种更加手动、非 Maven 的方法,从 Packt Publishing 网站下载的示例代码包括本章示例应用程序的基于 Ant 的版本。寻找与基于 Maven 的 chapter1 示例对应的子目录 chapter1-ant。这个子目录的根目录中有一个 README 文件,强调了不同之处。然而,主要收获是书中展示的概念应该很容易翻译成任何现代的 Java 应用程序构建系统。

设置项目并导入 Hibernate Search

我们可以使用我们选择的 IDE 创建 Maven 项目。Eclipse 通过可选的 m2e 插件与 Maven 配合使用,NetBeans 使用 Maven 作为其本地构建系统。如果系统上安装了 Maven,您还可以选择从命令行创建项目:

mvn archetype:generate -DgroupId=com.packpub.hibernatesearch.chapter1 -DartifactId=chapter1 -DarchetypeArtifactId=maven-archetype-webapp

在任何情况下,使用 Maven archetype都可以节省时间,archetype基本上是给定项目类型的一个模板。在这里,maven-archetype-webapp为我们提供了一个空白的网络应用程序,配置为打包成 WAR 文件。groupIdartifactId可以是任何我们希望的。如果我们将构建输出存储在 Maven 仓库中,它们将用于识别我们的构建输出。

我们新创建项目的pom.xml Maven 配置文件开始看起来类似于以下内容:

<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
      http://maven.apache.org/xsd/maven-4.0.0.xsd"  

         >

   <modelVersion>4.0.0</modelVersion>
   <groupId>com.packpub.hibernatesearch.chapter1</groupId>
   <artifactId>chapter1</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>chapter1</name>
   <url>http://maven.apache.org</url>

   <dependencies>
      <dependency>
         <groupId>junit</groupId>
         <artifactId>junit</artifactId>
         <version>3.8.1</version>
         <scope>test</scope>
      </dependency>
   </dependencies>

   <build>
 <!-- This controls the filename of the built WAR file -->
      <finalName>vaporware</finalName>
   </build>
</project>

我们首要的任务是声明编译和运行所需的依赖关系。在<dependencies>元素内,让我们添加一个 Hibernate Search 的条目:

...
<dependency>
   <groupId>org.hibernate</groupId>
   <artifactId>hibernate-search</artifactId>
   <version>4.2.0.Final</version>
</dependency>
...

等等,我们之前不是说这需要超过三个小时的依赖项吗?是的,那是真的,但这并不意味着你必须处理它们全部!当 Maven 到达仓库并抓取这个依赖项时,它还将收到有关所有其依赖项的信息。Maven 沿着梯子一路下滑,每一步都解决任何冲突,并计算出一个依赖层次结构,以便您不必这样做。

我们的应用程序需要一个数据库。为了简单起见,我们将使用 H2 (www.h2database.com),一个嵌入式数据库系统,整个系统只有一个 1 MB 的 JAR 文件。我们还将使用Apache Commons 数据库连接池 (commons.apache.org/dbcp)以避免不必要的打开和关闭数据库连接。这些只需要声明每个依赖关系:

...
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <version>1.3.168</version>
</dependency>
<dependency>
  <groupId>commons-dbcp</groupId>
  <artifactId>commons-dbcp</artifactId>
  <version>1.4</version>
</dependency>
...

最后但同样重要的是,我们想要指定我们的网络应用程序正在使用 JEE Servlet API 的 3.x 版本。在下面的依赖关系中,我们将作用域指定为provided,告诉 Maven 不要将这个 JAR 文件打包到我们的 WAR 文件中,因为反正我们期望我们的服务器会提供:

...
<dependency>
  <groupId>javax.servlet</groupId>
  <artifactId>javax.servlet-api</artifactId>
  <version>3.0.1</version>
  <scope>provided</scope>
</dependency>
...

有了我们的 POM 文件完备之后,我们可以将之前创建的源文件复制到我们的项目中。这三个 Java 类列在src/main/java子目录下。src/main/webapp子目录代表我们网络应用程序的文档根。index.html搜索页面及其search.jsp结果对应页面放在这里。下载并检查项目示例的结构。

运行应用程序

运行一个 Servlet 3.0 应用程序需要 Java 6 或更高版本,并且需要一个兼容的 Servlet 容器,如 Tomcat 7。然而,如果您使用嵌入式数据库以使测试和演示更简单,那么为什么不用嵌入式应用程序服务器呢?

Jetty web 服务器 (www.eclipse.org/jetty)有一个非常适合 Maven 和 Ant 的插件,它让开发者可以在不安装服务器的情况下从构建脚本中启动他们的应用程序。Jetty 8 或更高版本支持 Servlet 3.0 规范。

要向您的 Maven POM 中添加 Jetty 插件,请在root元素内插入一小块 XML:

<project>
...
<build>
   <finalName>vaporware</finalName>
   <plugins>
      <plugin>
         <groupId>org.mortbay.jetty</groupId>
         <artifactId>jetty-maven-plugin</artifactId>
         <version>8.1.7.v20120910</version>
 <configuration>
 <webAppConfig>
 <defaultsDescriptor>
 ${basedir}/src/main/webapp/WEB-INF/webdefault.xml
 </defaultsDescriptor>
 </webAppConfig>
 </configuration>
      </plugin>
   </plugins>
</build>
</project>

高亮显示的<configuration>元素是可选的。在大多数操作系统上,在 Maven 启动一个嵌入式 Jetty 实例之后,你可以在不重新启动的情况下立即进行更改并看到它们生效。然而,由于 Microsoft Windows 在处理文件锁定方面的问题,你有时无法在 Jetty 实例运行时保存更改。

所以,如果你正在使用 Windows 并且希望有实时进行更改的能力,那么就复制一份webdefault.xml的定制副本,并将其保存到前面片段中引用的位置。这个文件可以通过下载并使用解压缩工具打开一个jetty-webapp JAR 文件来找到,或者简单地从 Packt Publishing 网站下载这个示例应用程序。对于 Windows 用户来说,关键是要找到useFileMappedBuffer参数,并将其值更改为false

既然你已经有了一个 Web 服务器,那么让我们让它为我们创建和管理一个 H2 数据库。当 Jetty 插件启动时,它将自动寻找文件src/main/webapp/WEB-INF/jetty-env.xml。让我们创建这个文件,并使用以下内容填充它:

<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD
   Configure//EN" "http://jetty.mortbay.org/configure.dtd">

<Configure class="org.eclipse.jetty.webapp.WebAppContext">
   <New id="vaporwareDB" class="org.eclipse.jetty.plus.jndi.Resource">
      <Arg></Arg>
      <Arg>jdbc/vaporwareDB</Arg>
      <Arg>
      <New class="org.apache.commons.dbcp.BasicDataSource">
         <Set name="driverClassName">org.h2.Driver</Set>
         <Set name="url">
 jdbc:h2:mem:vaporware;DB_CLOSE_DELAY=-1
         </Set>
      </New>
      </Arg>
   </New>
</Configure>

这使得 Jetty 生成一个 H2 数据库连接池,JDBC URL 指定的是内存中的数据库,而不是文件系统上的持久数据库。我们将这个数据源以jdbc/vaporwareDB的名称注册到 JNDI 中,所以我们的应用程序可以通过这个名字来访问它。我们在应用程序的src/main/webapp/WEB-INF/web.xml文件中添加一个相应的引用:

<!DOCTYPE web-app PUBLIC
      "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
      "http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app 

      xsi:schemaLocation="http://java.sun.com/xml/ns/javaee   
      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"      
      version="3.0">
   <display-name>VAPORware Marketplace</display-name>
   <resource-ref>
      <res-ref-name>jdbc/vaporwareDB</res-ref-name>
      <res-type>javax.sql.DataSource</res-type>
      <res-auth>Container</res-auth>
   </resource-ref>
</web-app>

最后,我们需要通过一个标准的hibernate.cfg.xml文件将这个数据库资源与 Hibernate 绑定,这个文件我们将创建在src/main/resources目录下:

<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
      "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
      "http://www.hibernate.org/dtd/hibernate-configuration-
      3.0.dtd">
<hibernate-configuration>
   <session-factory>
      <property name="connection.datasource">
         jdbc/vaporwareDB
      </property>
      <property name="hibernate.dialect">
         org.hibernate.dialect.H2Dialect
      </property>
      <property name="hibernate.hbm2ddl.auto">
         update
      </property>
      <property name="hibernate.show_sql">
         false
      </property>
      <property name=hibernate.search.default.directory_provider">
         filesystem
      </property>
      <property name="hibernate.search.default.indexBase">
         target/lucenceIndex
      </property>

      <mapping class=
              "com.packtpub.hibernatesearch.domain.App"/>
   </session-factory>
</hibernate-configuration>

第一行高亮显示的代码将 Hibernate 会话工厂与 Jetty 管理的jdbc/vaporwareDBdata数据源关联起来。最后一行高亮显示的代码将App声明为一个与这个会话工厂绑定的实体类。目前我们只有这个一个实体,但随着后面章节中更多实体的引入,我们将在这里添加更多的<class>元素。

在此之间,<properties>元素的大部分与核心设置相关,这些对于有经验的 Hibernate 用户来说可能很熟悉。然而,高亮显示的属性是针对 Hibernate Search 附加组件的。hibernate.search.default.directory_provider声明我们希望在文件系统上存储我们的 Lucene 索引,而不是在内存中。hibernate.search.default.indexBase指定索引的位置,在我们项目的子目录中,Maven 在构建过程期间会为我们清理这个目录。

好的,我们有一个应用程序,一个数据库,还有一个服务器将这两者结合在一起。现在,我们可以实际部署和启动,通过运行带有jetty:run目标的 Maven 命令来实现:

mvn clean jetty:run

clean目标消除了先前构建的痕迹,然后因为jetty:run的暗示,Maven 组装我们的 Web 应用程序。我们的代码很快被编译,并在localhost:8080上启动了一个 Jetty 服务器:

运行应用程序

我们上线了!现在我们可以使用我们喜欢的任何关键词搜索应用程序。一个小提示:在可下载的示例代码中,所有测试数据记录的描述中都包含单词app

运行应用程序

可下载的示例代码让 HTML 看起来更加专业。它还将在每个应用程序的名称和描述旁边添加应用程序的图片:

运行应用程序

Maven 命令mvn clean package允许我们将应用程序打包成 WAR 文件,因此我们可以将其部署到 Maven Jetty 插件之外的独立服务器上。只要你知道如何为 JNDI 名称jdbc/vaporwareDB设置数据源,就可以使用任何符合 Servlet 3.0 规范的 Java 服务器(例如,Tomcat 7+),所以你都可以这样做。

事实上,你可以将H2替换为你喜欢的任何独立数据库。只需将适当的 JDBC 驱动添加到你的 Maven 依赖项中,并在persistence.xml中更新设置。

摘要

在本章中,我们学习了 Hibernate ORM、Hibernate Search 扩展和底层 Lucene 搜索引擎之间的关系。我们了解了如何将实体和字段映射以使它们可供搜索。我们使用 Hibernate Search DSL 编写跨多个字段的全文搜索查询,并且像处理正常数据库查询一样处理结果。我们使用自动构建过程来编译我们的应用程序,并将其部署到一个带有实时数据库的 Web 服务器上。

仅凭这些工具,我们就可以将 Hibernate Search 立即集成到许多实际应用程序中,使用任何其他服务器或数据库。在下一章中,我们将深入探讨 Hibernate Search 为映射实体对象到 Lucene 索引提供的选项。我们将了解如何处理扩展的数据模型,将我们的 VAPORware 应用程序与设备和客户评论关联起来。

第二章:映射实体类

在第一章,你的第一个应用中,我们使用了核心 Hibernate ORM 来将一个实体类映射到数据库表,然后使用 Hibernate Search 将它的两个字段映射到一个 Lucene 索引。仅凭这一点,就提供了很多搜索功能,如果从头开始编写将会非常繁琐。

然而,实际应用通常涉及许多实体,其中许多应该可供搜索使用。实体可能相互关联,我们的查询需要理解这些关联,这样我们才能一次性搜索多个实体。我们可能希望声明某些映射对于搜索来说比其他映射更重要,或者在某些条件下我们可能希望跳过索引数据。

在本章中,我们将开始深入探讨 Hibernate Search 为映射实体提供的选项。作为一个第一步,我们必须查看 Hibernate ORM 中的 API 选项。我们如何将实体类映射到数据库,这将影响 Hibernate Search 如何将它们映射到 Lucene。

选择 Hibernate ORM 的 API

当 Hibernate Search 文档提到 Hibernate ORM 的不同 API 时,可能会令人困惑。在某些情况下,这可能指的是是否使用 org.hibernate.Session 或者 javax.persistence.EntityManager 对象(下一章的重要部分)来执行数据库查询。然而,在实体映射的上下文中,这指的是 Hibernate ORM 提供的三种不同的方法:

  • 使用经典 Hibernate 特定注解的基于注解的映射

  • 使用 Java 持久化 API(JPA 2.0)的基于注解的映射

  • 使用 hbm.xml 文件的基于 XML 的映射

如果你只使用过 Hibernate ORM 的经典注解或基于 XML 的映射,或者如果你是 Hibernate 的新手,那么这可能是你第一次接触到 JPA。简而言之,JPA 是一个规范,旨在作为对象关系映射和其他类似功能的官方标准。

想法是提供 ORM 所需的类似于 JDBC 提供的低级数据库连接。一旦开发者学会了 JDBC,他们就可以快速使用任何实现 API 的数据库驱动程序(例如,Oracle、PostgreSQL、MySQL 等)。同样,如果你理解了 JPA,那么你应该能够轻松地在 Hibernate、EclipseLink 和 Apache OpenJPA 等 ORM 框架之间切换。

实际上,不同的实现通常有自己的怪癖和专有扩展,这可能会导致过渡性头痛。然而,一个共同的标准可以大大减少痛苦和学习曲线。

使用 Hibernate ORM 原生 API 与使用 JPA 进行实体映射的比较如下图所示:

选择 Hibernate ORM 的 API

对长期使用 Hibernate 的开发人员来说好消息是,JPA 实体映射注解与 Hibernate 自己的注解非常相似。实际上,Hibernate 的创始人参与了 JPA 委员会的开发,这两个 API 相互之间有很强的影响。

取决于你的观点,不那么好的消息是 Hibernate ORM 4.x 弃用自己的映射注解,以支持其 JPA 对应物。这些较旧的注解计划在 Hibernate ORM 5.x 中删除。

提示

如今使用这种已弃用的方法编写新代码没有意义,因此我们将忽略 Hibernate 特定的映射注解。

第三种选择,基于 XML 的映射,在遗留应用程序中仍然很常见。它正在失去青睐,Hibernate Search 文档甚至开玩笑说 XML 不适合 21 世纪!当然,这有点开玩笑,考虑到基本的 Hibernate 配置仍然存储在hibernate.cfg.xmlpersistence.xml文件中。尽管如此,大多数 Java 框架的趋势很明显,对于与特定类绑定的配置使用注解,对于全局配置使用某种形式的文本文件。

即使你使用hbm.xml文件将实体映射到数据库,你仍然可以使用 Hibernate Search 注解将这些实体映射到 Lucene 索引。这两个完全兼容。如果你想在最小努力的情况下将 Hibernate Search 添加到遗留应用程序中,或者即使在开发新应用程序时也有哲学上的偏好使用hbm.xml文件,这很方便。

本章包含 VAPORware Marketplace 应用程序的三种版本示例代码:

  • chapter2子目录继续第一章, 你的第一个应用程序的讲解,使用 JPA 注解将实体同时映射到数据库和 Lucene。

  • chapter2-xml子目录是相同代码的一个变体,修改为将基于 XML 的数据库映射与基于 JPA 的 Lucene 映射混合。

  • chapter2-mapping子目录使用一个特殊的 API 来完全避免注解。这在本章末尾的程序化映射 API部分中进一步讨论。

你应该详细探索这些示例代码,以了解可用的选项。然而,除非另有说明,本书中的代码示例将重点介绍使用 JPA 注解对数据库和 Lucene 进行映射。

注意

当使用 JPA 注解进行数据库映射时,Hibernate Search 会自动为用@Id注解的字段创建一个 Lucene 标识符。

出于某种原因,Hibernate Search 无法与 Hibernate ORM 自身的映射 API 相同。因此,当你不使用 JPA 将实体映射到数据库时,你也必须在应该用作 Lucene 标识符的字段上添加@DocumentId注解(在 Lucene 术语中,实体被称为文档)。

字段映射选项

在第一章你的第一个应用中,我们看到了 Hibernate 管理的类上的成员变量可以通过@Field注解变得可搜索。Hibernate Search 会将关于注解字段的信息放入一个或多个 Lucene 索引中,使用一些合理的默认值。

然而,你可以以无数种方式自定义索引行为,其中一些是@Field注解本身的可选元素。本书将进一步探讨这些元素,但在这里我们将简要介绍它们:

  • analyze:这告诉 Lucene 是存储字段数据原样,还是将其进行分析、解析,并以各种方式处理。它可以设置为Analyze.YES(默认)或Analyze.NO。我们将在第三章执行查询中再次看到这一点。

  • index:这控制是否由 Lucene 索引字段。它可以设置为Index.YES(默认)或Index.NO。在第五章高级查询中介绍基于投影的搜索后,使用@Field注解但不索引字段听起来可能没有意义,但这将更有意义。

  • indexNullAs:这声明了如何处理空字段值。默认情况下,空值将被简单忽略并从 Lucene 索引中排除。然而,在第四章高级映射中,你可以强制将空字段索引化为某个默认值。

  • name:这是一个自定义名称,用于描述字段在 Lucene 索引中的名称。默认情况下,Hibernate Search 将使用注解的成员变量的名称。

  • norms:这决定了是否存储用于提升(boosting)或调整搜索结果默认相关性的索引时间信息。它可以设置为Norms.YES(默认)或Norms.NO。索引时间提升将在第四章高级映射中介绍。

  • store:通常,字段以优化搜索的方式进行索引,但这可能不允许以原始形式检索数据。此选项使原始数据以这种方式存储,以至于你可以在稍后直接从 Lucene 而不是数据库中检索它。它可以设置为Store.NO(默认)、Store.YESStore.COMPRESS。我们将在第五章高级查询中与基于投影的搜索一起使用这个选项。

相同字段的多重映射

有时,你需要用一组选项对字段进行某些操作,用另一组选项进行其他操作。我们将在第三章执行查询中看到这一点,当我们使一个字段既可搜索又可排序。

暂时先说这么多,你可以在同一个字段上有尽可能多的自定义映射。只需包含多个 @Field 注解,用复数的 @Fields 包裹起来即可:

...
@Column
@Fields({
   @Field,
   @Field(name="sorting_name", analyze=Analyze.NO)
})
private String name;
...

现在不用担心这个例子。只需注意,当你为同一个字段创建多个映射时,你需要通过 name 元素给它们赋予不同的名称,这样你以后才能正确引用。

数值字段映射

在第一章,你的第一个应用程序中,我们的实体映射示例仅涉及字符串属性。同样,使用相同的 @Field 注解与其他基本数据类型也是完全没问题的。

然而,这种方式映射的字段被 Lucene 以字符串格式索引。这对于我们稍后探讨的技术(如排序和范围查询)来说非常低效。

为了提高此类操作的性能,Hibernate Search 提供了一个用于索引数值字段的特殊数据结构。当映射 IntegerLongFloatDouble(或它们的原始类型)类型的字段时,此选项是可用的。

要为数值字段使用这个优化的数据结构,你只需在正常的 @Field 注解之外添加 @NumericField 注解。作为一个例子,让我们在 VAPORware Marketplace 应用程序的 App 实体中添加一个价格字段:

...
@Column
@Field
@NumericField
private float price;
...

如果你将此注解应用于已经多次映射到 @Fields 的属性,你必须指定哪个映射应使用特殊的数据结构。这通过给 @NumericField 注解一个可选的 forField 元素来实现,该元素设置为所需 @Field 的相同名称。

实体间的关系

每当一个实体类被 @Indexed 注解标记时,默认情况下 Hibernate Search 将为该类创建一个 Lucene 索引。我们可以有尽可能多的实体和单独的索引。然而,单独搜索每个索引将是一种非常笨拙和繁琐的方法。

大多数 Hibernate ORM 数据模型已经捕捉了实体类之间的各种关联。当我们搜索实体的 Lucene 索引时,Hibernate Search 难道不应该跟随这些关联吗?在本节中,我们将了解如何使其这样做。

关联实体

到目前为止,我们示例应用程序中的实体字段一直是很简单的数据类型。App 类代表了一个名为 APP 的表,它的成员变量映射到该表的列。现在让我们添加一个复杂类型的字段,用于关联第二个数据库表的一个外键。

在线应用商店通常支持一系列不同的硬件设备。因此,我们将创建一个名为 Device 的新实体,代表有 App 实体可用的设备。

@Entity
public class Device {

   @Id
   @GeneratedValue
   private Long id;

   @Column
   @Field
   private String manufacturer;

   @Column
   @Field
   private String name;

 @ManyToMany(mappedBy="supportedDevices",
 fetch=FetchType.EAGER,
 cascade = { CascadeType.ALL }
 )
 @ContainedIn
 private Set<App> supportedApps;

   public Device() {
   }

   public Device(String manufacturer, String name,
         Set<App>supportedApps) {
      this.manufacturer = manufacturer;
      this.name = name;
      this.supportedApps = supportedApps;
   }

   //
   // Getters and setters for all fields...
   //

}

此类的大多数细节应该从第一章 你的第一个应用程序 中熟悉。Device@Entity注解标记,因此 Hibernate Search 将为它创建一个 Lucene 索引。实体类包含可搜索的设备名称和制造商名称字段。

然而,supportedApps成员变量引入了一个新注解,用于实现这两个实体之间的双向关联。一个App实体将包含一个它所支持的所有设备的列表,而一个Device实体将包含一个它所支持的所有应用的列表。

提示

如果没有其他原因,使用双向关联可以提高 Hibernate Search 的可靠性。

Lucene 索引包含来自关联实体的非规范化数据,但这些实体仍然主要与它们自己的 Lucene 索引相关联。长话短说,当两个实体的关联是双向的,并且变化被设置为级联时,那么当任一实体发生变化时,您都可以确信两个索引都会被更新。

Hibernate ORM 参考手册描述了几种双向映射类型和选项。在这里我们使用@ManyToMany,以声明AppDevice实体之间的多对多关系。cascade元素被设置以确保此端关联的变化正确地更新另一端。

注意

通常,Hibernate 是“懒加载”的。它实际上直到需要时才从数据库中检索关联实体。

然而,这里我们正在编写一个多层应用程序,当我们的搜索结果 JSP 接收到这些实体时,控制器 servlet 已经关闭了 Hibernate 会话。如果视图尝试在会话关闭后检索关联,将会发生错误。

这个问题有几个解决方法。为了简单起见,我们还在@ManyToMany注解中添加了一个fetch元素,将检索类型从“懒加载”更改为“ eager”。现在当我们检索一个 Device 实体时,Hibernate 会在会话仍然开启时立即获取所有关联的App实体。

然而,在大量数据的情况下,积极检索是非常低效的,因此,在第五章 高级查询 中,我们将探讨一个更高级的策略来处理这个问题。

迄今为止,关于supportedApps的一切都是在 Hibernate ORM 的范畴内。所以最后但并非最不重要的是,我们将添加 Hibernate Search 的@ContainedIn注解,声明App的 Lucene 索引应包含来自Device的数据。Hibernate ORM 已经将这两个实体视为有关联。Hibernate Search 的@ContainedIn注解也为 Lucene 设置了双向关联。

双向关联的另一面涉及向App实体类提供一个支持Device实体类的列表。

...
@ManyToMany(fetch=FetchType.EAGER, cascade = { CascadeType.ALL })
@IndexedEmbedded(depth=1)
private Set<Device>supportedDevices;
...
// Getter and setter methods
...

这与关联的Device方面非常相似,不同之处在于这里的@IndexedEmbedded注解是@ContainedIn的反向。

注意

如果你的关联对象本身就包含其他关联对象,那么你可能会索引比你想要的更多的数据。更糟糕的是,你可能会遇到循环依赖的问题。

为了防止这种情况,将@IndexEmbedded注解的可选depth元素设置为一个最大限制。在索引对象时,Hibernate Search 将不会超过指定层数。

之前的代码指定了一层深度。这意味着一个应用将带有关于它支持设备的信息进行索引,但不包括设备支持的其他应用的信息。

查询关联实体

一旦为 Hibernate Search 映射了关联实体,它们很容易被包含在搜索查询中。以下代码片段更新了SearchServlet以将supportedDevices添加到搜索字段列表中:

...
QueryBuilderqueryBuilder =
fullTextSession.getSearchFactory().buildQueryBuilder()
      .forEntity(App.class ).get();
org.apache.lucene.search.QueryluceneQuery = queryBuilder
   .keyword()
 .onFields("name", "description", "supportedDevices.name")
   .matching(searchString)
   .createQuery();
org.hibernate.QueryhibernateQuery =
   fullTextSession.createFullTextQuery(luceneQuery, App.class);
...

复杂类型与我们迄今为止处理过的简单数据类型略有不同。对于复杂类型,我们实际上并不太关心字段本身,因为字段实际上只是一个对象引用(或对象引用的集合)。

我们真正希望搜索匹配的是复杂类型中的简单数据类型字段。换句话说,我们希望搜索Device实体的name字段。因此,只要关联类字段已被索引(即使用@Field注解),它就可以使用[实体字段].[嵌套字段]格式进行查询,例如之前的代码中的supportedDevices.name

在本章的示例代码中,StartupDataLoader已经扩展以在数据库中保存一些Device实体并将它们与App实体关联。这些测试设备中的一个名为 xPhone。当我们运行 VAPORware Marketplace 应用并搜索这个关键词时,搜索结果将包括与 xPhone 兼容的应用,即使这个关键词没有出现在应用的名称或描述中。

嵌入对象

关联实体是完整的实体。它们通常对应自己的数据库表和 Lucene 索引,并且可以独立于它们的关联存在。例如,如果我们删除了在 xPhone 上支持的应用实体,那并不意味着我们想要删除 xPhone 的Device

还有一种不同的关联类型,其中关联对象的生存周期取决于包含它们的实体。如果 VAPORware Marketplace 应用有客户评论,并且一个应用从数据库中被永久删除,那么我们可能期望与它一起删除所有客户评论。

注意

经典 Hibernate ORM 术语将这些对象称为组件(有时也称为元素)。在新版 JPA 术语中,它们被称为嵌入对象

嵌入对象本身并不是实体。Hibernate Search 不会为它们创建单独的 Lucene 索引,并且它们不能在没有包含它们的实体的上下文中被搜索。否则,它们在外观和感觉上与关联实体非常相似。

让我们给示例应用程序添加一个客户评论的嵌入对象类型。CustomerReview实例将包括提交评论的人的用户名,他们给出的评分(例如,五颗星),以及他们写的任何附加评论。

@Embeddable
public class CustomerReview {

 @Field
   private String username;

   private int stars;

 @Field
   private String comments;

   publicCustomerReview() {
   }

   public CustomerReview(String username,
         int stars, String comments) {
      this.username = username;
      this.stars = stars;
      this.comments = comments;
   }

   // Getter and setter methods...

}

这个类被注解为@Embeddable而不是通常的@Entity注解,告诉 Hibernate ORMCustomerReview实例的生命周期取决于包含它的哪个实体对象。

@Field注解仍然应用于可搜索的字段。然而,Hibernate Search 不会为CustomerReview创建独立的 Lucene 索引。这个注解只是向包含这个嵌入类其他实体的索引中添加信息。

在我们的案例中,包含类将是App。给它一个客户评论作为成员变量:

...
@ElementCollection(fetch=FetchType.EAGER)
@Fetch(FetchMode.SELECT)
@IndexedEmbedded(depth=1)
private Set<CustomerReview>customerReviews;
...

而不是使用通常的 JPA 关系注解(例如,@OneToOne@ManyToMany等),此字段被注解为 JPA @ElementCollection。如果这个字段是一个单一对象,则不需要任何注解。JPA 会简单地根据该对象类具有@Embeddable注解来推断出来。然而,当处理嵌入元素的集合时,需要@ElementCollection注解。

提示

当使用基于经典 XML 的 Hibernate 映射时,hbm.xml文件等效物是<component>用于单个实例,<composite-element>用于集合。请参阅可下载示例应用程序源代码的chapter2-xml变体。

@ElementCollection注解有一个fetch元素设置为使用 eager fetching,原因与本章前面讨论的原因相同。

在下一行,我们使用 Hibernate 特定的@Fetch注解,以确保通过多个SELECT语句而不是单个OUTER JOIN来获取CustomerReview实例。这避免了由于 Hibernate ORM 的怪癖而在下载源代码中的注释中进一步讨论而导致的客户评论重复。不幸的是,当处理非常大的集合时,这种模式效率低下,因此在这种情况下你可能希望考虑另一种方法。

查询嵌入对象与关联实体相同。以下是从SearchServlet中修改的查询代码片段,以针对嵌入的CustomerReview实例的注释字段进行搜索:

...
QueryBuilderqueryBuilder =
fullTextSession.getSearchFactory().buildQueryBuilder()
   .forEntity(App.class ).get();
org.apache.lucene.search.QueryluceneQuery = queryBuilder
   .keyword()
   .onFields("name", "description", "supportedDevices.name",
      "customerReviews.comments")
   .matching(searchString)
   .createQuery();
org.hibernate.QueryhibernateQuery = fullTextSession.createFullTextQuery(
   luceneQuery, App.class);
...

现在我们有一个真正进行搜索的查询!chapter2版本的StartupDataLoader已扩展以加载所有测试应用的客户评论。当在客户评论中找到匹配项时,搜索现在将产生结果,尽管关键词本身没有出现在App中。

市场应用中的 VAPORware HTML 也得到了更新。现在每个搜索结果都有一个完整详情按钮,它会弹出一个包含支持设备和对该应用的客户评论的模态框。注意在这个截图中,搜索关键词是与客户评论相匹配,而不是与实际的应用描述相匹配:

嵌入对象

部分索引

关联实体每个都有自己的 Lucene 索引,并在彼此的索引中存储一些数据。对于嵌入对象,搜索信息存储在专有的包含实体的索引中。

然而,请注意,这些类可能在不止一个地方被关联或嵌入。例如,如果你的数据模型中有CustomerPublisher实体,它们可能都有一个Address类型的嵌入对象。

通常,我们使用@Field注解来告诉 Hibernate Search 哪些字段应该被索引和搜索。但是,如果我们想要这个字段随着相关或嵌入的对象而变化呢?如果我们想要一个字段根据包含它的其他实体是否被索引呢?Hibernate Search 通过@IndexedEmbedded注解的可选元素提供了这种能力。这个includePaths元素表明在这个包含实体的 Lucene 索引中,只应该包含关联实体或嵌入对象的某些字段。

在我们的示例应用程序中,CustomerReview类将其usernamecomments变量都注解为可搜索的字段。然而,假设对于App内的customerReviews,我们只关心在评论上进行搜索。App的变化如下所示:

...
@ElementCollection(fetch=FetchType.EAGER)
@Fetch(FetchMode.SELECT)
@IndexedEmbedded(depth=1, includePaths = { "comments" })
private Set<CustomerReview>customerReviews;
...

尽管CustomerReview.username被注解为@Field,但这个字段不会添加到App的 Lucene 索引中。这节省了空间,通过不必要的索引来提高性能。唯一的权衡是,为了防止错误,我们必须记得在我们的查询代码中避免使用任何未包含的字段。

程序化映射 API

在本章开头,我们说过,即使你使用hbm.xml文件将实体映射到数据库,你仍然可以使用 Hibernate Search 注解映射到 Lucene。然而,如果你真的想完全避免在实体类中放置注解,有一个 API 可以在运行时以程序化的方式声明你的 Lucene 映射。

如果你需要在运行时根据某些情况更改搜索配置,这可能会有所帮助。这也是如果你不能出于某种原因更改实体类,或者如果你是坚定的配置与 POJO 分离主义者,这是唯一可用的方法。

程序化映射 API 的核心是SearchMapping类,它存储了通常从注解中提取的 Hibernate Search 配置。典型的使用方式看起来像我们在前一章看到的查询 DSL 代码。你在SearchMapping对象上调用一个方法,然后调用返回对象上的方法,以此类推,形成一个长长的嵌套系列。

每一步可用的方法都直观地类似于你已经见过的搜索注解。entity()方法替代了@Entity注解,indexed()替代了@Indexedfield()替代了@Field,等等。

提示

如果你需要在应用程序中使用程序化映射 API,那么你可以在www.hibernate.org/subprojects/search/docs找到更多详细信息,该链接提供了参考手册Javadocs,它们都可供查阅。

在 Javadocs 的起点是org.hibernate.search.cfg.SearchMapping类,其他相关的类也都位于org.hibernate.search.cfg包中。

从 Packt Publishing 网站下载的源代码中,chapter2-mapping子目录包含了一个使用程序化映射 API 的 VAPORware Marketplace 应用程序版本。

这个示例应用的版本包含一个工厂类,其中有一个方法根据需求配置并返回一个SearchMapping对象。无论你给这个类或方法起什么名字,只要这个方法用@org.hibernate.search.annotations.Factory注解标记即可:

public class SearchMappingFactory {

 @Factory
 public SearchMapping getSearchMapping() {

      SearchMapping searchMapping = new SearchMapping();

      searchMapping
         .entity(App.class)
            .indexed()
            .interceptor(IndexWhenActiveInterceptor.class)
            .property("id", ElementType.METHOD).documentId()
            .property("name", ElementType.METHOD).field()
            .property("description", ElementType.METHOD).field()
            .property("supportedDevices",
               ElementType.METHOD).indexEmbedded().depth(1)
            .property("customerReviews",
               ElementType.METHOD).indexEmbedded().depth(1)

         .entity(Device.class)
            .property("manufacturer", ElementType.METHOD).field()
            .property("name", ElementType.METHOD).field()
            .property("supportedApps",   
               ElementType.METHOD).containedIn()
         .entity(CustomerReview.class)
            .property("stars", ElementType.METHOD).field()
            .property("comments", ElementType.METHOD).field();

      return searchMapping;
   }

}

请注意,这个工厂方法严格来说只有三行长。它的主要部分是一个从SearchMapping对象开始的连续一行链式方法调用,这个调用将我们的三个持久化类映射到 Lucene。

为了将映射工厂集成到 Hibernate Search 中,我们在主要的hibernate.cfg.xml配置文件中添加了一个属性:

...
<property name="hibernate.search.model_mapping">
   com.packtpub.hibernatesearch.util.SearchMappingFactory
</property>
...

现在,无论何时 Hibernate ORM 打开一个Session,Hibernate Search 以及所有的 Lucene 映射都会随之而来!

总结

在本章中,我们扩展了如何为搜索映射类的知识。现在,我们可以使用 Hibernate Search 将实体和其他类映射到 Lucene,无论 Hibernate ORM 如何将它们映射到数据库。如果我们任何时候需要将类映射到 Lucene 而不添加注解,我们可以在运行时使用程序化映射 API 来处理。

我们现在已经知道了如何跨相关实体以及其生命周期依赖于包含实体的嵌入对象管理 Hibernate Search。在这两种情况下,我们都涵盖了一些可能会让开发者绊倒的隐蔽怪癖。最后,我们学习了如何根据包含它们的实体来控制关联或嵌入类的哪些字段被索引。

在下一章中,我们将使用这些映射来处理各种搜索查询类型,并探索它们都共有的重要特性。

第三章:执行查询

在上一章中,我们创建了各种类型的持久化对象,并将它们以各种方式映射到 Lucene 搜索索引中。然而,到目前为止,示例应用程序的所有版本基本上都使用了相同的关键词查询。

在本章中,我们将探讨 Hibernate Search DSL 提供的其他搜索查询类型,以及所有它们共有的重要特性,如排序和分页。

映射 API 与查询 API

到目前为止,我们已经讨论了使用 Hibernate ORM 将类映射到数据库的各种 API 选项。你可以使用 XML 或注解来映射你的类,运用 JPA 或传统的 API,只要注意一些细微的差异,Hibernate Search 就能正常工作。

然而,当我们谈论一个 Hibernate 应用程序使用哪个 API 时,答案有两个部分。不仅有一个以上的方法将类映射到数据库,还有运行时查询数据库的选项。Hibernate ORM 有其传统的 API,基于SessionFactorySession类。它还提供了一个对应 JPA 标准的实现,围绕EntityManagerFactoryEntityManager构建。

你可能会注意到,在迄今为止的示例代码中,我们一直使用 JPA 注解将类映射到数据库,并使用传统的 Hibernate Session类来查询它们。这可能一开始看起来有些令人困惑,但映射和查询 API 实际上是可互换的。你可以混合使用!

那么,在 Hibernate Search 项目中你应该使用哪种方法呢?尽可能坚持常见标准是有优势的。一旦你熟悉了 JPA,这些技能在你从事使用不同 JPA 实现的其他项目时是可以转移的。

另一方面,Hibernate ORM 的传统 API 比通用的 JPA 标准更强大。此外,Hibernate Search 是 Hibernate ORM 的扩展。在没有找到其他的搜索策略之前,你不能将一个项目迁移到一个不同的 JPA 实现。

注意

所以简而言之,尽可能使用 JPA 标准的论据是很强的。然而,Hibernate Search 本来就需要 Hibernate ORM,所以过于教条是没有意义的。在这本书中,大多数示例代码将使用 JPA 注解来映射类,并使用传统的 Hibernate Session类来进行查询。

使用 JPA 进行查询

虽然我们将重点放在传统的查询 API 上,但可下载的源代码还包含一个不同版本的示例应用程序,在chapter3-entitymanager文件夹中。这个 VAPORware Marketplace 变体展示了 JPA 全面使用的情况,用于映射和查询。

在搜索控制器 servlet 中,我们没有使用 Hibernate SessionFactory对象来创建Session对象,而是使用 JPA EntityManagerFactory实例来创建EntityManager对象:

...
// The "com.packtpub.hibernatesearch.jpa" identifier is declared
// in "META-INF/persistence.xml"
EntityManagerFactory entityManagerFactory =
   Persistence.createEntityManagerFactory(
   "com.packtpub.hibernatesearch.jpa");
EntityManager entityManager =
   entityManagerFactory.createEntityManager();
...

我们已经看到了使用传统查询 API 的代码示例。在之前的示例中,Hibernate ORM 的Session对象被包裹在 Hibernate Search 的FullTextSession对象中。这些然后生成了实现核心org.hibernate.Query接口的 Hibernate SearchFullTextQuery对象:

...
FullTextSession fullTextSession = Search.getFullTextSession(session);
...
org.hibernate.search.FullTextQuery hibernateQuery =
   fullTextSession.createFullTextQuery(luceneQuery, App.class);
...

与 JPA 相比,常规的EntityManager对象同样被FullTextEntityManager对象包装。这些创建了实现标准javax.persistence.Query接口的FullTextQuery对象:

...
FullTextEntityManager fullTextEntityManager =
      org.hibernate.search.jpa.Search.getFullTextEntityManager(
      entityManager);
...
org.hibernate.search.jpa.FullTextQuery jpaQuery =
      fullTextEntityManager.createFullTextQuery(luceneQuery, App.class);
...

传统的FullTextQuery类及其 JPA 对应类非常相似,但它们是来自不同 Java 包的分开的类。两者都提供了大量我们迄今为止所看到的 Hibernate Search 功能的钩子,并将进一步探索。

小贴士

任何FullTextQuery版本都可以被强制转换为其相应的查询类型,尽管这样做会失去对 Hibernate Search 方法的直接访问。所以,在转换之前一定要调用任何扩展方法。

如果你在将 JPA 查询强制转换后需要访问非标准方法,那么你可以使用该接口的unwrap()方法回到底层的FullTextQuery实现。

为 Hibernate Search 和 JPA 设置项目

当你的基于 Maven 的项目包含了hibernate-search依赖时,它会自动为你拉取三十多个相关依赖。不幸的是,JPA 查询支持并不是其中之一。为了使用 JPA 风格的查询,我们必须自己声明一个额外的hibernate-entitymanager依赖。

它的版本需要与已经在依赖层次中hibernate-core的版本匹配。这不会总是与hibernate-search版本同步。

你的 IDE 可能提供了一种以视觉方式展示依赖层次的方法。无论如何,你总是可以用命令行 Maven 来用这个命令得到相同的信息:

mvn dependency:tree

为 Hibernate Search 和 JPA 设置项目

如本输出所示,Hibernate Search 4.2.0.Final 使用核心 Hibernate ORM 4.1.9.Final 版本。因此,应该在 POM 中添加一个hibernate-entitymanager依赖,使用与核心相同的版本:

...
<dependency>
   <groupId>org.hibernate</groupId>
   <artifactId>hibernate-entitymanager</artifactId>
   <version>4.1.9.Final</version>
</dependency>
...

Hibernate Search DSL

第一章, 你的第一个应用程序, 介绍了 Hibernate Search DSL,这是编写搜索查询的最直接方法。在使用 DSL 时,方法调用是以一种类似于编程语言的方式链接在一起的。如果你有在 Hibernate ORM 中使用标准查询的经验,那么这种风格会看起来非常熟悉。

无论你是使用传统的FullTextSession对象还是 JPA 风格的FullTextEntityManager对象,每个都传递了一个由QueryBuilder类生成的 Lucene 查询。这个类是 Hibernate Search DSL 的起点,并提供了几种 Lucene 查询类型。

关键字查询

我们已经简要了解的最基本的搜索形式是关键词查询。正如名称所暗示的,这种查询类型搜索一个或多个特定的单词。

第一步是获取一个QueryBuilder对象,该对象配置为对给定实体进行搜索:

...
QueryBuilderqueryBuilder =
   fullTextSession.getSearchFactory().buildQueryBuilder()
      .forEntity(App.class ).get();
...

从那里,以下图表描述了可能的流程。虚线灰色箭头代表可选的侧路径:

关键词查询

关键词查询流程(虚线灰色箭头代表可选路径)

在实际的 Java 代码中,关键词查询的 DSL 将类似于以下内容:

...
org.apache.lucene.search.Query luceneQuery =
 queryBuilder
 .keyword()
 .onFields("name", "description", "supportedDevices.name",
         "customerReviews.comments")
 .matching(searchString)
 .createQuery();
...

onField方法采用一个索引了相关实体的字段名称。如果该字段不包括在那个 Lucene 索引中,那么查询将失败。还可以搜索相关或内嵌对象字段,使用"[container-field-name].[field-name]"格式(例如,supportedDevices.name)。

选择性地,可以使用一个或多个andField方法来搜索多个字段。它的参数与onField完全一样工作。或者,您可以一次性通过onFields声明多个字段,如前面的代码片段所示。

匹配方法采用要进行查询的关键词。这个值通常是一个字符串,尽管从技术上讲,参数类型是一个泛型对象,以防您使用字段桥(下一章讨论)。假设您传递了一个字符串,它可能是一个单独的关键词或由空白字符分隔的一系列关键词。默认情况下,Hibernate Search 将分词字符串并分别搜索每个关键词。

最后,createQuery方法终止 DSL 并返回一个 Lucene 查询对象。该对象然后可以由FullTextSession(或FullTextEntityManager)用来创建最终的 Hibernate Search FullTextQuery对象:

...
FullTextQuery hibernateQuery =
   fullTextSession.createFullTextQuery(luceneQuery, App.class);
...

模糊搜索

当我们今天使用搜索引擎时,我们默认它会智能到足以在我们“足够接近”正确拼写时修正我们的拼写错误。向 Hibernate Search 添加这种智能的一种方法是将普通关键词查询模糊化

使用模糊搜索,关键词即使相差一个或多个字符也能与字段匹配。查询运行时有一个介于01之间的阈值,其中0意味着一切都匹配,而1意味着只接受精确匹配。查询的模糊度取决于您将阈值设置得多接近于零。

DSL 以相同的关键词方法开始,最终通过onFieldonFields继续关键词查询流程。然而,在这两者之间有一些新的流程可能性,如下所示:

模糊搜索

模糊搜索流程(虚线灰色箭头代表可选路径)

模糊方法只是使普通关键词查询变得“模糊”,默认阈值值为0.5(例如,平衡两个极端之间)。您可以从那里继续常规关键词查询流程,这将完全没问题。

然而,您可以选择调用withThreshold来指定不同的模糊度值。在本章中,VAPORware Marketplace 应用程序的版本为关键词查询增加了模糊度,阈值设置为0.7。这个值足够严格以避免过多的假阳性,但足够模糊,以至于现在拼写错误的搜索“rodio”将匹配“Athena Internet Radio”应用程序。

...
luceneQuery = queryBuilder
   .keyword()
 .fuzzy()
 .withThreshold(0.7f)
   .onFields("name", "description", "supportedDevices.name",
      "customerReviews.comments")
   .matching(searchString)
   .createQuery();
...

除了(或代替)withThreshold,您还可以使用withPrefixLength来调整查询的模糊度。这个整数值是在每个单词的开头您想要从模糊度计算中排除的字符数。

通配符搜索

关键词查询的第二个变体不涉及任何高级数学算法。如果您曾经使用过像*.java这样的模式来列出目录中的所有文件,那么您已经有了基本概念。

添加通配符方法使得普通关键词查询将问号(?)视为任何单个字符的有效替代品。例如,关键词201?将匹配字段值201020112012等。

星号(*)成为任何零个或多个字符序列的替代品。关键词down*匹配downloaddowntown等词汇。

Hibernate Search DSL 的通配符搜索与常规关键词查询相同,只是在最前面增加了零参数的wildcard方法。

通配符搜索

通配符搜索流程(虚线灰色箭头代表可选路径)

精确短语查询

当你在搜索引擎中输入一组关键词时,你期望看到匹配其中一个或多个关键词的结果。每个结果中可能不都包含所有关键词,它们可能不会按照你输入的顺序出现。

然而,现在已经习惯于当你将字符串用双引号括起来时,你期望搜索结果包含这个确切的短语。

Hibernate Search DSL 为这类搜索提供了短语查询流程。

精确短语查询

精确短语查询流程(虚线灰色箭头代表可选路径)

onFieldandField方法的行为与关键词查询相同。sentence方法与matching的区别在于,其输入必须是String

短语查询可以通过使用可选的withSlop子句来实现一种模糊性。该方法接受一个整数参数,代表在短语内可以找到的“额外”单词数,在达到这个数量之前,短语仍被视为匹配。

本章中 VAPORware Marketplace 应用程序的版本现在会检查用户搜索字符串周围是否有双引号。当输入被引号括起来时,应用程序将关键词查询替换为短语查询:

...
luceneQuery = queryBuilder
 .phrase()
   .onField("name")
   .andField("description")
   .andField("supportedDevices.name")
   .andField("customerReviews.comments")
   .sentence(searchStringWithQuotesRemoved)
   .createQuery();
...

范围查询

短语查询和各种关键词搜索类型,都是关于将字段匹配到搜索词。范围查询有点不同,因为它寻找被一个或多个搜索词限定的字段。也就是说,一个字段是大于还是小于给定值,还是在大于或小于两个值之间?

范围查询

范围查询流程(虚线灰色箭头代表可选路径)

当使用前述方法时,查询的字段必须大于或等于输入参数的值。这个参数是通用的Object类型,以增加灵活性。通常使用日期和数字值,尽管字符串也非常合适,并且会根据字母顺序进行比较。

正如你可能会猜到的,下一个方法是一个对应的方法,其中的值必须小于或等于输入参数。要声明匹配必须在两个参数之间,包括这两个参数,你就得使用fromto方法(它们必须一起使用)。

可以对这些子句中的任何一个应用excludeLimit子句。它的作用是将范围变为排他而非包含。换句话说,from(5).to(10).excludeLimit()匹配一个5 <= x < 10的范围。修改器可以放在from子句上,而不是to,或者同时放在两个上。

在我们的 VAPORware Marketplace 应用程序中,我们之前拒绝为CustomerReview.stars标注索引。然而,如果我们用@Field标注它,那么我们就可以用类似于以下的查询来搜索所有 4 星和 5 星的评论:

...
luceneQuery = queryBuilder
   .range()
   .onField("customerReviews.stars")
   .above(3).excludeLimit()
   .createQuery();
...

布尔(组合)查询

如果你有一个高级用例,其中关键词、短语或范围查询本身不够,但两个或更多组合在一起能满足你的需求,那怎么办?Hibernate Search 允许你用布尔逻辑混合任何查询组合:

布尔(组合)查询

布尔查询流程(虚线灰色箭头代表可选路径)

bool方法声明这将是一个组合查询。它后面至少跟着一个onemust或应该clause,每一个都接受一个前面讨论过的各种类型的 Lucene 查询对象。

当使用must子句时,一个字段必须与嵌套查询匹配,才能整体匹配查询。可以应用多个must子句,它们以逻辑与的方式操作。它们都必须成功,否则就没有匹配。

可选的not方法用于逻辑上否定一个must子句。效果是,整个查询只有在那个嵌套查询不匹配时才会匹配。

should子句大致相当于逻辑或操作。当一个组合只由should子句组成时,一个字段不必匹配它们全部。然而,为了使整个查询匹配,至少必须有一个匹配。

注意

你可以组合mustshould子句。然而,如果你这样做,那么should嵌套查询就变得完全可选了。如果must子句成功,整体查询无论如何都会成功。如果must子句失败,整体查询无论如何都会失败。当两种子句类型一起使用时,should子句只起到帮助按相关性排名搜索结果的作用。

这个例子结合了一个关键词查询和一个范围查询,以查找拥有 5 星客户评价的"xPhone"应用程序:

...
luceneQuery = queryBuilder
 .bool()
 .must(
      queryBuilder.keyword().onField("supportedDevices.name")
      .matching("xphone").createQuery()
   )
 .must(
      queryBuilder.range().onField("customerReviews.stars")
      .above(5).createQuery()
   )
   .createQuery();
...

排序

默认情况下,搜索结果按照它们的“相关性”排序返回。换句话说,它们是根据它们与查询的匹配程度进行排名的。我们将在接下来的两章中进一步讨论这一点,并学习如何调整这些相关性计算。

然而,我们有选项可以完全改变排序的其他标准。在典型情况下,你可能会按照日期或数字字段,或者按照字母顺序的字符串字段进行排序。在 VAPORware Marketplace 应用程序的的所有版本中,用户现在可以按照应用程序名称对他们的搜索结果进行排序。

要对一个字段进行排序,当这个字段被映射为 Lucene 索引时,需要特别考虑。通常当一个字符串字段被索引时,默认分析器(在下一章中探讨)会将字符串分词。例如,如果一个App实体的name字段是"Frustrated Flamingos",那么在 Lucene 索引中会为"frustrated"和"flamingos"创建单独的条目。这允许进行更强大的查询,但我们希望基于原始未分词的值进行排序。

支持这种情况的一个简单方法是将字段映射两次,这是完全可行的!正如我们在第二章中看到的,映射实体类,Hibernate Search 提供了一个复数@Fields注解。它包含一个由逗号分隔的@Field注解列表,具有不同的分析器设置。

在下面的代码片段中,一个@Field被声明为默认的分词设置。第二个则将它的analyze元素设置为Analyze.NO,以禁用分词,并在 Lucene 索引中给它自己的独立字段名称:

...
@Column
@Fields({
   @Field,
 @Field(name="sorting_name", analyze=Analyze.NO)
})
private String name;
...

这个新字段名称可以用如下方式来构建一个 Lucene SortField对象,并将其附加到一个 Hibernate Search FullTextQuery对象上:

import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
...
Sort sort = new Sort(
   new SortField("sorting_name", SortField.STRING));
hibernateQuery.setSort(sort);  // a FullTextQuery object

hibernateQuery后来返回一个搜索结果列表时,这个列表将按照应用程序名称进行排序,从 A 到 Z 开始。

反向排序也是可能的。SortField类还提供了一个带有第三个Boolean参数的构造函数。如果这个参数被设置为true,排序将以完全相反的方式进行(例如,从 Z 到 A)。

分页

当一个搜索查询返回大量的搜索结果时,一次性将它们全部呈现给用户通常是不受欢迎的(或者可能根本不可能)。一个常见的解决方案是分页,或者一次显示一个“页面”的搜索结果。

一个 Hibernate Search FullTextQuery对象有方法可以轻松实现分页:

…
hibernateQuery.setFirstResult(10);
hibernateQuery.setMaxResults(5);
List<App> apps = hibernateQuery.list();
…

setMaxResults 方法声明了页面的最大大小。在前面的代码片段的最后一行,即使查询有数千个匹配项,apps 列表也将包含不超过五个 App 对象。

当然,如果代码总是抓取前五个结果,分页将不会很有用。我们还需要能够抓取下一页,然后是下一页,依此类推。因此 setFirstResult 方法告诉 Hibernate Search 从哪里开始。

例如,前面的代码片段从第十一个结果项开始(参数是 10,但结果是零索引的)。然后将查询设置为抓取下一个五个结果。因此,下一个传入请求可能会使用 hibernateQuery.setFirstResult(15)

拼图的最后一片是知道有多少结果,这样你就可以为正确数量的页面进行规划:

…
intresultSize = hibernateQuery.getResultSize();
…

getResultSize 方法比乍一看要强大,因为它只使用 Lucene 索引来计算数字。跨所有匹配行的常规数据库查询可能是一个非常资源密集的操作,但对于 Lucene 来说是一个相对轻量级的事务。

注意

本章示例应用程序的版本现在使用分页来显示搜索结果,每页最多显示五个结果。查看 SearchServletsearch.jsp 结果页面,了解它们如何使用结果大小和当前起始点来构建所需的“上一页”和“下一页”链接。

以下是 VAPORware Marketplace 更新的实际操作情况:

分页

总结

在本章中,我们探讨了 Hibernate Search 查询中最常见的用例。现在,无论 JPA 是整体使用、部分使用还是根本不使用,我们都可以与 Hibernate Search 一起工作。我们了解了 Hibernate Search DSL 提供的核心查询类型,并可以轻松地访问到它们的全部可能流程,而不是不得不浏览 Javadocs 来拼凑它们。

现在我们知道如何按特定字段对搜索结果进行升序或降序排序。对于大型结果集,我们可以现在对结果进行分页,以提高后端性能和前端用户体验。我们 VAPORware Marketplace 示例中的搜索功能现在大于或等于许多生产 Hibernate Search 应用程序。

在下一章中,我们将探讨更高级的映射技术,例如处理自定义数据类型和控制 Lucene 索引过程的详细信息。

第四章:高级映射

到目前为止,我们已经学习了将对象映射到 Lucene 索引的基本知识。我们看到了如何处理与相关实体和嵌入对象的关系。然而,可搜索的字段大多是简单的字符串数据。

在本章中,我们将探讨如何有效地映射其他数据类型。我们将探讨 Lucene 为索引分析实体以及可以自定义该过程的 Solr 组件的过程。我们将了解如何调整每个字段的重要性,使按相关性排序更有意义。最后,我们将根据运行时实体的状态条件性地确定是否索引实体。

桥梁

Java 类中的成员变量可能是无数的自定义类型。通常,您也可以在自己的数据库中创建自定义类型。使用 Hibernate ORM,有数十种基本类型,可以构建更复杂的类型。

然而,在 Lucene 索引中,一切最终都归结为字符串。当你为搜索映射其他数据类型的字段时,该字段被转换为字符串表示。在 Hibernate Search 术语中,这种转换背后的代码称为桥梁。默认桥梁为您处理大多数常见情况,尽管您有能力为自定义场景编写自己的桥梁。

一对一自定义转换

最常见的映射场景是一个 Java 属性与一个 Lucene 索引字段绑定。String变量显然不需要任何转换。对于大多数其他常见数据类型,它们作为字符串的表达方式相当直观。

映射日期字段

Date值被调整为 GMT 时间,然后以yyyyMMddHHmmssSSS的格式存储为字符串。

尽管这一切都是自动发生的,但你确实可以选择显式地将字段注解为@DateBridge。当你不想索引到确切的毫秒时,你会这样做。这个注解有一个必需的元素resolution,让你从YEARMONTHDAYHOURMINUTESECONDMILLISECOND(正常默认)中选择一个粒度级别。

可下载的chapter4版本的 VAPORware Marketplace 应用现在在App实体中添加了一个releaseDate字段。它被配置为仅存储日期,而不存储具体的一天中的任何时间。

...
@Column
@Field
@DateBridge(resolution=Resolution.DAY)
private Date releaseDate;
...

处理 null 值

默认情况下,无论其类型如何,带有 null 值的字段都不会被索引。然而,您也可以自定义这种行为。@Field注解有一个可选元素indexNullAs,它控制了映射字段的 null 值的处理。

...
@Column
@Field(indexNullAs=Field.DEFAULT_NULL_TOKEN)
private String description;
...

此元素的默认设置是Field.DO_NOT_INDEX_NULL,这导致 null 值在 Lucene 索引中被省略。然而,当使用Field.DEFAULT_NULL_TOKEN时,Hibernate Search 将使用一个全局配置的值索引该字段。

这个值的名称是hibernate.search.default_null_token,它是在hibernate.cfg.xml(对于传统的 Hibernate ORM)或persistence.xml(对于作为 JPA 提供者的 Hibernate)中设置的。如果这个值没有配置,那么空字段将被索引为字符串"_null_"

注意

您可以使用这个机制对某些字段进行空值替换,而保持其他字段的行为。然而,indexNullAs元素只能与在全局级别配置的那个替代值一起使用。如果您想要为不同的字段或不同的场景使用不同的空值替代,您必须通过自定义桥接实现那个逻辑(在下一小节中讨论)。

自定义字符串转换

有时您需要在将字段转换为字符串值方面具有更多的灵活性。而不是依赖内置的桥接自动处理,您可以创建自己的自定义桥接。

StringBridge

要将对单个 Java 属性的映射映射到一个索引字段上,您的桥接可以实现 Hibernate Search 提供的两个接口中的一个。第一个,StringBridge,是为了在属性和字符串值之间进行单向翻译。

假设我们的App实体有一个currentDiscountPercentage成员变量,表示该应用程序正在提供的任何促销折扣(例如,25% 折扣!)。为了更容易进行数学运算,这个字段被存储为浮点数(0.25f)。然而,如果我们想要使折扣可搜索,我们希望它们以更易读的百分比格式(25)进行索引。

为了提供这种映射,我们首先需要创建一个桥接类,实现StringBridge接口。桥接类必须实现一个objectToString方法,该方法期望将我们的currentDiscountPercentage属性作为输入参数:

import org.hibernate.search.bridge.StringBridge;

/** Converts values from 0-1 into percentages (e.g. 0.25 -> 25) */
public class PercentageBridge implements StringBridge {
   public String objectToString(Object object) {
      try {
         floatfieldValue = ((Float) object).floatValue();
         if(fieldValue< 0f || fieldValue> 1f) return "0";
         int percentageValue = (int) (fieldValue * 100);
 return Integer.toString(percentageValue);
      } catch(Exception e) {
         // default to zero for null values or other problems
 return "0";
      }
   }

}

objectToString方法按照预期转换输入,并返回其String表示。这将是由 Lucene 索引的值。

注意

请注意,当给定一个空值时,或者当遇到任何其他问题时,这个方法返回一个硬编码的"0"。自定义空值处理是创建字段桥接的另一个可能原因。

要在索引时间调用这个桥接类,请将@FieldBridge注解添加到currentDiscountPercentage属性上:

...
@Column
@Field
@FieldBridge(impl=PercentageBridge.class)
private float currentDiscountPercentage;
...

注意

这个实体字段是一个原始float,然而桥接类却在与一个Float包装对象一起工作。为了灵活性,objectToString接受一个泛型Object参数,该参数必须转换为适当的类型。然而,多亏了自动装箱,原始值会自动转换为它们的对象包装器。

TwoWayStringBridge

第二个接口用于将单个变量映射到单个字段,TwoWayStringBridge,提供双向翻译,在值及其字符串表示之间进行翻译。

实现TwoWayStringBridge的方式与刚刚看到的常规StringBridge接口类似。唯一的区别是,这个双向版本还要求有一个stringToObject方法,用于反向转换:

...
public Object stringToObject(String stringValue) {
   return Float.parseFloat(stringValue) / 100;
}
...

提示

只有在字段将成为 Lucene 索引中的ID字段(即,用@Id@DocumentId注解)时,才需要双向桥。

参数化桥

为了更大的灵活性,可以向桥接类传递配置参数。为此,您的桥接类应该实现ParameterizedBridge接口,以及StringBridgeTwoWayStringBridge。然后,该类必须实现一个setParameterValues方法来接收这些额外的参数。

为了说明问题,假设我们想让我们的示例桥接能够以更大的精度写出百分比,而不是四舍五入到整数。我们可以传递一个参数,指定要使用的小数位数:

public class PercentageBridge implements StringBridge,
 ParameterizedBridge {

 public static final String DECIMAL_PLACES_PROPERTY =
 "decimal_places";
 private int decimalPlaces = 2;  // default

   public String objectToString(Object object) {
      String format = "%." + decimalPlaces + "g%n";
      try {
         float fieldValue = ((Float) object).floatValue();
         if(fieldValue< 0f || fieldValue> 1f) return "0";
         return String.format(format, (fieldValue * 100f));
      } catch(Exception e) {
         return String.format(format, "0");
      }
   }
 public void setParameterValues(Map<String, String> parameters) {
      try {
         this.decimalPlaces = Integer.parseInt(
            parameters.get(DECIMAL_PLACES_PROPERTY) );
      } catch(Exception e) {}
   }

}

我们桥接类的这个版本期望收到一个名为decimal_places的参数。它的值存储在decimalPlaces成员变量中,然后在objectToString方法中使用。如果没有传递这样的参数,那么将使用两个小数位来构建百分比字符串。

@FieldBridge注解中的params元素是实际传递一个或多个参数的机制:

...
@Column
@Field
@FieldBridge(
   impl=PercentageBridge.class,
 params=@Parameter(
 name=PercentageBridge.DECIMAL_PLACES_PROPERTY, value="4")
)
private float currentDiscountPercentage;
...

注意

请注意,所有StringBridgeTwoWayStringBridge的实现都必须是线程安全的。通常,您应该避免任何共享资源,并且只通过ParameterizedBridge参数获取额外信息。

使用 FieldBridge 进行更复杂的映射

迄今为止所涵盖的桥接类型是将 Java 属性映射到字符串索引值的最简单、最直接的方法。然而,有时您需要更大的灵活性,因此有一些支持自由形式的字段桥接变体。

将单个变量拆分为多个字段

有时,类属性与 Lucene 索引字段之间的期望关系可能不是一对一的。例如,假设一个属性表示文件名。然而,我们希望能够不仅通过文件名搜索,还可以通过文件类型(即文件扩展名)搜索。一种方法是从文件名属性中解析文件扩展名,从而使用这个变量创建两个字段。

FieldBridge接口允许我们这样做。实现必须提供一个set方法,在这个例子中,它从文件名字段中解析文件类型,并将其分别存储:

import org.apache.lucene.document.Document;
import org.hibernate.search.bridge.FieldBridge;
import org.hibernate.search.bridge.LuceneOptions;

public class FileBridge implements FieldBridge {

 public void set(String name, Object value, 
 Document document, LuceneOptionsluceneOptions) {
      String file = ((String) value).toLowerCase();
      String type = file.substring(
      file.indexOf(".") + 1 ).toLowerCase();
 luceneOptions.addFieldToDocument(name+".file", file, document);
 luceneOptions.addFieldToDocument(name+".file_type", type, 
 document);
   }

}

luceneOptions参数是与 Lucene 交互的帮助对象,document表示我们正在添加字段的 Lucene 数据结构。我们使用luceneOptions.addFieldToDocument()将字段添加到索引,而不必完全理解 Lucene API 的细节。

传递给setname参数代表了被索引的实体名称。注意我们用这个作为基础来声明要添加的两个实体的名称(也就是说,对于文件名,使用name+".file";对于文件类型,使用name+".file_type")。

最后,value 参数是指当前正在映射的字段。就像在Bridges部分看到的StringBridge接口一样,这里的函数签名使用了一个通用的Object以提高灵活性。必须将值转换为其适当的类型。

要应用FieldBridge实现,就像我们已经看到的其他自定义桥接类型一样,使用@FieldBridge注解:

...
@Column
@Field
@FieldBridge(impl=FileBridge.class)
private String file;
...

将多个属性合并为一个字段

实现FieldBridge接口的自定义桥接也可以用于相反的目的,将多个属性合并为一个索引字段。为了获得这种灵活性,桥接必须应用于级别而不是字段级别。当以这种方式使用FieldBridge接口时,它被称为类桥接,并替换了整个实体类的常规映射机制。

例如,考虑我们在 VAPORware Marketplace 应用程序中处理Device实体时可以采取的另一种方法。而不是将manufacturername作为单独的字段进行索引,我们可以将它们合并为一个fullName字段。这个类桥接仍然实现FieldBridge接口,但它会将两个属性合并为一个索引字段,如下所示:

public class DeviceClassBridge implements FieldBridge {

   public void set(String name, Object value, 
         Document document, LuceneOptionsluceneOptions) {
      Device device = (Device) value;
      String fullName = device.getManufacturer()
         + " " + device.getName();
 luceneOptions.addFieldToDocument(name + ".name", 
 fullName, document);
   }

}

而不是在Device类的任何特定字段上应用注解,我们可以在类级别应用一个@ClassBridge注解。注意字段级别的 Hibernate Search 注解已经被完全移除,因为类桥接将负责映射这个类中的所有索引字段。

@Entity
@Indexed
@ClassBridge(impl=DeviceClassBridge.class)
public class Device {

   @Id
   @GeneratedValue
   private Long id;

   @Column
   private String manufacturer;

   @Column
   private String name;

   // constructors, getters and setters...
}

TwoWayFieldBridge

之前我们看到了简单的StringBridge接口有一个TwoWayStringBridge对应接口,为文档 ID 字段提供双向映射能力。同样,FieldBridge接口也有一个TwoWayFieldBridge对应接口出于相同原因。当你将字段桥接接口应用于 Lucene 用作 ID 的属性(即,用@Id@DocumentId注解)时,你必须使用双向变体。

TwoWayStringBridge接口需要与StringBridge相同的objectToString方法,以及与FieldBridge相同的set方法。然而,这个双向版本还需要一个get对应方法,用于从 Lucene 检索字符串表示,并在真实类型不同时进行转换:

...
public Object get(String name, Object value, Document document) {
   // return the full file name field... the file type field
   // is not needed when going back in the reverse direction
   return = document.get(name + ".file");
}
public String objectToString(Object object) {
   // "file" is already a String, otherwise it would need conversion
      return object;
}
...

分析

当一个字段被 Lucene 索引时,它会经历一个称为分析的解析和转换过程。在第三章《执行查询》中,我们提到了默认的分析器会分词字符串字段,如果你打算对该字段进行排序,则应该禁用这种行为。

然而,在分析过程中可以实现更多功能。Apache Solr 组件可以组装成数百种组合。 它们可以在索引过程中以各种方式操纵文本,并打开一些非常强大的搜索功能的大门。

为了讨论可用的 Solr 组件,或者如何将它们组装成自定义分析器定义,我们首先必须了解 Lucene 分析的三个阶段:

  • 字符过滤

  • 标记化

  • 标记过滤

分析首先通过应用零个或多个字符过滤器进行,这些过滤器在处理之前去除或替换字符。 过滤后的字符串然后进行标记化,将其拆分为更小的标记,以提高关键字搜索的效率。 最后,零个或多个标记过滤器在将它们保存到索引之前去除或替换标记。

注意

这些组件由 Apache Solr 项目提供,总共有三十多个。 本书无法深入探讨每一个,但我们可以查看三种类型的一些关键示例,并了解如何一般地应用它们。

所有这些 Solr 分析器组件的完整文档可以在wiki.apache.org/solr/AnalyzersTokenizersTokenFilters找到,Javadocs 在lucene.apache.org/solr/api-3_6_1

字符过滤

定义自定义分析器时,字符过滤是一个可选步骤。如果需要此步骤,只有三种字符过滤类型可用:

  • MappingCharFilterFactory:此过滤器将字符(或字符序列)替换为特定定义的替换文本,例如,您可能会将1替换为one2替换为two,依此类推。

    字符(或字符序列)与替换值之间的映射存储在一个资源文件中,该文件使用标准的java.util.Properties格式,位于应用程序的类路径中的某个位置。对于每个属性,键是查找的序列,值是映射的替换。

    这个映射文件相对于类路径的位置被传递给MappingCharFilterFactory定义,作为一个名为mapping的参数。传递这个参数的确切机制将在定义和选择分析器部分中详细说明。

  • PatternReplaceCharFilter:此过滤器应用一个通过名为pattern的参数传递的正则表达式。 任何匹配项都将用通过replacement参数传递的静态文本字符串替换。

  • HTMLStripCharFilterFactory:这个极其有用的过滤器移除 HTML 标签,并将转义序列替换为其通常的文本形式(例如,&gt;变成>)。

标记化

在定义自定义分析器时,字符和标记过滤器都是可选的,您可以组合多种过滤器。然而,tokenizer组件是唯一的。分析器定义必须包含一个,最多一个。

总共有 10 个tokenizer组件可供使用。一些说明性示例包括:

  • WhitespaceTokenizerFactory:这个组件只是根据空白字符分割文本。例如,hello world 被分词为 helloworld

  • LetterTokenizerFactory:这个组件的功能与WhitespaceTokenizrFactory类似,但这个分词器还会在非字母字符处分割文本。非字母字符完全被丢弃,例如,please don't go被分词为please, don, t, 和 go

  • StandardTokenizerFactory:这是默认的tokenizer,在未定义自定义分析器时自动应用。它通常根据空白字符分割,丢弃多余字符。例如,it's 25.5 degrees outside!!! 变为 it's, 25.5, degrees, 和 outside

小贴士

当有疑问时,StandardTokenizerFactory几乎总是合理的选择。

分词过滤器

到目前为止,分析器功能的最大多样性是通过分词过滤器实现的,Solr 提供了二十多个选项供单独或组合使用。以下是更有用的几个示例:

  • StopFilterFactory:这个过滤器简单地丢弃“停用词”,或者根本没有人想要对其进行关键词查询的极其常见的词。列表包括 a, the, if, for, and, or 等(Solr 文档列出了完整列表)。

  • PhoneticFilterFactory:当你使用主流搜索引擎时,你可能会注意到它在处理你的拼写错误时非常智能。这样做的一种技术是寻找与搜索关键字听起来相似的单词,以防它被拼写错误。例如,如果你本想搜索morning,但误拼为mourning,搜索仍然能匹配到意图的词条!这个分词过滤器通过与实际分词一起索引音似字符串来实现这一功能。该过滤器需要一个名为encoder的参数,设置为支持的字符编码算法名称("DoubleMetaphone"是一个合理的选择)。

  • SnowballPorterFilterFactory:词干提取是一个将分词转化为其根形式的过程,以便更容易匹配相关词汇。Snowball 和 Porter 指的是词干提取算法。例如,单词 developerdevelopment 都可以被分解为共同的词干 develop。因此,Lucene 能够识别这两个较长词汇之间的关系(即使没有一个词汇是另一个的子串!)并能返回两个匹配项。这个过滤器有一个参数,名为 language(例如,"English")。

定义和选择分析器

分析器定义将一些这些组件的组合成一个逻辑整体,在索引实体或单个字段时可以引用这个整体。分析器可以在静态方式下定义,也可以根据运行时的一些条件动态地组装。

静态分析器选择

定义自定义分析器的任何方法都以在相关持久类上的@AnalyzerDef注解开始。在我们的chapter4版本的 VAPORware Marketplace 应用程序中,让我们定义一个自定义分析器,用于与App实体的description字段一起使用。它应该移除任何 HTML 标签,并应用各种分词过滤器以减少杂乱并考虑拼写错误:

...
@AnalyzerDef(
 name="appAnalyzer",
 charFilters={    
      @CharFilterDef(factory=HTMLStripCharFilterFactory.class) 
   },
 tokenizer=@TokenizerDef(factory=StandardTokenizerFactory.class),
 filters={ 
      @TokenFilterDef(factory=StandardFilterFactory.class),
      @TokenFilterDef(factory=StopFilterFactory.class),
      @TokenFilterDef(factory=PhoneticFilterFactory.class, 
            params = {
         @Parameter(name="encoder", value="DoubleMetaphone")
            }),
      @TokenFilterDef(factory=SnowballPorterFilterFactory.class, 
            params = {
      @Parameter(name="language", value="English") 
      })
   }
)
...

@AnalyzerDef注解必须有一个名称元素设置,正如之前讨论的,分析器必须始终包括一个且只有一个分词器。

charFiltersfilters元素是可选的。如果设置,它们分别接收一个或多个工厂类列表,用于字符过滤器和分词过滤器。

提示

请注意,字符过滤器和分词过滤器是按照它们列出的顺序应用的。在某些情况下,更改顺序可能会影响最终结果。

@Analyzer注解用于选择并应用一个自定义分析器。这个注解可以放在个别字段上,或者放在整个类上,影响每个字段。在这个例子中,我们只为desciption字段选择我们的分析器定义:

...
@Column(length = 1000)
@Field
@Analyzer(definition="appAnalyzer")
private String description;
...

在一个类中定义多个分析器是可能的,通过将它们的@AnalyzerDef注解包裹在一个复数@AnalyzerDefs中来实现:

...
@AnalyzerDefs({
   @AnalyzerDef(name="stripHTMLAnalyzer", ...),
   @AnalyzerDef(name="applyRegexAnalyzer", ...)
})
...

显然,在后来应用@Analyzer注解的地方,其定义元素必须与相应的@AnalyzerDef注解的名称元素匹配。

注意

chapter4版本的 VAPORware Marketplace 应用程序现在会从客户评论中移除 HTML。如果搜索包括关键词span,例如,不会在包含<span>标签的评论中出现假阳性匹配。

Snowball 和音译过滤器被应用于应用描述中。关键词mourning找到包含单词morning的匹配项,而development的搜索返回了描述中包含developers的应用程序。

动态分析器选择

可以等到运行时为字段选择一个特定的分析器。最明显的场景是一个支持不同语言的应用程序,为每种语言配置了分析器定义。您希望根据每个对象的言语属性选择适当的分析器。

为了支持这种动态选择,对特定的字段或整个类添加了@AnalyzerDiscriminator注解。这个代码段使用了后者的方法:

@AnalyzerDefs({
   @AnalyzerDef(name="englishAnalyzer", ...),
   @AnalyzerDef(name="frenchAnalyzer", ...)
})
@AnalyzerDiscriminator(impl=CustomerReviewDiscriminator.class)
public class CustomerReview {
   ...
   @Field
   private String language;
   ...
}

有两个分析器定义,一个是英语,另一个是法语,类CustomerReviewDiscriminator被宣布负责决定使用哪一个。这个类必须实现Discriminator接口,并它的getAnalyzerDefinitionName方法:

public class LanguageDiscriminator implements Discriminator {

 public String getAnalyzerDefinitionName(Object value, 
 Object entity, String field) {
      if( entity == null || !(entity instanceofCustomerReview) ) {
         return null;
      }
      CustomerReview review = (CustomerReview) entity;
      if(review.getLanguage() == null) {
         return null;
       } else if(review.getLanguage().equals("en")) {
         return "englishAnalyzer";
       } else if(review.getLanguage().equals("fr")) {
         return "frenchAnalyzer";
       } else {
         return null;
      }
   }

}

如果@AnalyzerDiscriminator注解放在字段上,那么其当前对象的值会自动作为第一个参数传递给getAnalyzerDefinitionName。如果注解放在类本身上,则传递null值。无论如何,第二个参数都是当前实体对象。

在这种情况下,鉴别器应用于类级别。所以我们将第二个参数转换为CustomerReview类型,并根据对象的language字段返回适当的分析器名称。如果语言未知或存在其他问题,则该方法简单地返回null,告诉 Hibernate Search 回退到默认分析器。

提升搜索结果的相关性

我们已经知道,搜索结果的默认排序顺序是按相关性,即它们与查询匹配的程度。如果一个实体在两个字段上匹配,而另一个只有一个字段匹配,那么第一个实体是更相关的结果。

Hibernate Search 允许我们通过在索引时调整实体或字段的相对重要性来调整相关性提升。这些调整可以是静态和固定的,也可以是动态的,由运行时数据状态驱动。

索引时间的静态提升

固定的提升,无论实际数据如何,都像注解一个类或字段一样简单,只需要使用@Boost。这个注解接受一个浮点数参数作为其相对权重,默认权重为 1.0\。所以,例如,@Boost(2.0f)会将一个类或字段的权重相对于未注解的类和字段加倍。

我们的 VAPORware Marketplace 应用程序在几个字段和关联上进行搜索,比如支持设备的名称,以及客户评论中的评论。然而,文本应该比来自外部各方的文本更重要,这难道不是合情合理的吗?(每个应用的名称和完整描述)

为了进行此调整,chapter4版本首先注释了App类本身:

...
@Boost(2.0f)
public class App implements Serializable {
...

这实际上使得App的权重是DeviceCustomerReview的两倍。接下来,我们对名称和完整描述字段应用字段级提升:

...
@Boost(1.5f)
private String name;
...
@Boost(1.2f)
private String description;
...

我们在这里声明name的权重略高于description,并且它们相对于普通字段都带有更多的权重。

注意

请注意,类级别和字段级别的提升是级联和结合的!当给定字段应用多个提升因子时,它们会被乘以形成总因子。

在这里,因为已经对App类本身应用了 2.0 的权重,name的总有效权重为 3.0,description为 2.4。

索引时间的动态提升

让我们假设我们希望在评论者给出五星评价时,给CustomerReview对象更多的权重。为此,我们在类上应用一个@DynamicBoost注解:

...
@DynamicBoost(impl=FiveStarBoostStrategy.class)
public class CustomerReview {
...

这个注解必须传递一个实现BoostStrategy接口的类,以及它的defineBoost方法:

public class FiveStarBoostStrategy implements BoostStrategy {

 public float defineBoost(Object value) {
      if(value == null || !(value instanceofCustomerReview)) {
         return 1;
      }
      CustomerReviewcustomerReview = (CustomerReview) value;
      if(customerReview.getStars() == 5) {
         return 1.5f;
      } else {
         return 1;
      }
   }

}

@DynamicBoost注解应用于一个类时,传递给defineBoost的参数自动是该类的一个实例(在这个例子中是一个CustomerReview对象)。如果注解是应用于一个特定的字段,那么自动传递的参数将是那个字段的值。

defineBoost返回的float值变成了被注解的类或字段的权重。在这个例子中,当CustomerReview对象代表一个五星评论时,我们将它的权重增加到 1.5。否则,我们保持默认的 1.0。

条件索引

字段索引有专门的处理方式,比如使用类桥接或程序化映射 API。总的来说,当一个属性被注解为@Field时,它就会被索引。因此,避免索引字段的一个明显方法就是简单地不应用这个注解。

然而,如果我们希望一个实体类通常可被搜索,但我们需要根据它们数据在运行时的状态排除这个类的某些实例怎么办?

@Indexed注解有一个实验性的第二个元素interceptor,它给了我们条件索引的能力。当这个元素被设置时,正常的索引过程将被自定义代码拦截,这可以根据实体的当前状态阻止实体被索引。

让我们给我们的 VAPORware Marketplace 添加使应用失效的能力。失效的应用仍然存在于数据库中,但不应该向客户展示或进行索引。首先,我们将向App实体类添加一个新属性:

...
@Column
private boolean active;
...
public App(String name, String image, String description) {
   this.name = name;
   this.image = image;
   this.description = description;
 this.active = true;
}
...
public booleanisActive() {
   return active;
}
public void setActive(boolean active) {
   this.active = active;
}
...

这个新的active变量有标准的 getter 和 setter 方法,并且在我们的正常构造函数中被默认为true。我们希望在active变量为false时,个别应用被排除在 Lucene 索引之外,所以我们给@Indexed注解添加了一个interceptor元素:

...
import com.packtpub.hibernatesearch.util.IndexWhenActiveInterceptor;
...
@Entity
@Indexed(interceptor=IndexWhenActiveInterceptor.class)
public class App {
...

这个元素必须绑定到一个实现EntityIndexingInterceptor接口的类上。由于我们刚刚指定了一个名为IndexWhenActiveInterceptor的类,所以我们现在需要创建这个类。

package com.packtpub.hibernatesearch.util;

import org.hibernate.search.indexes.interceptor.EntityIndexingInterceptor;
import org.hibernate.search.indexes.interceptor.IndexingOverride;
import com.packtpub.hibernatesearch.domain.App;

public class IndexWhenActiveInterceptor
 implementsEntityIndexingInterceptor<App> {

   /** Only index newly-created App's when they are active */
 public IndexingOverrideonAdd(App entity) {
      if(entity.isActive()) {
         return IndexingOverride.APPLY_DEFAULT;
      }
      return IndexingOverride.SKIP;
   }
 public IndexingOverrideonDelete(App entity) {
      return IndexingOverride.APPLY_DEFAULT;
   }

   /** Index active App's, and remove inactive ones */
 public IndexingOverrideonUpdate(App entity) {
      if(entity.isActive()) {
         return IndexingOverride.UPDATE;
            } else {
         return IndexingOverride.REMOVE;
      }
   }

   public IndexingOverrideonCollectionUpdate(App entity) {
      retur nonUpdate(entity);
   }

}

EntityIndexingInterceptor接口声明了四个方法,Hibernate Search 会在实体对象的生命周期的不同阶段调用它们:

  • onAdd(): 当实体实例第一次被创建时调用。

  • onDelete(): 当实体实例从数据库中被移除时调用。

  • onUpdate(): 当一个现有实例被更新时调用。

  • onCollectionUpdate(): 当一个实体作为其他实体的批量更新的一部分被修改时使用这个版本。通常,这个方法的实现简单地调用onUpdate()

这些方法中的每一个都应该返回IndexingOverride枚举的四种可能值之一。可能的返回值告诉 Hibernate Search 应该做什么:

  • IndexingOverride.SKIP:这告诉 Hibernate Search 在当前时间不要修改此实体实例的 Lucene 索引。

  • IndexingOverride.REMOVE:如果实体已经在索引中,Hibernate Search 将删除该实体;如果实体没有被索引,则什么也不做。

  • IndexingOverride.UPDATE:实体将在索引中更新,或者如果它还没有被索引,将被添加。

  • IndexingOverride.APPLY_DEFAULT:这等同于自定义拦截器根本没有被使用。Hibernate Search 将索引实体,如果这是一个onAdd()操作;如果这是一个onDelete(),则将其从索引中移除;或者如果这是onUpdate()onCollectionUpdate(),则更新索引。

尽管这四种方法在逻辑上暗示了某些返回值,但实际上如果你处理的是异常情况,可以任意组合它们。

在我们的示例应用程序中,我们的拦截器在onAdd()onDelete()中检查实体。当创建一个新的App时,如果其active变量为 false,则跳过索引。当App被更新时,如果它变得不活跃,它将被从索引中移除。

总结

在本章中,我们全面了解了为搜索而映射持久对象所提供的所有功能。现在我们可以调整 Hibernate Search 内置类型桥接的设置,并且可以创建高度先进的自定义桥接。

现在我们对 Lucene 分析有了更深入的了解。我们使用了一些最实用的自定义分析器组件,并且知道如何独立获取数十个其他 Solr 组件的信息。

我们现在可以通过提升来调整类和字段的相对权重,以在按相关性排序时提高我们的搜索结果质量。最后但同样重要的是,我们学会了如何使用条件索引动态地阻止某些数据根据其状态变得可搜索。

在下一章中,我们将转向更高级的查询概念。我们将学习如何过滤和分类搜索结果,并从 Lucene 中提取数据,而不需要数据库调用。

第五章:高级查询

在本章中,我们将详细阐述我们在前面章节中介绍的基本搜索查询概念,并融入我们刚刚学到的新的映射知识。现在,我们将探讨使搜索查询更具灵活性和强大性的多种技术。

我们将看到如何在数据库甚至还没有被触碰的情况下,在 Lucene 层面动态地过滤结果。我们还将通过使用基于投影的查询,避免数据库调用,直接从 Lucene 检索属性。我们将使用面向面的搜索,以识别和隔离搜索结果中的数据子集。最后,我们将介绍一些杂项查询工具,如查询时的提升和为查询设置时间限制。

过滤

构建查询的过程围绕着寻找匹配项。然而,有时你希望根据一个明确没有匹配的准则来缩小搜索结果。例如,假设我们想要限制我们的 VAPORware Marketplace 搜索,只支持特定设备上的那些应用:

  • 向现有查询添加关键词或短语是没有帮助的,因为这只会使查询更加包容。

  • 我们可以将现有的查询转换为一个布尔查询,增加一个额外的must子句,但这样 DSL 开始变得难以维护。此外,如果你需要使用复杂的逻辑来缩小你的结果集,那么 DSL 可能提供不了足够的灵活性。

  • 一个 Hibernate Search 的FullTextQuery对象继承自 Hibernate ORM 的Query(或其 JPA 对应物)类。因此,我们可以使用像ResultTransformer这样的核心 Hibernate 工具来缩小结果集。然而,这需要进行额外的数据库调用,这可能会影响性能。

Hibernate Search 提供了一种更优雅和高效的过滤器方法。通过这种机制,各种场景的过滤逻辑被封装在单独的类中。这些过滤器类可以在运行时动态地启用或禁用,也可以以任何组合方式使用。当查询被过滤时,不需要从 Lucene 获取不想要的结果。这减少了后续数据库访问的负担。

创建一个过滤器工厂

为了通过支持设备来过滤我们的搜索结果,第一步是创建一个存储过滤逻辑的类。这应该是org.apache.lucene.search.Filter的实例。对于简单的硬编码逻辑,你可能只需创建你自己的子类。

然而,如果我们通过过滤器工厂动态地生成过滤器,那么我们就可以接受参数(例如,设备名称)并在运行时定制过滤器:

public class DeviceFilterFactory {

   private String deviceName;

 @Factory
   public Filter getFilter() {
      PhraseQuery query = new PhraseQuery();
      StringTokenizertokenzier = new StringTokenizer(deviceName);
      while(tokenzier.hasMoreTokens()) {
         Term term = new Term(
            "supportedDevices.name", tokenzier.nextToken());
         query.add(term);
      }
 Filter filter = new QueryWrapperFilter(query);
      return new CachingWrapperFilter(filter);
   }

   public void setDeviceName(String deviceName) {
      this.deviceName = deviceName.toLowerCase();
   }

}

@Factory注解应用于负责生成 Lucene 过滤器对象的方法。在这个例子中,我们注解了恰当地命名为getFilter的方法。

注意

不幸的是,构建 Lucene Filter对象要求我们更紧密地与原始 Lucene API 合作,而不是 Hibernate Search 提供的方便的 DSL 包装器。Lucene 完整 API 非常复杂,要完全覆盖它需要一本完全不同的书。然而,即使这种浅尝辄止也足够深入地为我们提供编写真正有用过滤器的工具。

这个例子通过包装一个 Lucene 查询来构建过滤器,然后应用第二个包装器以促进过滤器缓存。使用特定类型的查询是org.apache.lucene.search.PhraseQuery,它相当于我们在第三章,执行查询中探讨的 DSL 短语查询。

提示

我们在这个例子中研究短语查询,因为它是一种非常有用的过滤器构建类型。然而,总共有 15 种 Lucene 查询类型。你可以探索lucene.apache.org/core/old_versioned_docs/versions/3_0_3/api/all/org/apache/lucene/search/Query.html上的 JavaDocs。

让我们回顾一下关于数据在 Lucene 索引中是如何存储的一些知识。默认情况下,分析器对字符串进行分词,并将它们作为单独的词项进行索引。默认分析器还将字符串数据转换为小写。Hibernate Search DSL 通常隐藏所有这些细节,因此开发人员不必考虑它们。

然而,当你直接使用 Lucene API 时,确实需要考虑这些事情。因此,我们的setDeviceName设置器方法手动将deviceName属性转换为小写,以避免与 Lucene 不匹配。getFilter方法随后手动将此属性拆分为单独的词项,同样是为了与 Lucene 索引的匹配。

每个分词词项都用于构造一个 Lucene Term对象,该对象包含数据和相关字段名(即在这个案例中的supportedDevices.name)。这些词项一个接一个地添加到PhraseQuery对象中,按照它们在短语中出现的确切顺序。然后将查询对象包装成过滤器并返回。

添加过滤器键

默认情况下,Hibernate Search 为更好的性能缓存过滤器实例。因此,每个实例需要引用缓存中的唯一键。在这个例子中,最逻辑的键将是每个实例过滤的设备名称。

首先,我们在过滤器工厂中添加一个新方法,用@Key注解表示它负责生成唯一键。这个方法返回FilterKey的一个子类:

...
@Key
Public FilterKey getKey() {
   DeviceFilterKey key = new DeviceFilterKey();
   key.setDeviceName(this.deviceName);
   return key;
}
...

自定义FilterKey子类必须实现equalshashCode方法。通常,当实际包装的数据可以表示为字符串时,你可以委派给String类相应的equalshashCode方法:

public class DeviceFilterKey extends FilterKey {

   private String deviceName;

 @Override
 public boolean equals(Object otherKey) {
      if(this.deviceName == null
           || !(otherKey instanceof DeviceFilterKey)) {
         return false;
      }
      DeviceFilterKeyotherDeviceFilterKey =
           (DeviceFilterKey) otherKey;
      return otherDeviceFilterKey.deviceName != null
              && this.deviceName.equals(otherDeviceFilterKey.deviceName);
   }

 @Override
 public int hashCode() {
      if(this.deviceName == null) {
         return 0;
      }
      return this.deviceName.hashCode();
   }

   // GETTER AND SETTER FOR deviceName...
}

建立过滤器定义

为了使这个过滤器对我们应用的搜索可用,我们将在App实体类中创建一个过滤器定义:

...
@FullTextFilterDefs({
   @FullTextFilterDef(
      name="deviceName", impl=DeviceFilterFactory.class
   )
})
public class App {
...

@FullTextFilterDef注解将实体类与给定的过滤器或过滤器工厂类关联,由impl元素指定。name元素是一个字符串,Hibernate Search 查询可以用它来引用过滤器,正如我们在下一小节中看到的。

一个entity类可以有任意数量的定义过滤器。复数形式的@FullTextFilterDefs注解支持这一点,通过包裹一个由逗号分隔的一个或多个单数形式的@FullTextFilterDef注解列表。

为查询启用过滤器

最后但并非最不重要的是,我们使用FullTextQuery对象的enableFullTextFilter方法为 Hibernate Search 查询启用过滤器定义:

...
if(selectedDevice != null && !selectedDevice.equals("all")) {
   hibernateQuery.enableFullTextFilter("deviceName")
      .setParameter("deviceName", selectedDevice);
}
...

这个方法的string参数与查询中涉及的实体类之一的过滤器定义相匹配。在这个例子中,是App上定义的deviceName过滤器。当 Hibernate Search 找到这个匹配项时,它会自动调用相应的过滤器工厂来获取一个Filter对象。

我们的过滤器工厂使用一个参数,也称为deviceName以保持一致性(尽管它是一个不同的变量)。在 Hibernate Search 可以调用工厂方法之前,这个参数必须被设置,通过将参数名和值传递给setParameter

过滤器是在if块中启用的,这样在没有选择设备时(也就是,所有设备选项),我们可以跳过这一步。如果你检查本章版本 VAPORware Marketplace 应用的可下载代码包,你会看到 HTML 文件已经被修改为添加了设备选择的下拉菜单:

为查询启用过滤器

投影

在前几章中,我们的示例应用程序在一次大的数据库调用中获取所有匹配的实体。我们在第三章,执行查询中引入了分页,以至少限制数据库调用到的行数。然而,由于我们最初已经在 Lucene 索引中搜索数据,真的有必要去数据库吗?

休眠搜索提供了投影作为一种减少或至少消除数据库访问的技术。基于投影的搜索只返回从 Lucene 中提取的特定字段,而不是从数据库中返回完整的实体对象。然后你可以去数据库获取完整的对象(如果需要),但 Lucene 中可用的字段本身可能就足够了。

本章的 VAPORware Marketplace 应用程序版本的搜索结果页面修改为现在使用基于查询的投影。之前的版本页面一次性收到App实体,并在点击每个应用的完整详情按钮之前隐藏每个应用的弹出窗口。现在,页面只接收足够构建摘要视图的字段。每个完整详情按钮触发对该应用的 AJAX 调用。只有在那时才调用数据库,并且仅为了获取那一个应用的数据。

注意

从 JavaScript 中进行 AJAX 调用以及编写响应这些调用的 RESTful 网络服务的详尽描述,已经超出了本 Hibernate Search 书籍的范围。

说到这里,所有的 JavaScript 都包含在搜索结果的 JSP 中,在showAppDetails函数内。所有相应的服务器端 Java 代码都位于com.packtpub.hibernatesearch.rest包中,并且非常注释。网络上 endless online primers and tutorials for writing RESTful services, and the documentation for the particular framework used here is at jersey.java.net/nonav/documentation/latest.

创建一个基于查询的查询投影

要将FullTextQuery更改为基于投影的查询,请对该对象调用setProjection方法。现在我们的搜索 servlet 类包含以下内容:

...
hibernateQuery.setProjection("id", "name", "description", "image");
...

该方法接受一个或多个字段名称,从与该查询关联的 Lucene 索引中提取这些字段。

将投影结果转换为对象形式

如果我们到此为止,那么查询对象的list()方法将不再返回App对象的列表!默认情况下,基于投影的查询返回对象数组列表(即Object[])而不是实体对象。这些数组通常被称为元组

每个元组中的元素包含投影字段的值,按它们声明的顺序排列。例如,这里listItem[0]将包含结果的 ID 值,field.listItem[1]将包含名称,value.listItem[2]将包含描述,依此类推。

在某些情况下,直接使用元组是很简单的。然而,您可以通过将 Hibernate ORM 结果转换器附加到查询来自动将元组转换为对象形式。这样做再次改变了查询的返回类型,从List<Object[]>变为所需对象类型的列表:

...
hibernateQuery.setResultTransformer(
   newAliasToBeanResultTransformer(App.class) );
...

您可以创建自己的自定义转换器类,继承自ResultTransformer,实现您需要的任何复杂逻辑。然而,在大多数情况下,Hibernate ORM 提供的开箱即用的子类已经足够了。

这里,我们使用AliasToBeanResultTransformer子类,并用我们的App实体类对其进行初始化。这将与投影字段匹配,并将每个属性的值设置为相应的字段值。

只有App的一部分属性是可用的。保留其他属性未初始化是可以的,因为搜索结果的 JSP 在构建其摘要列表时不需要它们。另外,生成的App对象实际上不会附加到 Hibernate 会话。然而,我们在此之前已经将我们的结果分离,然后再发送给 JSP。

使 Lucene 字段可用于投影

默认情况下,Lucene 索引是为假设它们不会用于基于投影的查询而优化的。因此,投影需要你做一些小的映射更改,并记住几个注意事项。

首先,字段数据必须以可以轻松检索的方式存储在 Lucene 中。正常的索引过程优化数据以支持复杂查询,而不是以原始形式检索。为了以可以被投影恢复的形式存储字段的值,你需要在@Field注解中添加一个store元素:

...
@Field(store=Store.COMPRESS)
private String description;
...

这个元素取三个可能值的枚举:

  • Store.NO是默认值。它使字段被索引用于搜索,但不能通过投影以原始形式检索。

  • Store.YES使字段以原样包含在 Lucene 索引中。这增加了索引的大小,但使投影变得可能。

  • Store.COMPRESS是对妥协的尝试。它也将字段存储原样,但应用压缩以减少整体索引大小。请注意,这更占用处理器资源,并且不适用于同时使用@NumericField注解的字段。

其次,一个字段必须使用双向字段桥。Hibernate Search 中所有内置的默认桥都支持这一点。然而,如果你创建自己的自定义桥类型(请参阅第四章,高级映射),它必须基于TwoWayStringBridgeTwoWayFieldBridge

最后但并非最不重要的是,投影仅适用于实体类本身的基属性。它不是用来获取关联实体或内嵌对象的。如果你尝试引用一个关联,那么你只能得到一个实例,而不是你可能期望的完整集合。

提示

如果你需要与关联或内嵌对象一起工作,那么你可能需要采用我们示例应用程序所使用的方法。Lucene 投影检索所有搜索结果的基本属性,包括实体对象的的主键。当我们后来需要与实体对象的关联一起工作时,我们通过数据库调用使用那个主键只检索必要的行。

分面搜索

Lucene 过滤器是缩小查询范围到特定子集的强大工具。然而,过滤器对预定义的子集起作用。你必须已经知道你在寻找什么。

有时你需要动态地识别子集。例如,让我们给我们的App实体一个表示其类别的category属性:

...
@Column
@Field
private String category;
...

当我们为应用执行关键字搜索时,我们可能想知道哪些类别在结果中有所体现以及每个类别下有多少结果。我们还可能想知道发现了哪些价格范围。所有这些信息都有助于用户更有效地缩小查询。

离散切片

动态识别维度然后通过它们进行过滤的过程称为切片搜索。Hibernate Search 查询 DSL 有一个流程为此,从 QueryBuilder 对象的 facet 方法开始:

离散切片

离散切片请求流程(虚线灰色箭头表示可选路径)

name 方法需要一个描述性标识符作为此切片的名称(例如,categoryFacet),以便后来可以通过查询引用它。熟悉的 onField 子句声明了按结果分组的字段(例如,category)。

discrete 子句表示我们是按单个值分组,而不是按值的范围分组。我们将在下一节探讨范围切片。

createFacetingRequest 方法完成此过程并返回一个 FacetingRequest 对象。然而,还有三个可选的方法,你可以先调用它们中的任何一个,可以任意组合:

  • includeZeroCounts:它导致 Hibernate Search 返回所有可能的切片,甚至在当前搜索结果中没有任何点击的那些。默认情况下,没有点击的切片会被悄悄忽略。

  • maxFacetCount:它限制返回的切片数量。

  • orderedBy:它指定了找到的切片的排序顺序。与离散切片相关的三个选项是:

    • COUNT_ASC: 按相关搜索结果的数量升序排列切片。数量最少点击的切片将被首先列出。

    • COUNT_DESC:这与 COUNT_ASC 正好相反。切片从点击量最高到最低依次列出。

    • FIELD_VALUE:按相关字段的值字母顺序排序切片。例如,"business" 类别会在 "games" 类别之前。

本章版本的 VAPORware Marketplace 现在包括以下设置 app 类别切片搜索的代码:

...
// Create a faceting request
FacetingRequestcategoryFacetingRequest =
 queryBuilder
 .facet()
   .name("categoryFacet")
   .onField("category")
   .discrete()
   .orderedBy(FacetSortOrder.FIELD_VALUE)
   .includeZeroCounts(false)
   .createFacetingRequest();

// Enable it for the FullTextQuery object
hibernateQuery.getFacetManager().enableFaceting(
   categoryFacetingRequest);
...

现在切片请求已启用,我们可以运行搜索查询并使用我们刚刚声明的 categoryFacet 名称检索切片信息:

...
List<App> apps = hibernateQuery.list();

List<Facet> categoryFacets =
   hibernateQuery.getFacetManager().getFacets("categoryFacet");
...

Facet 类包括一个 getValue 方法,该方法返回特定组的字段值。例如,如果一些匹配的应用程序属于 "business" 类别,那么其中一个切片将具有字符串 "business" 作为其值。getCount 方法报告与该切片关联多少搜索结果。

使用这两个方法,我们的搜索 servlet 可以遍历所有类别切片,并构建一个集合,用于在搜索结果 JSP 中显示:

...
Map<String, Integer> categories = new TreeMap<String, Integer>();
for(Facet categoryFacet : categoryFacets) {

   // Build a collection of categories, and the hit count for each
   categories.put(
 categoryFacet.getValue(),categoryFacet.getCount());

   // If this one is the *selected* category, then re-run the query
   // with this facet to narrow the results
   if(categoryFacet.getValue().equalsIgnoreCase(selectedCategory)) {
      hibernateQuery.getFacetManager()
 .getFacetGroup("categoryFacet").selectFacets(categoryFacet);
       apps = hibernateQuery.list();
   }
}
...

如果搜索 servlet 接收到带有selectedCategory CGI 参数的请求,那么用户选择将结果缩小到特定类别。所以如果这个字符串与正在迭代的面元值匹配,那么该面元就为FullTextQuery对象“选中”。然后可以重新运行查询,它将只返回属于该类别的应用程序。

范围面元

面元不仅仅限于单一的离散值。一个面元也可以由一个值范围创建。例如,我们可能想根据价格范围对应用程序进行分组——搜索结果中的价格低于一美元、在一到五美元之间,或者高于五美元。

Hibernate Search DSL 的范围面元需要将离散面元流程的元素与我们在第三章 执行查询 中看到的范围查询的元素结合起来:

范围面元

范围面元请求流程(虚线灰色箭头代表可选路径)

您可以定义一个范围为大于、小于或介于两个值之间(即fromto)。这些选项可以组合使用以定义尽可能多的范围子集。

与常规范围查询一样,可选的excludeLimit方法将其边界值从范围内排除。换句话说,above(5)意味着“大于或等于 5”,而above(5).excludeLimit()意味着“大于 5,期终”。

可选的includeZeroCountsmaxFacetCountorderBy方法与离散面元的方式相同。然而,范围面元提供了一个额外的排序顺序选择。FacetSortOrder.RANGE_DEFINITION_ODER使得面元按照它们被定义的顺序返回(注意“oder”中缺少了“r”)。

在针对category的离散面元请求中,本章的示例代码还包括以下代码段以启用price的范围面元:

...
FacetingRequestpriceRangeFacetingRequest =
 queryBuilder
 .facet()
      .name("priceRangeFacet")
      .onField("price")
      .range()
      .below(1f).excludeLimit()
      .from(1f).to(5f)
      .above(5f).excludeLimit()
      .createFacetingRequest();
hibernateQuery.getFacetManager().enableFaceting(
   priceRangeFacetingRequest);
...

如果你查看search.jsp的源代码,现在包括了在每次搜索中找到的类别和价格范围面元。这两种面元类型可以组合使用以缩小搜索结果,当前选中的面元以粗体突出显示。当所有选中任一类型时,该特定面元被移除,搜索结果再次扩大。

范围面元

查询时的提升

在第三章 执行查询 中,我们看到了如何在索引时间固定或动态地提升字段的的相关性。在查询时间动态改变权重也是可能的。

Hibernate Search DSL 中的所有查询类型都包括onFieldandField方法。对于每个查询类型,这两个子句也支持一个boostedTo方法,它接受一个weight因子作为float参数。无论该字段索引时的权重可能是什么,添加一个boostedTo子句就会将它乘以指示的数字:

...
luceneQuery = queryBuilder
      .phrase()
      .onField("name").boostedTo(2)
      .andField("description").boostedTo(2)
      .andField("supportedDevices.name")
      .andField("customerReviews.comments")
      .sentence(unquotedSearchString)
      .createQuery();
...

在本章的 VAPORware Marketplace 应用程序版本中,查询时的提升现在添加到了“确切短语”用例中。当用户用双引号括起他们的搜索字符串以通过短语而不是关键词进行搜索时,我们想要给App实体的名称和描述字段比正常情况下更多的权重。高亮显示的更改将这两个字段在索引时的权重加倍,但只针对确切短语查询,而不是所有查询类型。

设置查询的超时

我们一直在工作的这个示例应用程序有一个有限的测试数据集,只有十几款应用程序和几款设备。因此,只要你的计算机有合理的处理器和内存资源,搜索查询应该几乎立即运行。

然而,一个带有真实数据的应用程序可能涉及跨数百万个实体的搜索,你的查询可能存在运行时间过长的风险。从用户体验的角度来看,如果你不限制查询的执行时间,可能会导致应用程序响应缓慢。

Hibernate Search 提供了两种时间盒查询的方法。一种是通过FullTextQuery对象的limitExecutionTime方法:

...
hibernateQuery.limitExecutionTimeTo(2, TimeUnit.SECONDS);
...

这个方法会在指定的时间后优雅地停止查询,并返回它找到的所有结果直到那个点。第一个参数是时间单位数,第二个参数是时间单位类型(例如,微秒、毫秒、秒等)。前面的代码片段将尝试在搜索两秒后停止查询。

提示

查询运行后,你可以通过调用对象的hasPartialResults()方法来确定是否被中断。这个布尔方法如果在查询在自然结束之前超时就返回true

第二种方法,使用setTimeout()函数,在概念上和接受的参数上与第一种相似:

...
hibernateQuery.setTimeout(2, TimeUnit.SECONDS);
...

然而,这个方法适用于搜索在超时后应该完全失败,而不是像没发生过一样继续进行的情况。在前面的查询对象在运行两秒后会抛出QueryTimeoutException异常,并且不会返回在这段时间内找到的任何结果。

注意

请注意,这两种方法中,Hibernate Search 都会尽其所能尊重指定的一段时间。实际上,查询停止可能会需要一点额外的时间。

另外,这些超时设置只影响 Lucene 访问。一旦你的查询完成了对 Lucene 的搜索并开始从数据库中提取实际实体,超时控制就由 Hibernate ORM 而不是 Hibernate Search 来处理。

摘要

在本书的这一章,我们探讨了更多高级的技术来缩小搜索结果,提高匹配的相关性,以及提高性能。

现在我们可以使用 Lucene 过滤器来缩小匹配结果的一个固定子集。我们也看到了如何使用面向面的搜索在结果中动态识别子集。通过基于投影的查询,我们可以减少甚至消除实际数据库调用的需要。现在我们知道如何在查询时而非仅在索引时调整字段的相关性。最后但同样重要的是,我们现在能够为我们的查询设置时间限制,并优雅地处理搜索运行时间过长的情形。

在下一章中,我们将转向管理和维护的内容,学习如何配置 Hibernate Search 和 Lucene 以实现最佳性能。

第六章 系统配置和索引管理

在本章中,我们将查看 Lucene 索引的配置选项,并学习如何执行基本维护任务。我们将了解如何切换 Lucene 索引的自动和手动更新。我们将研究低延迟写操作、同步与异步更新以及其他性能优化选择。

我们将介绍如何为更好的性能对 Lucene 索引进行碎片整理和清理,以及如何完全不用接触硬盘存储来使用 Lucene。最后但并非最不重要的是,我们将接触到Luke这个强大的工具,用于在应用程序代码之外操作 Lucene 索引。

自动与手动索引

到目前为止,我们实际上并没有太多考虑实体索引的时间。毕竟,Hibernate Search 与 Hibernate ORM 紧密集成。默认情况下,附加组件在核心更新数据库时更新 Lucene。

然而,你有选择将这些操作解耦的选项,如果你愿意,可以手动索引。一些你可能考虑手动方法的常见情况如下:

  • 如果你能轻松地忍受在有限的时间内 Lucene 与数据库不同步,你可能想将索引操作推迟到非高峰时段,以在系统高峰使用期间减轻负载。

  • 如果你想使用条件索引,但又不习惯EntityIndexingInterceptor的实验性质(参见第四章,高级映射),你可以使用手动索引作为一种替代方法。

  • 如果你的数据库可能直接被不通过 Hibernate ORM 的过程更新,你必须定期手动更新 Lucene 索引,以保持它们与数据库同步。

要禁用自动索引,请在hibernate.cfg.xml(或使用 JPA 时的persistence.xml)中设置hibernate.search.indexing_strategy属性为manual,如下所示:

...
<property name="hibernate.search.indexing_strategy">manual</property>
...

单独更新

当自动索引被禁用时,手动索引操作是由FullTextSession对象上的方法驱动的(无论是传统的 Hibernate 版本还是 JPA 版本)。

添加和更新

这些方法中最重要的是index,它同时处理数据库侧的添加更新操作。此方法接受一个参数,是任何为 Hibernate Search 索引配置的实体类的实例。

本章的 VAPORware Marketplace 应用程序使用手动索引。StartupDataLoader在将 app 持久化到数据库后立即调用每个 app 的index

...
fullTextSession.save(theCloud);
fullTextSession.index(theCloud);
...

在 Lucene 侧,index方法在与数据库侧save方法相同的交易上下文中工作。只有在事务提交时才进行索引。在回滚事件中,Lucene 索引不受影响。

注意

手动使用index会覆盖任何条件索引规则。换句话说,index方法忽略与该实体类注册的任何EntityIndexingInterceptor

对于批量更新(请参阅批量更新部分),情况并非如此,但在考虑对单个对象进行手动索引时,这是需要记住的。调用index的代码需要先检查任何条件。

删除

从 Lucene 索引中删除实体的基本方法是purge。这个方法与index有点不同,因为你不需要向它传递一个要删除的对象实例。相反,你需要传递实体类引用和一个特定实例的 ID(即对应于@Id@DocumentId):

...
fullTextSession.purge(App.class, theCloud.getId());
fullTextSession.delete(theCloud);
...

Hibernate Search 还提供了purgeAll,这是一个方便的方法,用于删除特定实体类型的所有实例。这个方法也需要实体类引用,尽管显然不需要传递特定的 ID:

...
fullTextSession.purgeAll(App.class);
...

index一样,purgepurgeAll都在事务内操作。删除实际上直到事务提交才会发生。如果在回滚的情况下,什么也不会发生。

如果你想在事务提交之前真正地向 Lucene 索引中写入数据,那么无参数的flushToIndexes方法允许你这样做。如果你正在处理大量实体,并且想要在过程中释放内存(使用clear方法)以避免OutOfMemoryException,这可能很有用:

...
fullTextSession.index(theCloud);
fullTextSession.flushToIndexes();
fullTextSession.clear();
...

批量更新

单独添加、更新和删除实体可能会相当繁琐,而且如果你错过了某些东西,可能会出现错误。另一个选择是使用MassIndexer,它可以被认为是自动索引和手动索引之间的某种折中方案。

这个工具类仍然需要手动实例化和使用。然而,当它被调用时,它会一次性重建所有映射实体类的 Lucene 索引。不需要区分添加、更新和删除,因为该操作会抹掉整个索引,并从头开始重新创建它。

MassIndexer是通过FullTextSession对象的createIndexer方法实例化的。一旦你有一个实例,启动批量索引有两种方式:

  • start方法以异步方式索引,这意味着索引在后台线程中进行,而主线程的代码流程继续。

  • startAndWait方法以同步模式运行索引,这意味着主线程的执行将一直阻塞,直到索引完成。

当以同步模式运行时,你需要用 try-catch 块包装操作,以防主线程在等待时被中断:

...
try {
 fullTextSession.createIndexer().startAndWait();
} catch (InterruptedException e) {
   logger.error("Interrupted while wating on MassIndexer: "
      + e.getClass().getName() + ", " + e.getMessage());
}
...

提示

如果实际可行,当应用程序离线且不响应查询时,使用批量索引会更好。索引会将系统负载加重,而且 Lucene 与数据库相比会处于一个非常不一致的状态。

大规模索引与个别更新在两个方面有所不同:

  • MassIndexer操作不是事务性的。没有必要将操作包装在 Hibernate 事务中,同样,如果出现错误,你也不能依赖回滚。

  • MassIndexer确实支持条件索引(参考第四章,高级映射)。如果你为那个实体类注册了一个EntityIndexingInterceptor,它将被调用以确定是否实际索引特定实例。

    注意

    MassIndexer对条件索引的支持是在 Hibernate Search 的 4.2 代中添加的。如果你正在使用一个较老版本的应用程序,你需要将应用程序迁移到 4.2 或更高版本,以便同时使用EntityIndexingInterceptorMassIndexer

索引碎片化

随着时间的推移,对 Lucene 索引的更改会逐渐使其变得效率更低,就像硬盘可能会变得碎片化一样。当新的实体被索引时,它们会被放入一个与主索引文件分离的文件(称为片段)。当一个实体被删除时,它实际上仍然留在索引文件中,只是被标记为不可访问。

这些技术有助于使 Lucene 的索引尽可能适用于查询,但随着时间的推移,这会导致性能变慢。打开多个片段文件是慢的,并且可能会遇到操作系统对打开文件数量的限制。保留在索引中的已删除实体会使文件比必要的更膨胀。

将所有这些片段合并在一起,并真正清除已删除实体的过程称为优化。这个过程类似于对硬盘进行碎片整理。Hibernate Search 提供了基于手动或自动的基础上的索引优化机制。

手动优化

SearchFactory类提供了两种手动优化 Lucene 索引的方法。你可以在应用程序中的任何你喜欢的事件上调用这些方法。或者,你可能会公开它们,并从应用程序外部触发优化(例如,通过一个由夜间 cron 作业调用的 web 服务)。

您可以通过FullTextSession对象的getSearchFactory方法获得一个SearchFactory引用。一旦你有了这个实例,它的optimize方法将会碎片化所有可用的 Lucene 索引:

...
fullTextSession.getSearchFactory().optimize();
...

另外,您可以使用一个带有实体类参数的optimize重载版本。这个方法将优化限制在只对该实体的 Lucene 索引进行优化,如下所示:

...
fullTextSession.getSearchFactory().optimize(App.class);
...

注意

另一个选择是使用MassIndexer重新构建你的 Lucene 索引(参考大规模更新部分)。从零开始重建索引无论如何都会使其处于优化状态,所以如果你已经定期执行这种类型的维护工作,进一步的优化将是多余的。

一个非常手动的方法是使用 Luke 工具,完全不在你的应用程序代码中。请参阅本章末尾关于 Luke 的部分。

自动优化

一个更简单,但灵活性较低的方法是让 Hibernate Search 自动为你触发优化。这可以全局或针对每个索引执行。触发事件可以是 Lucene 更改的阈值数量,或者事务的阈值数量。

VAPORware Marketplace 应用程序的chapter6版本现在在其hibernate.cfg.xml文件中包含了以下四行:

<property name="hibernate.search.default.optimizer.operation_limit.max">
   1000
</property>
<property name="hibernate.search.default.optimizer.transaction_limit.max">
   1000
</property>
<property name="hibernate.search.App.optimizer.operation_limit.max">
   100
</property>
<property name="hibernate.search.App.optimizer.transaction_limit.max">
   100
</property>

最上面的两行,在属性名称中引用default,为所有 Lucene 索引建立了全局默认值。最后两行,引用App,是针对App实体的覆盖值。

注意

本章中的大多数配置属性可以通过将default子字符串替换为相关索引的名称,使其变为索引特定。

通常这是实体类的名称(例如,App),但如果你设置了该实体的@Indexed注解中的index元素,它也可以是一个自定义名称。

无论你是在全局还是索引特定级别操作,operation_limit.max指的是 Lucene 更改(即添加或删除)的阈值数量。transaction_limit.max指的是事务的阈值数量。

总的来说,此代码段配置了在 100 个事务或 Lucene 更改后对App索引进行优化。所有其他索引将在 1,000 个事务或更改后进行优化。

自定义优化器策略

你可以通过使用带有自定义优化策略的自动方法,享受到两全其美。本章的 VAPORware Marketplace 应用程序使用自定义策略,只在非高峰时段允许优化。这个自定义类扩展了默认优化器策略,但只允许在当前时间在午夜至凌晨 6 点之间时,基类进行优化:

public class NightlyOptimizerStrategy
      extendsIncrementalOptimizerStrategy {

 @Override
 public void optimize(Workspace workspace) {
      Calendar calendar = Calendar.getInstance();
      inthourOfDay = calendar.get(Calendar.HOUR_OF_DAY);
      if(hourOfDay>= 0 &&hourOfDay<= 6) {
 super.optimize(workspace);
      }
   }

}

提示

最简单的方法是扩展IncrementalOptimizerStrategy,并用你的拦截逻辑覆盖optimize方法。然而,如果你的策略与默认策略根本不同,那么你可以从自己的基类开始。只需让它实现OptimizerStrategy接口。

为了声明你自己的自定义策略,无论是在全局还是每个索引级别,都需要在hibernate.cfg.xml中添加一个hibernate.search.X.optimizer.implementation属性(其中Xdefault,或者是特定实体索引的名称):

...
<property name="hibernate.search.default.optimizer.implementation">
com.packtpub.hibernatesearch.util.NightlyOptimizerStrategy
</property>
...

选择索引管理器

索引管理器是一个负责将更改应用到 Lucene 索引的组件。它协调优化策略、目录提供者以及工作者后端(在本章后面部分介绍),还有各种其他底层组件。

休眠搜索自带两种索引管理器实现。默认的是基于directory-based的,在大多数情况下这是一个非常合理的选择。

另一个内置选项是近实时。它是一个从基于目录的索引管理器派生的子类,但设计用于低延迟的索引写入。而不是立即在磁盘上执行添加或删除,这个实现将它们排队在内存中,以便更有效地批量写入。

注意

近实时实现比基于目录的默认实现具有更好的性能,但有两个权衡。首先,当在集群环境中使用 Lucene 时,近实时实现是不可用的(参考第七章,高级性能策略)。其次,由于 Lucene 操作不会立即写入磁盘,因此在应用程序崩溃的情况下可能会永久丢失。

与本章中介绍的大多数配置属性一样,索引管理器可以在全局默认或每索引的基础上选择。区别在于是否包括default,或者实体索引名称(例如,App)在属性中:

...
<property name="hibernate.search.default.indexmanager">
   directory-based
</property>
<property name="hibernate.search.App.indexmanager">
   near-real-time
</property>
...

可以编写自己的索引管理器实现。为了更深入地了解索引管理器是如何工作的,请查看提供的两个内置实现源代码。基于目录的管理器由DirectoryBasedIndexManager实现,近实时管理器由NRTIndexManager实现。

提示

编写自定义实现的一种简单方法是继承两个内置选项中的一个,并根据需要重写方法。如果您想从头开始创建自定义索引管理器,那么它需要实现org.hibernate.search.indexes.spi.IndexManager接口。

在全局或每索引级别应用自定义索引管理器与内置选项相同。只需将适当的属性设置为您的实现的全限定类名(例如,com.packtpub.hibernatesearch.util.MyIndexManager),而不是directory-basednear-real-time字符串。

配置工作者

索引管理器协调的组件类型之一是工作者,它们负责对 Lucene 索引进行实际的更新。

如果您在集群环境中使用 Lucene 和 Hibernate Search,许多配置选项是在工作者级别设置的。我们将在第七章,高级性能策略中更全面地探讨这些内容。然而,在任何环境中都提供了三个关键的配置选项。

执行模式

默认情况下,工作者执行 Lucene 更新同步。也就是说,一旦开始更新,主线的执行就会被阻塞,直到更新完成。

工人可能被配置为以异步方式更新,这是一种“启动并忘记”的模式,它会创建一个单独的线程来执行工作。优点是主线程将更具响应性,且能更高效地处理工作负载。缺点是在非常短暂的时间内数据库和索引可能会不同步。

执行模式在hibernate.cfg.xml(或persistence.xml对于 JPA)中声明。可以用default子字符串建立全局默认值,而每个实体的配置可以用实体索引名称(例如,App)来设置:

...
<property name="hibernate.search.default.worker.execution">
   sync
</property>
<property name="hibernate.search.App.worker.execution">
   async
</property>
...

线程池

默认情况下,工人在只有一个线程中更新,要么是同步模式下的主线程,要么是异步模式下单独创建的一个线程。然而,你有创建一个更大线程池来处理工作的选项。这个池可能适用于全局默认级别,也可能特定于某个索引:

...
<property name="hibernate.search.default.worker.thread_pool.size">
   2
</property>
<property name="hibernate.search.App.worker.thread_pool.size">
   5
</property>
...

提示

由于 Lucene 索引在更新操作期间以这种方式被锁定,使用许多并行线程通常不会提供你可能会期望的性能提升。然而,在调整和负载测试应用程序时尝试是有价值的。

缓冲队列

挂起的工作会保存在队列中,等待线程空闲时处理。默认情况下,这个缓冲区的大小是无限的,至少在理论上如此。实际上,它受到可用系统内存量的限制,如果缓冲区增长过大,可能会抛出OutOfMemoryExeception

因此,为这些缓冲区设置一个全局大小或每个索引大小的限制是一个好主意。

...
<property name="hibernate.search.default.worker.buffer_queue.max">
   50
</property>
<property name="hibernate.search.App.worker.buffer_queue.max">
   250
</property>
...

当一个缓冲区达到其索引允许的最大大小时,将由创建它们的线程执行额外操作。这会阻塞执行并减慢性能,但确保应用程序不会运行 out of memory。实验找到一个应用程序的平衡阈值。

选择和配置目录提供程序

内置的索引管理器都使用了一个子类DirectoryBasedIndexManager。正如其名,它们都利用了 Lucene 的抽象类Directory,来管理索引存储的形式。

在第七章中,我们将探讨一些特殊目录实现,这些实现是为集群环境量身定做的。然而,在单服务器环境中,内置的两种选择是文件系统存储和内存中的存储。

Filesystem-based

默认情况下,Lucene 索引存储在 Java 应用程序的当前工作目录中。对于这种安排,无需进行任何配置,但在 VAPORware Marketplace 应用程序的所有版本中,都明确设置了这个属性在hibernate.cfg.xml(或persistence.xml)中:

...
<property name="hibernate.search.default.directory_provider">
   filesystem
</property>
...

正如我们在本章中看到的其他配置属性一样,你可以用特定的索引名称(例如,App)替换default

当使用基于文件系统的索引时,您可能希望使用一个已知的固定位置,而不是当前工作目录。您可以使用 indexBase 属性指定相对路径或绝对路径。在我们见过的所有 VAPORware Marketplace 版本中,Lucene 索引都存储在每个 Maven 项目的 target 目录下,这样 Maven 在每次全新构建之前会删除它们:

...
<property name="hibernate.search.default.indexBase">
   target/lucenceIndex
</property>
...

锁策略

所有 Lucene 目录实现当向其写入时都会锁定它们的索引,以防止多个进程或线程同时向其写入导致的损坏。有四种锁策略可供选择,您可以通过将 hibernate.search.default.locking_strategy 属性设置为这些字符串之一来指定一个:

  • native: 当没有指定锁策略属性时,基于文件系统的目录默认采用的策略。它依赖于本地操作系统级别的文件锁,因此如果您的应用程序崩溃,索引锁仍然会被释放。然而,这种策略不适用于您的索引存储在远程网络共享驱动器上时。

  • simple: 这种策略依赖于 JVM 来处理文件锁。当您的 Lucene 索引存储在远程共享驱动器上时,使用这种策略更安全,但如果应用程序崩溃或被杀死,锁不会被干净地释放。

  • single: 这种策略不会在文件系统上创建锁文件,而是使用内存中的 Java 对象(类似于多线程 Java 代码中的 synchronized 块)。对于单 JVM 应用程序,无论索引文件在哪里,这种方法都工作得很好,而且在崩溃后没有锁被释放的问题。然而,这种策略只有在您确信没有任何其他外部 JVM 进程可能会写入您的索引文件时才是可行的。

  • none: 根本不使用锁。这不是一个推荐的选项。

提示

为了删除未干净释放的锁,请使用本章使用 Luke 工具部分探索的 Luke 工具。

基于 RAM

出于测试和演示目的,我们这本书中的 VAPORware Marketplace 应用程序一直使用内存中的 H2 数据库。每次应用程序启动时都会重新创建它,应用程序停止时会摧毁它,在此过程中没有任何持久化存储。

Lucene 索引能够以完全相同的方式工作。在本章示例应用程序的版本中,hibernate.cfg.xml 文件已经被修改以将其索引存储在 RAM 中,而不是文件系统上:

...
<property name="hibernate.search.default.directory_provider">
   ram
</property>
...

注意

基于 RAM 的目录提供者在其 Hibernate SessionFactory(或 JPA EntityManagerFactory)创建时初始化其 Lucene 索引。请注意,当你关闭这个工厂时,它会摧毁你所有的索引!

使用现代依赖注入框架时,这不应该是一个问题,因为框架会在内存中保持您的工厂实例,并在需要时可用。即使在我们的基础示例应用程序中,我们也为此原因在 StartupDataLoader 类中存储了一个单例 SessionFactory

内存中的索引似乎能提供更好的性能,在您的应用程序调整中尝试一下可能是值得的。然而,通常不建议在生产环境中使用基于 RAM 的目录提供程序。

首先,当数据集很大时,很容易耗尽内存并导致应用程序崩溃。另外,每次重新启动时,您的应用程序都必须从头开始重建索引。由于只有创建内存索引的 JVM 才能访问该内存,因此无法使用集群。最后但同样重要的是,基于文件系统的目录提供程序已经智能地使用了缓存,其性能出奇地与基于 RAM 的提供程序相当。

话虽如此,基于 RAM 的提供程序是测试应用程序的常见方法。单元测试可能涉及相对较小的数据集,因此耗尽内存不是问题。另外,在每次单元测试之间完全且干净地销毁索引可能更是一个特性而非缺点。

提示

基于 RAM 的目录提供程序默认使用 single 锁定策略,而且真的没有改变它的意义。

使用 Luke 工具

Hibernate ORM 为您的应用程序代码提供了与数据库交互所需的大部分功能。然而,您可能仍然需要使用某种 SQL 客户端,在应用程序代码的上下文之外手动操作数据库。

同样,在没有编写相关代码的情况下手动探索 Lucene 索引可能很有用。Luke(code.google.com/p/luke)是一个非常有用的工具,它为 Lucene 提供了这一功能。您可以使用 Luke 浏览索引、测试查询,并执行诸如删除未正常释放的索引锁等有用任务。

Luke 的下载文件是一个单片式的可执行 JAR 文件。双击 JAR 文件,或者从控制台提示符执行它,会弹出一个图形界面和一个提示您索引位置的输入框,如下面的屏幕快照所示:

使用 Luke 工具

前一个屏幕快照显示了 Luke 启动时的界面。不幸的是,Luke 只能访问基于文件系统的索引,而不能访问本章中使用基于 RAM 的索引。所以在这段示例中,Luke 指向了 chapter5 代码文件目录的 Maven 项目工作区。App 实体的索引位于 target/luceneIndex/com.packtpub.hibernatesearch.domain.App

请注意打开索引对话框顶部附近的强制解锁,如果 锁定复选框。如果您有一个索引文件锁没有干净释放(参考锁定策略部分),则可以通过勾选此复选框并打开索引来解决问题。

一旦您打开了一个 Lucene 索引,Luke 就会显示关于索引文档(即实体)数量的各类信息(即,碎片化)和其他详细信息,如下面的屏幕截图所示:

使用 Luke 工具

从工具栏顶部的工具菜单中,您可以选择执行诸如检查索引是否损坏或手动优化(即,去碎片化)等基本维护任务。这些操作最好在非高峰时段或全面停机窗口期间执行。

使用 Luke 工具

文档标签允许您逐一浏览实体,这可能有一些有限的用途。更有趣的是搜索标签,它允许您使用自由形式的 Lucene 查询来探索您的索引,如下面的屏幕截图所示:

使用 Luke 工具

完整的 Lucene API 超出了本书的范围,但这里有一些基础知识来帮助您入门:

  • 搜索表达式的形式是字段名和期望值,由冒号分隔。例如,要搜索business类别的应用程序,请使用搜索表达式category:business

  • 相关项目可以用实体字段名,后跟一个点,后跟相关项目内的字段名来指定。在上面的屏幕截图中,我们通过使用搜索表达式supportedDevices.name:xphone来搜索所有支持xPhone设备的应用程序。

  • 记住,默认分析器在索引过程中将术语转换为小写。所以如果你想搜索xPhone,例如,请确保将其输入为xphone

如果您双击找到的搜索结果之一,Luke 会切换到文档标签,并加载相关文档。点击重建&编辑按钮来检查该实体的字段,如下面的屏幕截图所示:

使用 Luke 工具

浏览这些数据将让您了解分析器如何解析您的实体。单词将被过滤掉,除非您配置了@Field注解相反(正如我们用sorting_name所做的那样),否则文本将被分词。如果 Hibernate Search 查询没有返回您期望的结果,Luke 中浏览字段数据可以帮助您发现问题。

摘要

在本章中,我们了解了如何手动更新 Lucene 索引,一次一个实体对象或批量更新,作为让 Hibernate Search 自动管理更新的一种替代方式。我们了解了 Lucene 更新操作积累的碎片,以及如何基于手动或自动方法进行优化。

我们探索了 Lucene 的各种性能调优选项,从低延迟写入到多线程异步更新。我们现在知道如何配置 Hibernate Search,在文件系统或 RAM 上创建 Lucene 索引,以及为什么您可能会选择其中之一。最后,我们使用 Luke 工具来检查和执行维护任务,而无需通过应用程序的 Hibernate Search 代码来操作 Lucene 索引。

在下一章中,我们将探讨一些高级策略,以提高您的应用程序的性能。这将包括回顾到目前为止介绍的性能提示,然后深入探讨服务器集群和 Lucene 索引分片。

第七章 高级性能策略

在本章中,我们将探讨一些高级策略,通过代码以及服务器架构来提高生产应用程序的性能和可伸缩性。我们将探讨运行应用程序的多节点服务器集群选项,以分布式方式分散和处理用户请求。我们还将学习如何使用分片来使我们的 Lucene 索引更快且更易于管理。

通用建议

在深入探讨一些提高性能和可伸缩性的高级策略之前,让我们简要回顾一下书中已经提到的某些通用性能优化建议。

  • 当为 Hibernate Search 映射实体类时,使用@Field注解的可选元素去除 Lucene 索引中的不必要膨胀(参见第二章,映射实体类):

    • 如果你确实不使用索引时提升(参见第四章,高级映射),那么就没有理由存储实现此功能所需的信息。将norms元素设置为Norms.NO

    • 默认情况下,除非将store元素设置为Store.YESStore.COMPRESS(参见第五章,高级查询),否则基于投影的查询所需的信息不会被存储。如果你有不再使用的基于投影的查询,那么在进行清理时删除这个元素。

  • 使用条件索引(参见第四章,高级映射)和部分索引(参见第二章,映射实体类)来减小 Lucene 索引的大小。

  • 依赖于过滤器在 Lucene 层面缩小结果,而不是在数据库查询层面使用WHERE子句(参见第五章,高级查询)。

  • 尽可能尝试使用基于投影的查询(参见第五章,高级查询),以减少或消除对数据库调用的需求。请注意,随着数据库缓存的提高,这些好处可能并不总是值得增加的复杂性。

  • 测试各种索引管理器选项(参见第六章,系统配置和索引管理),例如尝试近实时索引管理器或async工作执行模式。

在集群中运行应用程序

在生产环境中使现代 Java 应用程序扩展通常涉及在服务器实例的集群中运行它们。Hibernate Search 非常适合集群环境,并提供了多种配置解决方案的方法。

简单集群

最直接的方法需要非常少的 Hibernate Search 配置。只需为托管您的 Lucene 索引设置一个文件服务器,并使其可供您集群中的每个服务器实例使用(例如,NFS、Samba 等):

简单集群

具有多个服务器节点的简单集群,使用共享驱动上的公共 Lucene 索引

集群中的每个应用程序实例都使用默认的索引管理器,以及常用的filesystem目录提供程序(参见第六章,系统配置和索引管理)。

在这种安排中,所有的服务器节点都是真正的对等节点。它们各自从同一个 Lucene 索引中读取,无论哪个节点执行更新,那个节点就负责写入。为了防止损坏,Hibernate Search 依赖于锁定策略(即“简单”或“本地”,参见第六章,系统配置和索引管理)同时写入被阻止。

提示

回想一下,“近实时”索引管理器与集群环境是不兼容的。

这种方法的优点是两方面的。首先是简单性。涉及的步骤仅包括设置一个文件系统共享,并将每个应用程序实例的目录提供程序指向同一位置。其次,这种方法确保 Lucene 更新对集群中的所有节点立即可见。

然而,这种方法的严重缺点是它只能扩展到一定程度。非常小的集群可能运行得很好,但是尝试同时访问同一共享文件的更多节点最终会导致锁定争用。

另外,托管 Lucene 索引的文件服务器是一个单点故障。如果文件共享挂了,那么在整个集群中的搜索功能会立即灾难性地崩溃。

主从集群

当您的可扩展性需求超出简单集群的限制时,Hibernate Search 提供了更高级别的模型供您考虑。它们之间的共同点是主节点负责所有 Lucene 写操作的理念。

集群还可能包括任何数量的从节点。从节点仍然可以初始化 Lucene 更新,应用程序代码实际上无法区分。然而,在底层,从节点将这项工作委托给主节点实际执行。

目录提供程序

在主从集群中,仍然有一个“总体主”Lucene 索引,它在逻辑上与所有节点区分开来。这个索引可能是基于文件系统的,正如它在一个简单集群中一样。然而,它可能是基于 JBoss Infinispan(www.jboss.org/infinispan),一个由同一公司主要赞助 Hibernate 开发的开源内存中 NoSQL 数据存储:

  • 基于文件系统的方法中,所有节点都保留它们自己的 Lucene 索引的本地副本。主节点实际上在整体主索引上执行更新,所有节点定期从那个整体主索引中读取以刷新它们的本地副本。

  • Infinispan 基于的方法中,所有节点都从 Infinispan 索引中读取(尽管仍然建议将写操作委派给主节点)。因此,节点不需要维护它们自己的本地索引副本。实际上,由于 Infinispan 是一个分布式数据存储,索引的某些部分将驻留在每个节点上。然而,最好还是将整个索引视为一个单独的实体。

工作端后端

奴隶节点将写操作委派给主节点的两种可用机制:

  • JMS消息队列提供程序创建一个队列,奴隶节点将有关 Lucene 更新请求的详细信息发送到这个队列。主节点监控这个队列,检索消息,并实际执行更新操作。

  • 您可以选择用JGroupswww.jgroups.org)替换 JMS,这是一个用于 Java 应用程序的开源多播通信系统。它的优点是速度更快,更立即。消息实时接收,同步而不是异步。

    然而,JMS 消息通常在等待检索时持久化到磁盘上,因此可以在应用程序崩溃的情况下恢复并稍后处理。如果您使用 JGroups 并且主节点离线,那么在停机期间奴隶节点发送的所有更新请求都将丢失。为了完全恢复,您可能需要手动重新索引您的 Lucene 索引。

    Worker backends

    一个基于文件系统或 Infinispan 的目录提供程序和基于 JMS 或 JGroups 的工作程序的主从集群。请注意,当使用 Infinispan 时,节点不需要它们自己的单独索引副本。

一个工作示例

要尝试所有可能的集群策略,需要查阅 Hibernate Search 参考指南,以及 Infinispan 和 JGroups 的文档。然而,我们将从实现使用文件系统和 JMS 方法的集群开始,因为其他所有内容都只是这个标准主题的变体。

本章版本的 VAPORware Marketplace 应用摒弃了我们一直使用的 Maven Jetty 插件。这个插件非常适合测试和演示目的,但它只适用于运行单个服务器实例,而我们现在需要同时运行至少两个 Jetty 实例。

为了实现这一点,我们将以编程方式配置和启动 Jetty 实例。如果你在chapter7项目的src/test/java/目录下查看,现在有一个ClusterTest类。它为 JUnit 4 设计,以便 Maven 可以在构建后自动调用其testCluster()方法。让我们看看那个测试用例方法的相关部分:

...
String projectBaseDirectory = System.getProperty("user.dir");
...
Server masterServer = new Server(8080);
WebAppContextmasterContext = new WebAppContext();
masterContext.setDescriptor(projectBaseDirectory +
   "/target/vaporware/WEB-INF/web.xml");
...
masterServer.setHandler(masterContext);
masterServer.start();
...
Server slaveServer = new Server(8181);
WebAppContextslaveContext = new WebAppContext();
slaveContext.setDescriptor(projectBaseDirectory +
   "/target/vaporware/WEB-INF/web-slave.xml");
...
slaveServer.setHandler(slaveContext);
slaveServer.start();
...

尽管所有这些都在一台物理机器上运行,但我们为了测试和演示目的模拟了一个集群。一个 Jetty 服务器实例在端口 8080 上作为主节点启动,另一个 Jetty 服务器在端口 8181 上作为从节点启动。这两个节点之间的区别在于,它们使用不同的web.xml文件,在启动时相应地加载不同的监听器。

在这个应用程序的先前版本中,一个StartupDataLoader类处理了所有数据库和 Lucene 的初始化。现在,两个节点分别使用MasterNodeInitializerSlaveNodeInitializer。这些依次从名为hibernate.cfg.xmlhibernate-slave.cfg.xml的不同文件加载 Hibernate ORM 和 Hibernate Search 设置。

提示

有许多方法可以配置一个应用程序以作为主节点或从节点实例运行。而不是构建不同的 WAR,具有不同的web.xmlhibernate.cfg.xml版本,你可能会使用依赖注入框架根据环境中的某个内容加载正确的设置。

Hibernate 的两种版本都设置了config文件中的以下 Hibernate Search 属性:

  • hibernate.search.default.directory_provider:在之前的章节中,我们看到这个属性被设置为filesystemram。之前讨论过的另一个选项是infinispan

    在这里,我们在主节点和从节点上分别使用filesystem-masterfilesystem-slave。这两个目录提供者都与常规的filesystem类似,并且与迄今为止我们看到的所有相关属性(如位置、锁定策略等)一起工作。

    然而,“主”变体包含了定期刷新整体主 Lucene 索引的功能。而“从”变体则相反,定期用整体主内容刷新其本地副本。

  • hibernate.search.default.indexBase:正如我们之前在单节点版本中看到的,这个属性包含了本地 Lucene 索引的基础目录。由于我们这里的示例集群在同一台物理机器上运行,主节点和从节点对这个属性使用不同的值。

  • hibernate.search.default.sourceBase:这个属性包含了整体主 Lucene 索引的基础目录。在生产环境中,这将是某种共享文件系统,挂在并可供所有节点访问。在这里,节点在同一台物理机器上运行,所以主节点和从节点对这个属性使用相同的值。

  • hibernate.search.default.refresh:这是索引刷新之间的间隔(以秒为单位)。主节点在每个间隔后刷新整体主索引,奴隶节点使用整体主索引刷新它们自己的本地副本。本章的 VAPORware Marketplace 应用程序使用 10 秒的设置作为演示目的,但在生产环境中这太短了。默认设置是 3600 秒(一小时)。

为了建立一个 JMS 工作后端,奴隶节点需要三个额外的设置:

  • hibernate.search.default.worker.backend:将此值设置为jms。默认值lucene在之前的章节中已经应用,因为没有指定设置。如果你使用 JGroups,那么它将被设置为jgroupsMasterjgroupsSlave,这取决于节点类型。

  • hibernate.search.default.worker.jms.connection_factory:这是 Hibernate Search 在 JNDI 中查找你的 JMS 连接工厂的名称。这与 Hibernate ORM 使用connection.datasource属性从数据库检索 JDBC 连接的方式类似。

    在这两种情况下,JNDI 配置都是特定于你的应用程序运行的应用服务器。要了解 JMS 连接工厂是如何设置的,请查看src/main/webapp/WEB-INF/jetty-env.xml这个 Jetty 配置文件。在这个示例中我们使用 Apache ActiveMQ,但任何兼容 JMS 的提供商都会同样适用。

  • hibernate.search.default.worker.jms.queue:从奴隶节点向 Lucene 发送写请求的 JMS 队列的 JNDI 名称。这也是在应用服务器级别配置的,紧挨着连接工厂。

使用这些工作后端设置,奴隶节点将自动向 JMS 队列发送一条消息,表明需要 Lucene 更新。为了看到这种情况的发生,新的MasterNodeInitializerSlaveNodeInitializer类各自加载了一半的通常测试数据集。如果我们所有的测试实体最终都被一起索引,并且可以从任一节点运行的搜索查询中检索到它们,那么我们就会知道我们的集群运行正常。

尽管 Hibernate Search 会自动从奴隶节点向 JMS 队列发送消息,但让主节点检索这些消息并处理它们是你的责任。

在 JEE 环境中,你可能会使用消息驱动 bean,正如 Hibernate Search 文档所建议的那样。Spring 也有一个可以利用的任务执行框架。然而,在任何框架中,基本思想是主节点应该产生一个后台线程来监控 JMS 队列并处理其消息。

本章的 VAPORware Marketplace 应用程序包含一个用于此目的的QueueMonitor类,该类被包装在一个Thread对象中,由MasterNodeInitializer类产生。

要执行实际的 Lucene 更新,最简单的方法是创建您自己的自定义子类AbstractJMSHibernateSearchController。我们的实现称为QueueController,所做的只是包装这个抽象基类。

当队列监视器从 JMS 队列中接收到javax.jms.Message对象时,它只是原样传递给控制器的基类方法onMessage。那个内置方法为我们处理 Lucene 更新。

注意

正如您所看到的,主从集群方法涉及的内容比简单集群要多得多。然而,主从方法在可扩展性方面提供了巨大的优势。

它还减少了单点故障的风险。确实,这种架构涉及一个单一的“主”节点,所有 Lucene 写操作都必须通过这个节点。然而,如果主节点宕机,从节点仍然可以继续工作,因为它们的搜索查询针对的是自己的本地索引副本。此外,更新请求应该由 JMS 提供商持久化,以便在主节点重新上线后,这些更新仍然可以执行。

由于我们程序化地启动 Jetty 实例,而不是通过 Maven 插件,因此我们将不同的目标传递给每个 Maven 构建。对于chapter7项目,您应该像以下这样运行 Maven:

mvn clean compile war:exploded test

您将能够通过http://localhost:8080访问“主”节点,通过http://localhost:8181访问“从”节点。如果您在主节点启动后立即发送一个搜索查询,那么您将看到它只返回预期结果的一半!然而,在几秒钟内,从节点通过 JMS 更新。数据集的两个部分将合并并在整个集群中可用。

分片 Lucene 索引

正如您可以在集群中的多个节点之间平衡应用程序负载一样,您还可以通过一个称为分片(sharding)的过程将 Lucene 索引拆分。如果您的索引变得非常大,出于性能原因,您可能会考虑进行分片,因为较大的索引文件比小型分片索引和优化需要更长的时间。

如果您的实体适合于分区(例如,按语言、地理区域等),分片可能会提供额外的优势。如果您能够可预测地将查询引导到特定的适当分片,性能可能会得到改善。此外,当您能够在物理位置不同的地方存储“敏感”数据时,有时会让律师感到高兴。

尽管它的数据集非常小,但本章的 VAPORware Marketplace 应用程序现在将其App索引分成两个分片。hibernate.cfg.xml中的相关行类似于以下内容:

...
<property
   name="hibernate.search.default.sharding_strategy.nbr_of_shards">
      2
</property>
...

与所有包含子字符串default的其他 Hibernate Search 属性一样,这是一个全局设置。可以通过用索引名称(例如App)替换default来使其特定于索引。

注意

这个确切的行出现在hibernate.cfg.xml(由我们的“主”节点使用)和hibernate-slave.cfg.xml(由我们的“从”节点使用)中。在集群环境中运行时,你的分片配置应与所有节点匹配。

当一个索引被分成多个分片时,每个分片都包括正常的索引名称后面跟着一个数字(从零开始)。例如,是com.packtpub.hibernatesearch.domain.App.0,而不仅仅是com.packtpub.hibernatesearch.domain.App。这张截图展示了我们双节点集群的 Lucene 目录结构,在两个节点都配置为两个分片的情况下运行中:

分片 Lucene 索引

集群中运行的分片 Lucene 索引的一个示例(注意每个App实体目录的编号)

正如分片在文件系统上编号一样,它们可以在hibernate.cfg.xml中按编号单独配置。例如,如果你想将分片存储在不同的位置,你可能如下设置属性:

...
<property name="hibernate.search.App.0.indexBase">
   target/lucenceIndexMasterCopy/EnglishApps
</property>
<property name="hibernate.search.App.1.indexBase">
   target/lucenceIndexMasterCopy/FrenchApps
</property>
...

当对实体执行 Lucene 写操作时,或者当搜索查询需要从实体的索引中读取时,分片策略确定使用哪个分片。

如果你只是分片以减少文件大小,那么默认策略(由org.hibernate.search.store.impl.IdHashShardingStrategy实现)完全没问题。它使用每个实体的 ID 来计算一个唯一的哈希码,并将实体在分片之间大致均匀地分布。因为哈希计算是可复制的,策略能够将实体的未来更新引导到适当的分片。

要创建具有更复杂逻辑的自定义分片策略,你可以创建一个新子类,继承自IdHashShardingStrategy,并按需调整。或者,你可以完全从零开始,创建一个实现org.hibernate.search.store.IndexShardingStrategy接口的新类,或许可以参考IdHashShardingStrategy的源代码作为指导。

总结

在本章中,我们学习了如何在现代分布式服务器架构中与应用程序一起工作,以实现可扩展性和更好的性能。我们看到了一个使用基于文件系统的目录提供程序和基于 JMS 的后端实现的集群,现在有了足够的知识去探索涉及 Inifinispan 和 JGroups 的其他方法。我们使用了分片将 Lucene 索引分成更小的块,并知道如何实施自己的自定义分片策略。

这带我们结束了与 Hibernate Search 的这次小冒险!我们已经涵盖了关于 Hibernate、Lucene 和 Solr 以及搜索的一般性关键概念。我们学会了如何将我们的数据映射到搜索索引中,在运行时查询和更新这些索引,并将其安排在给定项目的最佳架构中。这一切都是通过一个示例应用程序完成的,这个应用程序随着我们的知识从简单到复杂一路成长。

学无止境。Hibernate Search 可以与 Solr 的数十个组件协同工作,以实现更高级的功能,同时也能与新一代的“NoSQL”数据存储集成。然而,现在你已经拥有了足够的核心知识,可以独立探索这些领域,如果你愿意的话。下次再见,感谢您的阅读!您可以在 steveperkins.net 上找到我,我很乐意收到您的来信。

posted @ 2024-05-24 10:54  绝不原创的飞龙  阅读(37)  评论(0编辑  收藏  举报