在过去的十年里,用户已经期望在搜索数据时软件能够高度智能。仅仅使搜索不区分大小写、作为子字符串查找关键词或其他基本的SQL技巧已经不够了。
搜索应该能够解析单词并理解它们可能如何相互连接。如果你搜索单词development,那么搜索应该能够理解这个词与developer有关联,尽管这两个单词都不是彼此的子字符串。
最重要的是,搜索应该要友好。当我们在网上论坛中发布东西,把“there”、“they're”和“their”这几个单词弄错了,人们可能只会批评我们的语法。相比之下,搜索应该能够理解我们的拼写错误,并且对此保持冷静!当搜索能够令人愉快地给我们带来惊喜,似乎比我们自己更理解我们在寻找的真实含义时,搜索表现得最好。
这本书的目的是介绍和探索HibernateSearch,这是一个用于向我们的自定义应用程序添加现代搜索功能的软件包,而无需从头开始发明。因为程序员通常通过查看真实代码来学习最佳,所以这本书围绕一个示例应用程序展开。我们将随着书的进展而坚持这个应用程序,并在每个章节中引入新概念时丰富它。
这个搜索功能的真正大脑是ApacheLucene,这是一个用于数据索引和搜索的开源软件库。Lucene是一个有着丰富创新历史的成熟Java项目,尽管它也被移植到了其他编程语言中。它被广泛应用于各行各业,从迪士尼到推特的知名用户都采用了它。
HibernateSearch是Lucene和可选Solr组件的薄层封装。它扩展了核心的HibernateORM,这是Java持久性最广泛采用的对象/关系映射框架。
下面的图表展示了所有这些组件之间的关系:
最终,HibernateSearch扮演两个角色:
首先,它将Hibernate数据对象转换为Lucene可以用来构建搜索索引的信息
朝着相反的方向前进,它将Lucene搜索的结果转换成熟悉的Hibernate格式
从一个程序员的角度来看,他或她正以通常的方式使用Hibernate映射数据。搜索结果以与正常Hibernate数据库查询相同的格式返回。HibernateSearch隐藏了与Lucene的大部分底层管道。
第一章,你的第一个应用,直接深入创建一个HibernateSearch应用,一个在线软件应用目录。我们将创建一个实体类并为其准备搜索,然后编写一个Web应用来执行搜索并显示结果。我们将逐步了解如何设置带有服务器、数据库和构建系统的应用程序,并学习如何用其他选项替换这些组件。
第二章,映射实体类,在示例应用程序中添加了更多的实体类,这些类通过注解来展示HibernateSearch映射的基本概念。在本章结束时,您将了解如何为HibernateSearch使用映射最常见的实体类。
第三章,执行查询,扩展了示例应用程序的查询,以使用新的映射。在本章结束时,您将了解HibernateSearch查询的最常见用例。到这个阶段,示例应用程序将具备足够的功能,类似于许多HibernateSearch生产环境的用途。
第五章,高级查询,更深入地探讨了在第第三章,执行查询中介绍的查询概念,解释了如何通过投影和结果转换获得更快的性能。本章探讨了分面搜索,以及原生LuceneAPI的介绍。到本章结束时,您将对HibernateSearch提供的查询功能有更坚实的基础。示例市场应用程序现在将使用更轻量级的、基于投影的搜索,并支持按类别组织搜索结果。
第六章,系统配置和索引管理,介绍了Lucene索引管理,并提供了一些高级配置选项的概览。本章详细介绍了其中一些更常见的选项,并提供了足够的背景知识,使我们能够独立探索其他选项。在本章结束时,你将能够执行标准的管理任务,对HibernateSearch使用的Lucene索引进行管理,并理解通过配置选项为HibernateSearch提供额外功能的能力。
使用本书中的示例代码,你需要一台安装有Java开发工具包(版本1.6或更高)的计算机。你还需要安装ApacheMaven,或者安装有Maven插件的Java集成开发环境(IDE),如Eclipse。
本书的目标读者是希望为他们的应用程序添加搜索功能的Java开发者。本书的讨论和代码示例假设读者已经具备了Java编程的基本知识。对HibernateORM、JavaPersistenceAPI(JPA2.0)或ApacheMaven的先验知识会有帮助,但不是必需的。
在本书中,你会发现有几种不同信息的文本样式。以下是一些这些样式的示例及其含义的解释。
文本中的代码词汇如下所示:"id字段被同时注解为@Id和@GeneratedValue"。
一段代码如下所示:
@Column(length=1000)@FieldprivateStringdescription;任何命令行输入或输出如下所示:
mvnarchetype:generate-DgroupId=com.packpub.hibernatesearch.chapter1-DartifactId=chapter1-DarchetypeArtifactId=maven-archetype-webapp注意警告或重要说明以这样的盒子出现。
小贴士和小技巧如下所示。
来自我们读者的反馈总是受欢迎的。让我们知道你对这本书的看法——你喜欢或可能不喜欢的地方。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
如果您想给我们发送一般性反馈,只需发送一封电子邮件到
既然你已经拥有了一本Packt书籍,我们有很多东西可以帮助你充分利用你的购买。
我们感谢您在保护我们的作者和我们提供有价值内容的能力方面所提供的帮助。
如果您在阅读本书的过程中遇到任何问题,可以通过
为了探索HibernateSearch的能力,我们将使用对经典“Java宠物店”示例应用程序的一个变化。我们版本,“VAPORwareMarketplace”,将是一个在线软件应用程序目录。想想苹果、谷歌、微软、Facebook以及……好吧,现在几乎所有其他公司都在运营这样的商店。
我们的应用程序市场将给我们提供大量以不同方式搜索数据的机会。当然,像大多数产品目录一样,有标题和描述。然而,软件应用程序涉及一组更广泛的数据点,如类型、版本和支持的设备。这些不同的方面将让我们看看HibernateSearch提供的许多功能。
在高层次上,在应用程序中整合HibernateSearch需要以下三个步骤:
向你的实体类中添加信息,以便Lucene知道如何索引它们。
设置你的项目,以便在最初就拥有HibernateSearch所需的依赖和配置。
在未来的项目中,在我们有了相当基本的了解之后,我们可能从这个第三个项目点开始。然而,现在,让我们直接进入一些代码!
为了保持简单,我们这个应用程序的第一个版本将只包括一个实体类。这个App类描述了一个软件应用程序,是所有其他实体类都将与之关联的中心实体。不过,现在,我们将给一个“应用程序”提供三个基本数据点:
一个名称
marketplace网站上显示的图片
一段长描述
下面的Java代码:
packagecom.packtpub.hibernatesearch.domain;importjavax.persistence.Column;importjavax.persistence.Entity;importjavax.persistence.GeneratedValue;importjavax.persistence.Id;@EntitypublicclassApp{@Id@GeneratedValueprivateLongid;@ColumnprivateStringname;@Column(length=1000)privateStringdescription;@ColumnprivateStringimage;publicApp(){}publicApp(Stringname,Stringimage,Stringdescription){this.name=name;this.image=image;this.description=description;}publicLonggetId(){returnid;}publicvoidsetId(Longid){this.id=id;}publicStringgetName(){returnname;}publicvoidsetName(Stringname){this.name=name;}publicStringgetDescription(){returndescription;}publicvoidsetDescription(Stringdescription){this.description=description;}publicStringgetImage(){returnimage;}publicvoidsetImage(Stringimage){this.image=image;}}这个类是一个基本的普通旧Java对象(POJO),只有成员变量和用于处理它们的getter/setter方法。然而,请注意突出显示的注解。
如果你习惯了Hibernate3.x,请注意版本4.x废弃了许多Hibernate自己的映射注解,转而使用它们的Java持久化API(JPA)2.0对应物。我们将在第三章,执行查询中进一步讨论JPA。现在,只需注意这里的JPA注解与它们的本地Hibernate注解基本相同,除了属于javax.persistence包。
该类本身用@Entity注解标记,告诉Hibernate将该类映射到数据库表。由于我们没有明确指定一个表名,默认情况下,Hibernate将为App类创建一个名为APP的表。
现在Hibernate知道了我们的领域对象,我们需要告诉HibernateSearch插件如何用Lucene管理它。
我们可以使用一些高级选项来充分利用Lucene的的全部力量,随着这个应用程序的发展,我们会的。然而,在基本场景下使用HibernateSearch只需添加两个注解那么简单。
首先,我们将添加@Indexed注解到类本身:
...importorg.hibernate.search.annotations.Field;...@Id@GeneratedValueprivateLongid;@Column@FieldprivateStringname;@Column(length=1000)@FieldprivateStringdescription;@ColumnprivateStringimage;...注意我们只把这个注解应用到name和description成员变量上。我们没有注释image,因为我们不在乎通过图片文件名搜索应用程序。同样,我们也没有注释id,因为你要找一个数据库表行通过它的主键,你不需要一个强大的搜索引擎!
决定注解什么是一个判断call。你注释的索引实体越多,作为字段注释的成员变量越多,你的Lucene索引就会越丰富、越强大。然而,如果我们仅仅因为可以就注解多余的东西,那么我们就让Lucene做不必要的功,这可能会影响性能。
在第七章,高级性能策略,我们将更深入地探讨这些性能考虑。现在,我们已经准备好通过名称或描述来搜索应用程序。
我们应用程序中的多个地方将需要一个简单且线程安全的手段来打开到数据库的连接(即,HibernateSession对象)。因此,我们还添加了一个名为openSession()的publicstaticsynchronized方法。该方法作为创建单例SessionFactory的线程安全守门员。
在更复杂的应用程序中,您可能会使用依赖注入框架,如Spring或CDI。这在我们的小型示例应用程序中有些分散注意力,但这些框架为您提供了一种安全机制,用于无需手动编码即可注入SessionFactory或Session对象。
在具体化contextInitialized方法时,我们首先获取一个Hibernate会话并开始一个新事务:
我们的VAPORwareMarketplace网络应用程序将基于Servlet3.0控制器/模型类,呈现JSP/JSTL视图。目标是保持事情简单,这样我们就可以专注于HibernateSearch方面。在审阅了这个示例应用程序之后,应该很容易将相同的逻辑适配到JSF或SpringMVC,甚至更新的基于JVM的框架,如Play或Grails。
首先,我们将编写一个简单的index.html页面,包含一个用户输入搜索关键词的文本框:
WelcometotheVAPORwareMarketplace
Pleaseenterkeywordstosearch:现在,我们来到了问题的核心——执行搜索查询。我们创建了一个FullTextSession对象,这是HibernateSearch的一个扩展,它用Lucene搜索功能包装了一个普通的Session。
...importorg.hibernate.Session;importorg.hibernate.search.FullTextSession;importorg.hibernate.search.Search;...Sessionsession=StartupDataLoader.openSession();FullTextSessionfullTextSession=Search.getFullTextSession(session);fullTextSession.beginTransaction();...现在我们有了HibernateSearch会话可以使用,我们可以获取用户的关键词并执行Lucene搜索:
...importorg.hibernate.search.query.dsl.QueryBuilder;...StringsearchString=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风格是有意为之的。因为它本身就像是一种语言,所以被称为HibernateSearchDSL(领域特定语言)。如果你曾经使用过HibernateORM中的条件查询,那么这里的视觉感受对你来说应该是非常熟悉的。
现在我们已经创建了一个org.apache.lucene.search.Query对象,HibernateSearch在幕后将其转换为Lucene搜索。这种魔力是双向的!Lucene搜索结果可以转换为标准的org.hibernate.Query对象,并且像任何正常的数据库查询一样使用:
...org.hibernate.QueryhibernateQuery=fullTextSession.createFullTextQuery(luceneQuery,App.class);List
这个JSP视图将始于非常基础的内容,使用JSTL标签从请求中获取App结果并遍历它们:
好了,现在我们已经到达目的地!我们需要将所有这些代码整合到一个有序的项目结构中,确保所有的JAR文件依赖项都可用,并建立一个运行Web应用程序或将其打包为WAR文件的过程。我们需要一个项目构建系统。
一种我们不会考虑的方法是全部手动完成。对于一个使用原始HibernateORM的小型应用程序,我们可能只需要依赖六个半的JAR文件。在这个规模上,我们可能会考虑在我们的首选IDE(例如Eclipse、NetBeans或IntelliJ)中设置一个标准项目。我们可以从Hibernate网站获取二进制分发,并手动复制必要的JAR文件,让IDE从这里开始。
问题是HibernateSearch在幕后有很多东西。等你完成了Lucene甚至最基本的Solr组件的依赖项添加,依赖项列表会被扩大几倍。即使在这里的第一章,我们的非常基础的VAPORwareMarketplace应用程序已经需要编译和运行超过三十六个JAR文件。这些库之间高度相互依赖,如果你升级了它们中的一个,避免冲突可能真的是一场噩梦。
在这个依赖管理级别,使用自动化构建系统来解决这些问题变得至关重要。在本书中的代码示例中,我们将主要使用ApacheMaven进行构建自动化。
Maven确实有自己的批评者。默认情况下,它的配置是基于XML的,这在最近几年已经不再流行了。更重要的是,当开发者需要做超出模板基础的事情时,有一个学习曲线。他或她必须了解可用的插件、Maven构建的生命周期以及如何为适当的生命周期阶段配置插件。许多开发者都有过在学习曲线上的沮丧经历。
最近创建了许多其他构建系统,试图以更简单的形式harnessMaven的相同力量(例如,基于Groovy的Gradle,基于Scala的SBT,基于Ruby的Buildr等)。然而,重要的是要注意,所有这些新系统仍然设计为从标准Maven仓库获取依赖项。如果您希望使用其他依赖管理和构建系统,那么本书中看到的概念将直接适用于这些其他工具。
为了展示一种更加手动、非Maven的方法,从PacktPublishing网站下载的示例代码包括本章示例应用程序的基于Ant的版本。寻找与基于Maven的chapter1示例对应的子目录chapter1-ant。这个子目录的根目录中有一个README文件,强调了不同之处。然而,主要收获是书中展示的概念应该很容易翻译成任何现代的Java应用程序构建系统。
我们可以使用我们选择的IDE创建Maven项目。Eclipse通过可选的m2e插件与Maven配合使用,NetBeans使用Maven作为其本地构建系统。如果系统上安装了Maven,您还可以选择从命令行创建项目:
我们新创建项目的pom.xmlMaven配置文件开始看起来类似于以下内容:
...
...
...
运行一个Servlet3.0应用程序需要Java6或更高版本,并且需要一个兼容的Servlet容器,如Tomcat7。然而,如果您使用嵌入式数据库以使测试和演示更简单,那么为什么不用嵌入式应用程序服务器呢?
要向您的MavenPOM中添加Jetty插件,请在root元素内插入一小块XML:
所以,如果你正在使用Windows并且希望有实时进行更改的能力,那么就复制一份webdefault.xml的定制副本,并将其保存到前面片段中引用的位置。这个文件可以通过下载并使用解压缩工具打开一个jetty-webappJAR文件来找到,或者简单地从PacktPublishing网站下载这个示例应用程序。对于Windows用户来说,关键是要找到useFileMappedBuffer参数,并将其值更改为false。
既然你已经有了一个Web服务器,那么让我们让它为我们创建和管理一个H2数据库。当Jetty插件启动时,它将自动寻找文件src/main/webapp/WEB-INF/jetty-env.xml。让我们创建这个文件,并使用以下内容填充它:
好的,我们有一个应用程序,一个数据库,还有一个服务器将这两者结合在一起。现在,我们可以实际部署和启动,通过运行带有jetty:run目标的Maven命令来实现:
mvncleanjetty:runclean目标消除了先前构建的痕迹,然后因为jetty:run的暗示,Maven组装我们的Web应用程序。我们的代码很快被编译,并在localhost:8080上启动了一个Jetty服务器:
我们上线了!现在我们可以使用我们喜欢的任何关键词搜索应用程序。一个小提示:在可下载的示例代码中,所有测试数据记录的描述中都包含单词app:
可下载的示例代码让HTML看起来更加专业。它还将在每个应用程序的名称和描述旁边添加应用程序的图片:
Maven命令mvncleanpackage允许我们将应用程序打包成WAR文件,因此我们可以将其部署到MavenJetty插件之外的独立服务器上。只要你知道如何为JNDI名称jdbc/vaporwareDB设置数据源,就可以使用任何符合Servlet3.0规范的Java服务器(例如,Tomcat7+),所以你都可以这样做。
事实上,你可以将H2替换为你喜欢的任何独立数据库。只需将适当的JDBC驱动添加到你的Maven依赖项中,并在persistence.xml中更新设置。
在本章中,我们学习了HibernateORM、HibernateSearch扩展和底层Lucene搜索引擎之间的关系。我们了解了如何将实体和字段映射以使它们可供搜索。我们使用HibernateSearchDSL编写跨多个字段的全文搜索查询,并且像处理正常数据库查询一样处理结果。我们使用自动构建过程来编译我们的应用程序,并将其部署到一个带有实时数据库的Web服务器上。
在第一章,你的第一个应用中,我们使用了核心HibernateORM来将一个实体类映射到数据库表,然后使用HibernateSearch将它的两个字段映射到一个Lucene索引。仅凭这一点,就提供了很多搜索功能,如果从头开始编写将会非常繁琐。
在本章中,我们将开始深入探讨HibernateSearch为映射实体提供的选项。作为一个第一步,我们必须查看HibernateORM中的API选项。我们如何将实体类映射到数据库,这将影响HibernateSearch如何将它们映射到Lucene。
当HibernateSearch文档提到HibernateORM的不同API时,可能会令人困惑。在某些情况下,这可能指的是是否使用org.hibernate.Session或者javax.persistence.EntityManager对象(下一章的重要部分)来执行数据库查询。然而,在实体映射的上下文中,这指的是HibernateORM提供的三种不同的方法:
使用经典Hibernate特定注解的基于注解的映射
使用Java持久化API(JPA2.0)的基于注解的映射
使用hbm.xml文件的基于XML的映射
如果你只使用过HibernateORM的经典注解或基于XML的映射,或者如果你是Hibernate的新手,那么这可能是你第一次接触到JPA。简而言之,JPA是一个规范,旨在作为对象关系映射和其他类似功能的官方标准。
想法是提供ORM所需的类似于JDBC提供的低级数据库连接。一旦开发者学会了JDBC,他们就可以快速使用任何实现API的数据库驱动程序(例如,Oracle、PostgreSQL、MySQL等)。同样,如果你理解了JPA,那么你应该能够轻松地在Hibernate、EclipseLink和ApacheOpenJPA等ORM框架之间切换。
实际上,不同的实现通常有自己的怪癖和专有扩展,这可能会导致过渡性头痛。然而,一个共同的标准可以大大减少痛苦和学习曲线。
使用HibernateORM原生API与使用JPA进行实体映射的比较如下图所示:
对长期使用Hibernate的开发人员来说好消息是,JPA实体映射注解与Hibernate自己的注解非常相似。实际上,Hibernate的创始人参与了JPA委员会的开发,这两个API相互之间有很强的影响。
取决于你的观点,不那么好的消息是HibernateORM4.x弃用自己的映射注解,以支持其JPA对应物。这些较旧的注解计划在HibernateORM5.x中删除。
如今使用这种已弃用的方法编写新代码没有意义,因此我们将忽略Hibernate特定的映射注解。
第三种选择,基于XML的映射,在遗留应用程序中仍然很常见。它正在失去青睐,HibernateSearch文档甚至开玩笑说XML不适合21世纪!当然,这有点开玩笑,考虑到基本的Hibernate配置仍然存储在hibernate.cfg.xml或persistence.xml文件中。尽管如此,大多数Java框架的趋势很明显,对于与特定类绑定的配置使用注解,对于全局配置使用某种形式的文本文件。
即使你使用hbm.xml文件将实体映射到数据库,你仍然可以使用HibernateSearch注解将这些实体映射到Lucene索引。这两个完全兼容。如果你想在最小努力的情况下将HibernateSearch添加到遗留应用程序中,或者即使在开发新应用程序时也有哲学上的偏好使用hbm.xml文件,这很方便。
本章包含VAPORwareMarketplace应用程序的三种版本示例代码:
chapter2子目录继续第一章,你的第一个应用程序的讲解,使用JPA注解将实体同时映射到数据库和Lucene。
chapter2-xml子目录是相同代码的一个变体,修改为将基于XML的数据库映射与基于JPA的Lucene映射混合。
chapter2-mapping子目录使用一个特殊的API来完全避免注解。这在本章末尾的程序化映射API部分中进一步讨论。
你应该详细探索这些示例代码,以了解可用的选项。然而,除非另有说明,本书中的代码示例将重点介绍使用JPA注解对数据库和Lucene进行映射。
当使用JPA注解进行数据库映射时,HibernateSearch会自动为用@Id注解的字段创建一个Lucene标识符。
出于某种原因,HibernateSearch无法与HibernateORM自身的映射API相同。因此,当你不使用JPA将实体映射到数据库时,你也必须在应该用作Lucene标识符的字段上添加@DocumentId注解(在Lucene术语中,实体被称为文档)。
在第一章你的第一个应用中,我们看到了Hibernate管理的类上的成员变量可以通过@Field注解变得可搜索。HibernateSearch会将关于注解字段的信息放入一个或多个Lucene索引中,使用一些合理的默认值。
然而,你可以以无数种方式自定义索引行为,其中一些是@Field注解本身的可选元素。本书将进一步探讨这些元素,但在这里我们将简要介绍它们:
analyze:这告诉Lucene是存储字段数据原样,还是将其进行分析、解析,并以各种方式处理。它可以设置为Analyze.YES(默认)或Analyze.NO。我们将在第三章执行查询中再次看到这一点。
index:这控制是否由Lucene索引字段。它可以设置为Index.YES(默认)或Index.NO。在第五章高级查询中介绍基于投影的搜索后,使用@Field注解但不索引字段听起来可能没有意义,但这将更有意义。
name:这是一个自定义名称,用于描述字段在Lucene索引中的名称。默认情况下,HibernateSearch将使用注解的成员变量的名称。
store:通常,字段以优化搜索的方式进行索引,但这可能不允许以原始形式检索数据。此选项使原始数据以这种方式存储,以至于你可以在稍后直接从Lucene而不是数据库中检索它。它可以设置为Store.NO(默认)、Store.YES或Store.COMPRESS。我们将在第五章高级查询中与基于投影的搜索一起使用这个选项。
有时,你需要用一组选项对字段进行某些操作,用另一组选项进行其他操作。我们将在第三章执行查询中看到这一点,当我们使一个字段既可搜索又可排序。
暂时先说这么多,你可以在同一个字段上有尽可能多的自定义映射。只需包含多个@Field注解,用复数的@Fields包裹起来即可:
...@Column@Fields({@Field,@Field(name="sorting_name",analyze=Analyze.NO)})privateStringname;...现在不用担心这个例子。只需注意,当你为同一个字段创建多个映射时,你需要通过name元素给它们赋予不同的名称,这样你以后才能正确引用。
在第一章,你的第一个应用程序中,我们的实体映射示例仅涉及字符串属性。同样,使用相同的@Field注解与其他基本数据类型也是完全没问题的。
然而,这种方式映射的字段被Lucene以字符串格式索引。这对于我们稍后探讨的技术(如排序和范围查询)来说非常低效。
为了提高此类操作的性能,HibernateSearch提供了一个用于索引数值字段的特殊数据结构。当映射Integer、Long、Float和Double(或它们的原始类型)类型的字段时,此选项是可用的。
要为数值字段使用这个优化的数据结构,你只需在正常的@Field注解之外添加@NumericField注解。作为一个例子,让我们在VAPORwareMarketplace应用程序的App实体中添加一个价格字段:
...@Column@Field@NumericFieldprivatefloatprice;...如果你将此注解应用于已经多次映射到@Fields的属性,你必须指定哪个映射应使用特殊的数据结构。这通过给@NumericField注解一个可选的forField元素来实现,该元素设置为所需@Field的相同名称。
每当一个实体类被@Indexed注解标记时,默认情况下HibernateSearch将为该类创建一个Lucene索引。我们可以有尽可能多的实体和单独的索引。然而,单独搜索每个索引将是一种非常笨拙和繁琐的方法。
大多数HibernateORM数据模型已经捕捉了实体类之间的各种关联。当我们搜索实体的Lucene索引时,HibernateSearch难道不应该跟随这些关联吗?在本节中,我们将了解如何使其这样做。
到目前为止,我们示例应用程序中的实体字段一直是很简单的数据类型。App类代表了一个名为APP的表,它的成员变量映射到该表的列。现在让我们添加一个复杂类型的字段,用于关联第二个数据库表的一个外键。
在线应用商店通常支持一系列不同的硬件设备。因此,我们将创建一个名为Device的新实体,代表有App实体可用的设备。
@EntitypublicclassDevice{@Id@GeneratedValueprivateLongid;@Column@FieldprivateStringmanufacturer;@Column@FieldprivateStringname;@ManyToMany(mappedBy="supportedDevices",fetch=FetchType.EAGER,cascade={CascadeType.ALL})@ContainedInprivateSet
然而,supportedApps成员变量引入了一个新注解,用于实现这两个实体之间的双向关联。一个App实体将包含一个它所支持的所有设备的列表,而一个Device实体将包含一个它所支持的所有应用的列表。
如果没有其他原因,使用双向关联可以提高HibernateSearch的可靠性。
通常,Hibernate是“懒加载”的。它实际上直到需要时才从数据库中检索关联实体。
然而,这里我们正在编写一个多层应用程序,当我们的搜索结果JSP接收到这些实体时,控制器servlet已经关闭了Hibernate会话。如果视图尝试在会话关闭后检索关联,将会发生错误。
这个问题有几个解决方法。为了简单起见,我们还在@ManyToMany注解中添加了一个fetch元素,将检索类型从“懒加载”更改为“eager”。现在当我们检索一个Device实体时,Hibernate会在会话仍然开启时立即获取所有关联的App实体。
然而,在大量数据的情况下,积极检索是非常低效的,因此,在第五章高级查询中,我们将探讨一个更高级的策略来处理这个问题。
双向关联的另一面涉及向App实体类提供一个支持Device实体类的列表。
...@ManyToMany(fetch=FetchType.EAGER,cascade={CascadeType.ALL})@IndexedEmbedded(depth=1)privateSet
如果你的关联对象本身就包含其他关联对象,那么你可能会索引比你想要的更多的数据。更糟糕的是,你可能会遇到循环依赖的问题。
为了防止这种情况,将@IndexEmbedded注解的可选depth元素设置为一个最大限制。在索引对象时,HibernateSearch将不会超过指定层数。
之前的代码指定了一层深度。这意味着一个应用将带有关于它支持设备的信息进行索引,但不包括设备支持的其他应用的信息。
一旦为HibernateSearch映射了关联实体,它们很容易被包含在搜索查询中。以下代码片段更新了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。当我们运行VAPORwareMarketplace应用并搜索这个关键词时,搜索结果将包括与xPhone兼容的应用,即使这个关键词没有出现在应用的名称或描述中。
关联实体是完整的实体。它们通常对应自己的数据库表和Lucene索引,并且可以独立于它们的关联存在。例如,如果我们删除了在xPhone上支持的应用实体,那并不意味着我们想要删除xPhone的Device。
经典HibernateORM术语将这些对象称为组件(有时也称为元素)。在新版JPA术语中,它们被称为嵌入对象。
嵌入对象本身并不是实体。HibernateSearch不会为它们创建单独的Lucene索引,并且它们不能在没有包含它们的实体的上下文中被搜索。否则,它们在外观和感觉上与关联实体非常相似。
@EmbeddablepublicclassCustomerReview{@FieldprivateStringusername;privateintstars;@FieldprivateStringcomments;publicCustomerReview(){}publicCustomerReview(Stringusername,intstars,Stringcomments){this.username=username;this.stars=stars;this.comments=comments;}//Getterandsettermethods...}这个类被注解为@Embeddable而不是通常的@Entity注解,告诉HibernateORMCustomerReview实例的生命周期取决于包含它的哪个实体对象。
@Field注解仍然应用于可搜索的字段。然而,HibernateSearch不会为CustomerReview创建独立的Lucene索引。这个注解只是向包含这个嵌入类其他实体的索引中添加信息。
...@ElementCollection(fetch=FetchType.EAGER)@Fetch(FetchMode.SELECT)@IndexedEmbedded(depth=1)privateSet
当使用基于经典XML的Hibernate映射时,hbm.xml文件等效物是
@ElementCollection注解有一个fetch元素设置为使用eagerfetching,原因与本章前面讨论的原因相同。
查询嵌入对象与关联实体相同。以下是从SearchServlet中修改的查询代码片段,以针对嵌入的CustomerReview实例的注释字段进行搜索:
关联实体每个都有自己的Lucene索引,并在彼此的索引中存储一些数据。对于嵌入对象,搜索信息存储在专有的包含实体的索引中。
然而,请注意,这些类可能在不止一个地方被关联或嵌入。例如,如果你的数据模型中有Customer和Publisher实体,它们可能都有一个Address类型的嵌入对象。
...@ElementCollection(fetch=FetchType.EAGER)@Fetch(FetchMode.SELECT)@IndexedEmbedded(depth=1,includePaths={"comments"})privateSet
如果你需要在运行时根据某些情况更改搜索配置,这可能会有所帮助。这也是如果你不能出于某种原因更改实体类,或者如果你是坚定的配置与POJO分离主义者,这是唯一可用的方法。
程序化映射API的核心是SearchMapping类,它存储了通常从注解中提取的HibernateSearch配置。典型的使用方式看起来像我们在前一章看到的查询DSL代码。你在SearchMapping对象上调用一个方法,然后调用返回对象上的方法,以此类推,形成一个长长的嵌套系列。
每一步可用的方法都直观地类似于你已经见过的搜索注解。entity()方法替代了@Entity注解,indexed()替代了@Indexed,field()替代了@Field,等等。
从PacktPublishing网站下载的源代码中,chapter2-mapping子目录包含了一个使用程序化映射API的VAPORwareMarketplace应用程序版本。
这个示例应用的版本包含一个工厂类,其中有一个方法根据需求配置并返回一个SearchMapping对象。无论你给这个类或方法起什么名字,只要这个方法用@org.hibernate.search.annotations.Factory注解标记即可:
publicclassSearchMappingFactory{@FactorypublicSearchMappinggetSearchMapping(){SearchMappingsearchMapping=newSearchMapping();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();returnsearchMapping;}}请注意,这个工厂方法严格来说只有三行长。它的主要部分是一个从SearchMapping对象开始的连续一行链式方法调用,这个调用将我们的三个持久化类映射到Lucene。
为了将映射工厂集成到HibernateSearch中,我们在主要的hibernate.cfg.xml配置文件中添加了一个属性:
...
在本章中,我们扩展了如何为搜索映射类的知识。现在,我们可以使用HibernateSearch将实体和其他类映射到Lucene,无论HibernateORM如何将它们映射到数据库。如果我们任何时候需要将类映射到Lucene而不添加注解,我们可以在运行时使用程序化映射API来处理。
在下一章中,我们将使用这些映射来处理各种搜索查询类型,并探索它们都共有的重要特性。
在上一章中,我们创建了各种类型的持久化对象,并将它们以各种方式映射到Lucene搜索索引中。然而,到目前为止,示例应用程序的所有版本基本上都使用了相同的关键词查询。
在本章中,我们将探讨HibernateSearchDSL提供的其他搜索查询类型,以及所有它们共有的重要特性,如排序和分页。
到目前为止,我们已经讨论了使用HibernateORM将类映射到数据库的各种API选项。你可以使用XML或注解来映射你的类,运用JPA或传统的API,只要注意一些细微的差异,HibernateSearch就能正常工作。
然而,当我们谈论一个Hibernate应用程序使用哪个API时,答案有两个部分。不仅有一个以上的方法将类映射到数据库,还有运行时查询数据库的选项。HibernateORM有其传统的API,基于SessionFactory和Session类。它还提供了一个对应JPA标准的实现,围绕EntityManagerFactory和EntityManager构建。
你可能会注意到,在迄今为止的示例代码中,我们一直使用JPA注解将类映射到数据库,并使用传统的HibernateSession类来查询它们。这可能一开始看起来有些令人困惑,但映射和查询API实际上是可互换的。你可以混合使用!
那么,在HibernateSearch项目中你应该使用哪种方法呢?尽可能坚持常见标准是有优势的。一旦你熟悉了JPA,这些技能在你从事使用不同JPA实现的其他项目时是可以转移的。
另一方面,HibernateORM的传统API比通用的JPA标准更强大。此外,HibernateSearch是HibernateORM的扩展。在没有找到其他的搜索策略之前,你不能将一个项目迁移到一个不同的JPA实现。
所以简而言之,尽可能使用JPA标准的论据是很强的。然而,HibernateSearch本来就需要HibernateORM,所以过于教条是没有意义的。在这本书中,大多数示例代码将使用JPA注解来映射类,并使用传统的HibernateSession类来进行查询。
虽然我们将重点放在传统的查询API上,但可下载的源代码还包含一个不同版本的示例应用程序,在chapter3-entitymanager文件夹中。这个VAPORwareMarketplace变体展示了JPA全面使用的情况,用于映射和查询。
在搜索控制器servlet中,我们没有使用HibernateSessionFactory对象来创建Session对象,而是使用JPAEntityManagerFactory实例来创建EntityManager对象:
...//The"com.packtpub.hibernatesearch.jpa"identifierisdeclared//in"META-INF/persistence.xml"EntityManagerFactoryentityManagerFactory=Persistence.createEntityManagerFactory("com.packtpub.hibernatesearch.jpa");EntityManagerentityManager=entityManagerFactory.createEntityManager();...我们已经看到了使用传统查询API的代码示例。在之前的示例中,HibernateORM的Session对象被包裹在HibernateSearch的FullTextSession对象中。这些然后生成了实现核心org.hibernate.Query接口的HibernateSearchFullTextQuery对象:
...FullTextSessionfullTextSession=Search.getFullTextSession(session);...org.hibernate.search.FullTextQueryhibernateQuery=fullTextSession.createFullTextQuery(luceneQuery,App.class);...与JPA相比,常规的EntityManager对象同样被FullTextEntityManager对象包装。这些创建了实现标准javax.persistence.Query接口的FullTextQuery对象:
...FullTextEntityManagerfullTextEntityManager=org.hibernate.search.jpa.Search.getFullTextEntityManager(entityManager);...org.hibernate.search.jpa.FullTextQueryjpaQuery=fullTextEntityManager.createFullTextQuery(luceneQuery,App.class);...传统的FullTextQuery类及其JPA对应类非常相似,但它们是来自不同Java包的分开的类。两者都提供了大量我们迄今为止所看到的HibernateSearch功能的钩子,并将进一步探索。
任何FullTextQuery版本都可以被强制转换为其相应的查询类型,尽管这样做会失去对HibernateSearch方法的直接访问。所以,在转换之前一定要调用任何扩展方法。
如果你在将JPA查询强制转换后需要访问非标准方法,那么你可以使用该接口的unwrap()方法回到底层的FullTextQuery实现。
它的版本需要与已经在依赖层次中hibernate-core的版本匹配。这不会总是与hibernate-search版本同步。
你的IDE可能提供了一种以视觉方式展示依赖层次的方法。无论如何,你总是可以用命令行Maven来用这个命令得到相同的信息:
如本输出所示,HibernateSearch4.2.0.Final使用核心HibernateORM4.1.9.Final版本。因此,应该在POM中添加一个hibernate-entitymanager依赖,使用与核心相同的版本:
...
无论你是使用传统的FullTextSession对象还是JPA风格的FullTextEntityManager对象,每个都传递了一个由QueryBuilder类生成的Lucene查询。这个类是HibernateSearchDSL的起点,并提供了几种Lucene查询类型。
我们已经简要了解的最基本的搜索形式是关键词查询。正如名称所暗示的,这种查询类型搜索一个或多个特定的单词。
第一步是获取一个QueryBuilder对象,该对象配置为对给定实体进行搜索:
...QueryBuilderqueryBuilder=fullTextSession.getSearchFactory().buildQueryBuilder().forEntity(App.class).get();...从那里,以下图表描述了可能的流程。虚线灰色箭头代表可选的侧路径:
关键词查询流程(虚线灰色箭头代表可选路径)
在实际的Java代码中,关键词查询的DSL将类似于以下内容:
匹配方法采用要进行查询的关键词。这个值通常是一个字符串,尽管从技术上讲,参数类型是一个泛型对象,以防您使用字段桥(下一章讨论)。假设您传递了一个字符串,它可能是一个单独的关键词或由空白字符分隔的一系列关键词。默认情况下,HibernateSearch将分词字符串并分别搜索每个关键词。
最后,createQuery方法终止DSL并返回一个Lucene查询对象。该对象然后可以由FullTextSession(或FullTextEntityManager)用来创建最终的HibernateSearchFullTextQuery对象:
...FullTextQueryhibernateQuery=fullTextSession.createFullTextQuery(luceneQuery,App.class);...模糊搜索当我们今天使用搜索引擎时,我们默认它会智能到足以在我们“足够接近”正确拼写时修正我们的拼写错误。向HibernateSearch添加这种智能的一种方法是将普通关键词查询模糊化。
使用模糊搜索,关键词即使相差一个或多个字符也能与字段匹配。查询运行时有一个介于0到1之间的阈值,其中0意味着一切都匹配,而1意味着只接受精确匹配。查询的模糊度取决于您将阈值设置得多接近于零。
DSL以相同的关键词方法开始,最终通过onField或onFields继续关键词查询流程。然而,在这两者之间有一些新的流程可能性,如下所示:
模糊搜索流程(虚线灰色箭头代表可选路径)
模糊方法只是使普通关键词查询变得“模糊”,默认阈值值为0.5(例如,平衡两个极端之间)。您可以从那里继续常规关键词查询流程,这将完全没问题。
然而,您可以选择调用withThreshold来指定不同的模糊度值。在本章中,VAPORwareMarketplace应用程序的版本为关键词查询增加了模糊度,阈值设置为0.7。这个值足够严格以避免过多的假阳性,但足够模糊,以至于现在拼写错误的搜索“rodio”将匹配“AthenaInternetRadio”应用程序。
...luceneQuery=queryBuilder.keyword().fuzzy().withThreshold(0.7f).onFields("name","description","supportedDevices.name","customerReviews.comments").matching(searchString).createQuery();...除了(或代替)withThreshold,您还可以使用withPrefixLength来调整查询的模糊度。这个整数值是在每个单词的开头您想要从模糊度计算中排除的字符数。
关键词查询的第二个变体不涉及任何高级数学算法。如果您曾经使用过像*.java这样的模式来列出目录中的所有文件,那么您已经有了基本概念。
添加通配符方法使得普通关键词查询将问号()视为任何单个字符的有效替代品。例如,关键词201将匹配字段值2010、2011、2012等。
星号(*)成为任何零个或多个字符序列的替代品。关键词down*匹配download、downtown等词汇。
HibernateSearchDSL的通配符搜索与常规关键词查询相同,只是在最前面增加了零参数的wildcard方法。
通配符搜索流程(虚线灰色箭头代表可选路径)
当你在搜索引擎中输入一组关键词时,你期望看到匹配其中一个或多个关键词的结果。每个结果中可能不都包含所有关键词,它们可能不会按照你输入的顺序出现。
然而,现在已经习惯于当你将字符串用双引号括起来时,你期望搜索结果包含这个确切的短语。
HibernateSearchDSL为这类搜索提供了短语查询流程。
精确短语查询流程(虚线灰色箭头代表可选路径)
onField和andField方法的行为与关键词查询相同。sentence方法与matching的区别在于,其输入必须是String。
短语查询可以通过使用可选的withSlop子句来实现一种模糊性。该方法接受一个整数参数,代表在短语内可以找到的“额外”单词数,在达到这个数量之前,短语仍被视为匹配。
本章中VAPORwareMarketplace应用程序的版本现在会检查用户搜索字符串周围是否有双引号。当输入被引号括起来时,应用程序将关键词查询替换为短语查询:
...luceneQuery=queryBuilder.phrase().onField("name").andField("description").andField("supportedDevices.name").andField("customerReviews.comments").sentence(searchStringWithQuotesRemoved).createQuery();...范围查询短语查询和各种关键词搜索类型,都是关于将字段匹配到搜索词。范围查询有点不同,因为它寻找被一个或多个搜索词限定的字段。也就是说,一个字段是大于还是小于给定值,还是在大于或小于两个值之间?
范围查询流程(虚线灰色箭头代表可选路径)
当使用前述方法时,查询的字段必须大于或等于输入参数的值。这个参数是通用的Object类型,以增加灵活性。通常使用日期和数字值,尽管字符串也非常合适,并且会根据字母顺序进行比较。
可以对这些子句中的任何一个应用excludeLimit子句。它的作用是将范围变为排他而非包含。换句话说,from(5).to(10).excludeLimit()匹配一个5<=x<10的范围。修改器可以放在from子句上,而不是to,或者同时放在两个上。
...luceneQuery=queryBuilder.range().onField("customerReviews.stars").above(3).excludeLimit().createQuery();...布尔(组合)查询如果你有一个高级用例,其中关键词、短语或范围查询本身不够,但两个或更多组合在一起能满足你的需求,那怎么办?HibernateSearch允许你用布尔逻辑混合任何查询组合:
布尔查询流程(虚线灰色箭头代表可选路径)
当使用must子句时,一个字段必须与嵌套查询匹配,才能整体匹配查询。可以应用多个must子句,它们以逻辑与的方式操作。它们都必须成功,否则就没有匹配。
可选的not方法用于逻辑上否定一个must子句。效果是,整个查询只有在那个嵌套查询不匹配时才会匹配。
should子句大致相当于逻辑或操作。当一个组合只由should子句组成时,一个字段不必匹配它们全部。然而,为了使整个查询匹配,至少必须有一个匹配。
这个例子结合了一个关键词查询和一个范围查询,以查找拥有5星客户评价的"xPhone"应用程序:
然而,我们有选项可以完全改变排序的其他标准。在典型情况下,你可能会按照日期或数字字段,或者按照字母顺序的字符串字段进行排序。在VAPORwareMarketplace应用程序的的所有版本中,用户现在可以按照应用程序名称对他们的搜索结果进行排序。
要对一个字段进行排序,当这个字段被映射为Lucene索引时,需要特别考虑。通常当一个字符串字段被索引时,默认分析器(在下一章中探讨)会将字符串分词。例如,如果一个App实体的name字段是"FrustratedFlamingos",那么在Lucene索引中会为"frustrated"和"flamingos"创建单独的条目。这允许进行更强大的查询,但我们希望基于原始未分词的值进行排序。
支持这种情况的一个简单方法是将字段映射两次,这是完全可行的!正如我们在第二章中看到的,映射实体类,HibernateSearch提供了一个复数@Fields注解。它包含一个由逗号分隔的@Field注解列表,具有不同的分析器设置。
...@Column@Fields({@Field,@Field(name="sorting_name",analyze=Analyze.NO)})privateStringname;...这个新字段名称可以用如下方式来构建一个LuceneSortField对象,并将其附加到一个HibernateSearchFullTextQuery对象上:
importorg.apache.lucene.search.Sort;importorg.apache.lucene.search.SortField;...Sortsort=newSort(newSortField("sorting_name",SortField.STRING));hibernateQuery.setSort(sort);//aFullTextQueryobject当hibernateQuery后来返回一个搜索结果列表时,这个列表将按照应用程序名称进行排序,从A到Z开始。
反向排序也是可能的。SortField类还提供了一个带有第三个Boolean参数的构造函数。如果这个参数被设置为true,排序将以完全相反的方式进行(例如,从Z到A)。
当一个搜索查询返回大量的搜索结果时,一次性将它们全部呈现给用户通常是不受欢迎的(或者可能根本不可能)。一个常见的解决方案是分页,或者一次显示一个“页面”的搜索结果。
一个HibernateSearchFullTextQuery对象有方法可以轻松实现分页:
当然,如果代码总是抓取前五个结果,分页将不会很有用。我们还需要能够抓取下一页,然后是下一页,依此类推。因此setFirstResult方法告诉HibernateSearch从哪里开始。
例如,前面的代码片段从第十一个结果项开始(参数是10,但结果是零索引的)。然后将查询设置为抓取下一个五个结果。因此,下一个传入请求可能会使用hibernateQuery.setFirstResult(15)。
拼图的最后一片是知道有多少结果,这样你就可以为正确数量的页面进行规划:
…intresultSize=hibernateQuery.getResultSize();…getResultSize方法比乍一看要强大,因为它只使用Lucene索引来计算数字。跨所有匹配行的常规数据库查询可能是一个非常资源密集的操作,但对于Lucene来说是一个相对轻量级的事务。
本章示例应用程序的版本现在使用分页来显示搜索结果,每页最多显示五个结果。查看SearchServlet和search.jsp结果页面,了解它们如何使用结果大小和当前起始点来构建所需的“上一页”和“下一页”链接。
以下是VAPORwareMarketplace更新的实际操作情况:
在本章中,我们探讨了HibernateSearch查询中最常见的用例。现在,无论JPA是整体使用、部分使用还是根本不使用,我们都可以与HibernateSearch一起工作。我们了解了HibernateSearchDSL提供的核心查询类型,并可以轻松地访问到它们的全部可能流程,而不是不得不浏览Javadocs来拼凑它们。
现在我们知道如何按特定字段对搜索结果进行升序或降序排序。对于大型结果集,我们可以现在对结果进行分页,以提高后端性能和前端用户体验。我们VAPORwareMarketplace示例中的搜索功能现在大于或等于许多生产HibernateSearch应用程序。
在下一章中,我们将探讨更高级的映射技术,例如处理自定义数据类型和控制Lucene索引过程的详细信息。
Java类中的成员变量可能是无数的自定义类型。通常,您也可以在自己的数据库中创建自定义类型。使用HibernateORM,有数十种基本类型,可以构建更复杂的类型。
然而,在Lucene索引中,一切最终都归结为字符串。当你为搜索映射其他数据类型的字段时,该字段被转换为字符串表示。在HibernateSearch术语中,这种转换背后的代码称为桥梁。默认桥梁为您处理大多数常见情况,尽管您有能力为自定义场景编写自己的桥梁。
最常见的映射场景是一个Java属性与一个Lucene索引字段绑定。String变量显然不需要任何转换。对于大多数其他常见数据类型,它们作为字符串的表达方式相当直观。
尽管这一切都是自动发生的,但你确实可以选择显式地将字段注解为@DateBridge。当你不想索引到确切的毫秒时,你会这样做。这个注解有一个必需的元素resolution,让你从YEAR、MONTH、DAY、HOUR、MINUTE、SECOND或MILLISECOND(正常默认)中选择一个粒度级别。
...@Column@Field@DateBridge(resolution=Resolution.DAY)privateDatereleaseDate;...处理null值默认情况下,无论其类型如何,带有null值的字段都不会被索引。然而,您也可以自定义这种行为。@Field注解有一个可选元素indexNullAs,它控制了映射字段的null值的处理。
...@Column@Field(indexNullAs=Field.DEFAULT_NULL_TOKEN)privateStringdescription;...此元素的默认设置是Field.DO_NOT_INDEX_NULL,这导致null值在Lucene索引中被省略。然而,当使用Field.DEFAULT_NULL_TOKEN时,HibernateSearch将使用一个全局配置的值索引该字段。
这个值的名称是hibernate.search.default_null_token,它是在hibernate.cfg.xml(对于传统的HibernateORM)或persistence.xml(对于作为JPA提供者的Hibernate)中设置的。如果这个值没有配置,那么空字段将被索引为字符串"_null_"。
您可以使用这个机制对某些字段进行空值替换,而保持其他字段的行为。然而,indexNullAs元素只能与在全局级别配置的那个替代值一起使用。如果您想要为不同的字段或不同的场景使用不同的空值替代,您必须通过自定义桥接实现那个逻辑(在下一小节中讨论)。
有时您需要在将字段转换为字符串值方面具有更多的灵活性。而不是依赖内置的桥接自动处理,您可以创建自己的自定义桥接。
要将对单个Java属性的映射映射到一个索引字段上,您的桥接可以实现HibernateSearch提供的两个接口中的一个。第一个,StringBridge,是为了在属性和字符串值之间进行单向翻译。
假设我们的App实体有一个currentDiscountPercentage成员变量,表示该应用程序正在提供的任何促销折扣(例如,25%折扣!)。为了更容易进行数学运算,这个字段被存储为浮点数(0.25f)。然而,如果我们想要使折扣可搜索,我们希望它们以更易读的百分比格式(25)进行索引。
为了提供这种映射,我们首先需要创建一个桥接类,实现StringBridge接口。桥接类必须实现一个objectToString方法,该方法期望将我们的currentDiscountPercentage属性作为输入参数:
importorg.hibernate.search.bridge.StringBridge;/**Convertsvaluesfrom0-1intopercentages(e.g.0.25->25)*/publicclassPercentageBridgeimplementsStringBridge{publicStringobjectToString(Objectobject){try{floatfieldValue=((Float)object).floatValue();if(fieldValue<0f||fieldValue>1f)return"0";intpercentageValue=(int)(fieldValue*100);returnInteger.toString(percentageValue);}catch(Exceptione){//defaulttozerofornullvaluesorotherproblemsreturn"0";}}}objectToString方法按照预期转换输入,并返回其String表示。这将是由Lucene索引的值。
请注意,当给定一个空值时,或者当遇到任何其他问题时,这个方法返回一个硬编码的"0"。自定义空值处理是创建字段桥接的另一个可能原因。
...@Column@Field@FieldBridge(impl=PercentageBridge.class)privatefloatcurrentDiscountPercentage;...注意这个实体字段是一个原始float,然而桥接类却在与一个Float包装对象一起工作。为了灵活性,objectToString接受一个泛型Object参数,该参数必须转换为适当的类型。然而,多亏了自动装箱,原始值会自动转换为它们的对象包装器。
第二个接口用于将单个变量映射到单个字段,TwoWayStringBridge,提供双向翻译,在值及其字符串表示之间进行翻译。
实现TwoWayStringBridge的方式与刚刚看到的常规StringBridge接口类似。唯一的区别是,这个双向版本还要求有一个stringToObject方法,用于反向转换:
...publicObjectstringToObject(StringstringValue){returnFloat.parseFloat(stringValue)/100;}...提示只有在字段将成为Lucene索引中的ID字段(即,用@Id或@DocumentId注解)时,才需要双向桥。
为了更大的灵活性,可以向桥接类传递配置参数。为此,您的桥接类应该实现ParameterizedBridge接口,以及StringBridge或TwoWayStringBridge。然后,该类必须实现一个setParameterValues方法来接收这些额外的参数。
为了说明问题,假设我们想让我们的示例桥接能够以更大的精度写出百分比,而不是四舍五入到整数。我们可以传递一个参数,指定要使用的小数位数:
publicclassPercentageBridgeimplementsStringBridge,ParameterizedBridge{publicstaticfinalStringDECIMAL_PLACES_PROPERTY="decimal_places";privateintdecimalPlaces=2;//defaultpublicStringobjectToString(Objectobject){Stringformat="%."+decimalPlaces+"g%n";try{floatfieldValue=((Float)object).floatValue();if(fieldValue<0f||fieldValue>1f)return"0";returnString.format(format,(fieldValue*100f));}catch(Exceptione){returnString.format(format,"0");}}publicvoidsetParameterValues(Map
@FieldBridge注解中的params元素是实际传递一个或多个参数的机制:
...@Column@Field@FieldBridge(impl=PercentageBridge.class,params=@Parameter(name=PercentageBridge.DECIMAL_PLACES_PROPERTY,value="4"))privatefloatcurrentDiscountPercentage;...注意请注意,所有StringBridge或TwoWayStringBridge的实现都必须是线程安全的。通常,您应该避免任何共享资源,并且只通过ParameterizedBridge参数获取额外信息。
迄今为止所涵盖的桥接类型是将Java属性映射到字符串索引值的最简单、最直接的方法。然而,有时您需要更大的灵活性,因此有一些支持自由形式的字段桥接变体。
有时,类属性与Lucene索引字段之间的期望关系可能不是一对一的。例如,假设一个属性表示文件名。然而,我们希望能够不仅通过文件名搜索,还可以通过文件类型(即文件扩展名)搜索。一种方法是从文件名属性中解析文件扩展名,从而使用这个变量创建两个字段。
FieldBridge接口允许我们这样做。实现必须提供一个set方法,在这个例子中,它从文件名字段中解析文件类型,并将其分别存储:
importorg.apache.lucene.document.Document;importorg.hibernate.search.bridge.FieldBridge;importorg.hibernate.search.bridge.LuceneOptions;publicclassFileBridgeimplementsFieldBridge{publicvoidset(Stringname,Objectvalue,Documentdocument,LuceneOptionsluceneOptions){Stringfile=((String)value).toLowerCase();Stringtype=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()将字段添加到索引,而不必完全理解LuceneAPI的细节。
最后,value参数是指当前正在映射的字段。就像在Bridges部分看到的StringBridge接口一样,这里的函数签名使用了一个通用的Object以提高灵活性。必须将值转换为其适当的类型。
要应用FieldBridge实现,就像我们已经看到的其他自定义桥接类型一样,使用@FieldBridge注解:
...@Column@Field@FieldBridge(impl=FileBridge.class)privateStringfile;...将多个属性合并为一个字段实现FieldBridge接口的自定义桥接也可以用于相反的目的,将多个属性合并为一个索引字段。为了获得这种灵活性,桥接必须应用于类级别而不是字段级别。当以这种方式使用FieldBridge接口时,它被称为类桥接,并替换了整个实体类的常规映射机制。
例如,考虑我们在VAPORwareMarketplace应用程序中处理Device实体时可以采取的另一种方法。而不是将manufacturer和name作为单独的字段进行索引,我们可以将它们合并为一个fullName字段。这个类桥接仍然实现FieldBridge接口,但它会将两个属性合并为一个索引字段,如下所示:
publicclassDeviceClassBridgeimplementsFieldBridge{publicvoidset(Stringname,Objectvalue,Documentdocument,LuceneOptionsluceneOptions){Devicedevice=(Device)value;StringfullName=device.getManufacturer()+""+device.getName();luceneOptions.addFieldToDocument(name+".name",fullName,document);}}而不是在Device类的任何特定字段上应用注解,我们可以在类级别应用一个@ClassBridge注解。注意字段级别的HibernateSearch注解已经被完全移除,因为类桥接将负责映射这个类中的所有索引字段。
@Entity@Indexed@ClassBridge(impl=DeviceClassBridge.class)publicclassDevice{@Id@GeneratedValueprivateLongid;@ColumnprivateStringmanufacturer;@ColumnprivateStringname;//constructors,gettersandsetters...}TwoWayFieldBridge之前我们看到了简单的StringBridge接口有一个TwoWayStringBridge对应接口,为文档ID字段提供双向映射能力。同样,FieldBridge接口也有一个TwoWayFieldBridge对应接口出于相同原因。当你将字段桥接接口应用于Lucene用作ID的属性(即,用@Id或@DocumentId注解)时,你必须使用双向变体。
TwoWayStringBridge接口需要与StringBridge相同的objectToString方法,以及与FieldBridge相同的set方法。然而,这个双向版本还需要一个get对应方法,用于从Lucene检索字符串表示,并在真实类型不同时进行转换:
...publicObjectget(Stringname,Objectvalue,Documentdocument){//returnthefullfilenamefield...thefiletypefield//isnotneededwhengoingbackinthereversedirectionreturn=document.get(name+".file");}publicStringobjectToString(Objectobject){//"file"isalreadyaString,otherwiseitwouldneedconversionreturnobject;}...分析当一个字段被Lucene索引时,它会经历一个称为分析的解析和转换过程。在第三章《执行查询》中,我们提到了默认的分析器会分词字符串字段,如果你打算对该字段进行排序,则应该禁用这种行为。
然而,在分析过程中可以实现更多功能。ApacheSolr组件可以组装成数百种组合。它们可以在索引过程中以各种方式操纵文本,并打开一些非常强大的搜索功能的大门。
为了讨论可用的Solr组件,或者如何将它们组装成自定义分析器定义,我们首先必须了解Lucene分析的三个阶段:
字符过滤
标记化
标记过滤
分析首先通过应用零个或多个字符过滤器进行,这些过滤器在处理之前去除或替换字符。过滤后的字符串然后进行标记化,将其拆分为更小的标记,以提高关键字搜索的效率。最后,零个或多个标记过滤器在将它们保存到索引之前去除或替换标记。
这些组件由ApacheSolr项目提供,总共有三十多个。本书无法深入探讨每一个,但我们可以查看三种类型的一些关键示例,并了解如何一般地应用它们。
定义自定义分析器时,字符过滤是一个可选步骤。如果需要此步骤,只有三种字符过滤类型可用:
MappingCharFilterFactory:此过滤器将字符(或字符序列)替换为特定定义的替换文本,例如,您可能会将1替换为one,2替换为two,依此类推。
字符(或字符序列)与替换值之间的映射存储在一个资源文件中,该文件使用标准的java.util.Properties格式,位于应用程序的类路径中的某个位置。对于每个属性,键是查找的序列,值是映射的替换。
这个映射文件相对于类路径的位置被传递给MappingCharFilterFactory定义,作为一个名为mapping的参数。传递这个参数的确切机制将在定义和选择分析器部分中详细说明。
PatternReplaceCharFilter:此过滤器应用一个通过名为pattern的参数传递的正则表达式。任何匹配项都将用通过replacement参数传递的静态文本字符串替换。
HTMLStripCharFilterFactory:这个极其有用的过滤器移除HTML标签,并将转义序列替换为其通常的文本形式(例如,>变成>)。
在定义自定义分析器时,字符和标记过滤器都是可选的,您可以组合多种过滤器。然而,tokenizer组件是唯一的。分析器定义必须包含一个,最多一个。
总共有10个tokenizer组件可供使用。一些说明性示例包括:
WhitespaceTokenizerFactory:这个组件只是根据空白字符分割文本。例如,helloworld被分词为hello和world。
LetterTokenizerFactory:这个组件的功能与WhitespaceTokenizrFactory类似,但这个分词器还会在非字母字符处分割文本。非字母字符完全被丢弃,例如,pleasedon'tgo被分词为please,don,t,和go。
StandardTokenizerFactory:这是默认的tokenizer,在未定义自定义分析器时自动应用。它通常根据空白字符分割,丢弃多余字符。例如,it's25.5degreesoutside!!!变为it's,25.5,degrees,和outside。
当有疑问时,StandardTokenizerFactory几乎总是合理的选择。
到目前为止,分析器功能的最大多样性是通过分词过滤器实现的,Solr提供了二十多个选项供单独或组合使用。以下是更有用的几个示例:
StopFilterFactory:这个过滤器简单地丢弃“停用词”,或者根本没有人想要对其进行关键词查询的极其常见的词。列表包括a,the,if,for,and,or等(Solr文档列出了完整列表)。
PhoneticFilterFactory:当你使用主流搜索引擎时,你可能会注意到它在处理你的拼写错误时非常智能。这样做的一种技术是寻找与搜索关键字听起来相似的单词,以防它被拼写错误。例如,如果你本想搜索morning,但误拼为mourning,搜索仍然能匹配到意图的词条!这个分词过滤器通过与实际分词一起索引音似字符串来实现这一功能。该过滤器需要一个名为encoder的参数,设置为支持的字符编码算法名称("DoubleMetaphone"是一个合理的选择)。
分析器定义将一些这些组件的组合成一个逻辑整体,在索引实体或单个字段时可以引用这个整体。分析器可以在静态方式下定义,也可以根据运行时的一些条件动态地组装。
...@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注解必须有一个名称元素设置,正如之前讨论的,分析器必须始终包括一个且只有一个分词器。
charFilters和filters元素是可选的。如果设置,它们分别接收一个或多个工厂类列表,用于字符过滤器和分词过滤器。
请注意,字符过滤器和分词过滤器是按照它们列出的顺序应用的。在某些情况下,更改顺序可能会影响最终结果。
@Analyzer注解用于选择并应用一个自定义分析器。这个注解可以放在个别字段上,或者放在整个类上,影响每个字段。在这个例子中,我们只为desciption字段选择我们的分析器定义:
...@Column(length=1000)@Field@Analyzer(definition="appAnalyzer")privateStringdescription;...在一个类中定义多个分析器是可能的,通过将它们的@AnalyzerDef注解包裹在一个复数@AnalyzerDefs中来实现:
...@AnalyzerDefs({@AnalyzerDef(name="stripHTMLAnalyzer",...),@AnalyzerDef(name="applyRegexAnalyzer",...)})...显然,在后来应用@Analyzer注解的地方,其定义元素必须与相应的@AnalyzerDef注解的名称元素匹配。
Snowball和音译过滤器被应用于应用描述中。关键词mourning找到包含单词morning的匹配项,而development的搜索返回了描述中包含developers的应用程序。
可以等到运行时为字段选择一个特定的分析器。最明显的场景是一个支持不同语言的应用程序,为每种语言配置了分析器定义。您希望根据每个对象的言语属性选择适当的分析器。
为了支持这种动态选择,对特定的字段或整个类添加了@AnalyzerDiscriminator注解。这个代码段使用了后者的方法:
@AnalyzerDefs({@AnalyzerDef(name="englishAnalyzer",...),@AnalyzerDef(name="frenchAnalyzer",...)})@AnalyzerDiscriminator(impl=CustomerReviewDiscriminator.class)publicclassCustomerReview{...@FieldprivateStringlanguage;...}有两个分析器定义,一个是英语,另一个是法语,类CustomerReviewDiscriminator被宣布负责决定使用哪一个。这个类必须实现Discriminator接口,并它的getAnalyzerDefinitionName方法:
publicclassLanguageDiscriminatorimplementsDiscriminator{publicStringgetAnalyzerDefinitionName(Objectvalue,Objectentity,Stringfield){if(entity==null||!(entityinstanceofCustomerReview)){returnnull;}CustomerReviewreview=(CustomerReview)entity;if(review.getLanguage()==null){returnnull;}elseif(review.getLanguage().equals("en")){return"englishAnalyzer";}elseif(review.getLanguage().equals("fr")){return"frenchAnalyzer";}else{returnnull;}}}如果@AnalyzerDiscriminator注解放在字段上,那么其当前对象的值会自动作为第一个参数传递给getAnalyzerDefinitionName。如果注解放在类本身上,则传递null值。无论如何,第二个参数都是当前实体对象。
在这种情况下,鉴别器应用于类级别。所以我们将第二个参数转换为CustomerReview类型,并根据对象的language字段返回适当的分析器名称。如果语言未知或存在其他问题,则该方法简单地返回null,告诉HibernateSearch回退到默认分析器。
固定的提升,无论实际数据如何,都像注解一个类或字段一样简单,只需要使用@Boost。这个注解接受一个浮点数参数作为其相对权重,默认权重为1.0\。所以,例如,@Boost(2.0f)会将一个类或字段的权重相对于未注解的类和字段加倍。
为了进行此调整,chapter4版本首先注释了App类本身:
...@Boost(2.0f)publicclassAppimplementsSerializable{...这实际上使得App的权重是Device或CustomerReview的两倍。接下来,我们对名称和完整描述字段应用字段级提升:
请注意,类级别和字段级别的提升是级联和结合的!当给定字段应用多个提升因子时,它们会被乘以形成总因子。
在这里,因为已经对App类本身应用了2.0的权重,name的总有效权重为3.0,description为2.4。
...@DynamicBoost(impl=FiveStarBoostStrategy.class)publicclassCustomerReview{...这个注解必须传递一个实现BoostStrategy接口的类,以及它的defineBoost方法:
publicclassFiveStarBoostStrategyimplementsBoostStrategy{publicfloatdefineBoost(Objectvalue){if(value==null||!(valueinstanceofCustomerReview)){return1;}CustomerReviewcustomerReview=(CustomerReview)value;if(customerReview.getStars()==5){return1.5f;}else{return1;}}}当@DynamicBoost注解应用于一个类时,传递给defineBoost的参数自动是该类的一个实例(在这个例子中是一个CustomerReview对象)。如果注解是应用于一个特定的字段,那么自动传递的参数将是那个字段的值。
字段索引有专门的处理方式,比如使用类桥接或程序化映射API。总的来说,当一个属性被注解为@Field时,它就会被索引。因此,避免索引字段的一个明显方法就是简单地不应用这个注解。
然而,如果我们希望一个实体类通常可被搜索,但我们需要根据它们数据在运行时的状态排除这个类的某些实例怎么办?
@Indexed注解有一个实验性的第二个元素interceptor,它给了我们条件索引的能力。当这个元素被设置时,正常的索引过程将被自定义代码拦截,这可以根据实体的当前状态阻止实体被索引。
让我们给我们的VAPORwareMarketplace添加使应用失效的能力。失效的应用仍然存在于数据库中,但不应该向客户展示或进行索引。首先,我们将向App实体类添加一个新属性:
...@Columnprivatebooleanactive;...publicApp(Stringname,Stringimage,Stringdescription){this.name=name;this.image=image;this.description=description;this.active=true;}...publicbooleanisActive(){returnactive;}publicvoidsetActive(booleanactive){this.active=active;}...这个新的active变量有标准的getter和setter方法,并且在我们的正常构造函数中被默认为true。我们希望在active变量为false时,个别应用被排除在Lucene索引之外,所以我们给@Indexed注解添加了一个interceptor元素:
...importcom.packtpub.hibernatesearch.util.IndexWhenActiveInterceptor;...@Entity@Indexed(interceptor=IndexWhenActiveInterceptor.class)publicclassApp{...这个元素必须绑定到一个实现EntityIndexingInterceptor接口的类上。由于我们刚刚指定了一个名为IndexWhenActiveInterceptor的类,所以我们现在需要创建这个类。
onAdd():当实体实例第一次被创建时调用。
onDelete():当实体实例从数据库中被移除时调用。
onUpdate():当一个现有实例被更新时调用。
onCollectionUpdate():当一个实体作为其他实体的批量更新的一部分被修改时使用这个版本。通常,这个方法的实现简单地调用onUpdate()。
这些方法中的每一个都应该返回IndexingOverride枚举的四种可能值之一。可能的返回值告诉HibernateSearch应该做什么:
IndexingOverride.REMOVE:如果实体已经在索引中,HibernateSearch将删除该实体;如果实体没有被索引,则什么也不做。
IndexingOverride.UPDATE:实体将在索引中更新,或者如果它还没有被索引,将被添加。
IndexingOverride.APPLY_DEFAULT:这等同于自定义拦截器根本没有被使用。HibernateSearch将索引实体,如果这是一个onAdd()操作;如果这是一个onDelete(),则将其从索引中移除;或者如果这是onUpdate()或onCollectionUpdate(),则更新索引。
尽管这四种方法在逻辑上暗示了某些返回值,但实际上如果你处理的是异常情况,可以任意组合它们。
在我们的示例应用程序中,我们的拦截器在onAdd()和onDelete()中检查实体。当创建一个新的App时,如果其active变量为false,则跳过索引。当App被更新时,如果它变得不活跃,它将被从索引中移除。
在本章中,我们全面了解了为搜索而映射持久对象所提供的所有功能。现在我们可以调整HibernateSearch内置类型桥接的设置,并且可以创建高度先进的自定义桥接。
现在我们对Lucene分析有了更深入的了解。我们使用了一些最实用的自定义分析器组件,并且知道如何独立获取数十个其他Solr组件的信息。
在下一章中,我们将转向更高级的查询概念。我们将学习如何过滤和分类搜索结果,并从Lucene中提取数据,而不需要数据库调用。
在本章中,我们将详细阐述我们在前面章节中介绍的基本搜索查询概念,并融入我们刚刚学到的新的映射知识。现在,我们将探讨使搜索查询更具灵活性和强大性的多种技术。
构建查询的过程围绕着寻找匹配项。然而,有时你希望根据一个明确没有匹配的准则来缩小搜索结果。例如,假设我们想要限制我们的VAPORwareMarketplace搜索,只支持特定设备上的那些应用:
向现有查询添加关键词或短语是没有帮助的,因为这只会使查询更加包容。
我们可以将现有的查询转换为一个布尔查询,增加一个额外的must子句,但这样DSL开始变得难以维护。此外,如果你需要使用复杂的逻辑来缩小你的结果集,那么DSL可能提供不了足够的灵活性。
一个HibernateSearch的FullTextQuery对象继承自HibernateORM的Query(或其JPA对应物)类。因此,我们可以使用像ResultTransformer这样的核心Hibernate工具来缩小结果集。然而,这需要进行额外的数据库调用,这可能会影响性能。
HibernateSearch提供了一种更优雅和高效的过滤器方法。通过这种机制,各种场景的过滤逻辑被封装在单独的类中。这些过滤器类可以在运行时动态地启用或禁用,也可以以任何组合方式使用。当查询被过滤时,不需要从Lucene获取不想要的结果。这减少了后续数据库访问的负担。
为了通过支持设备来过滤我们的搜索结果,第一步是创建一个存储过滤逻辑的类。这应该是org.apache.lucene.search.Filter的实例。对于简单的硬编码逻辑,你可能只需创建你自己的子类。
然而,如果我们通过过滤器工厂动态地生成过滤器,那么我们就可以接受参数(例如,设备名称)并在运行时定制过滤器:
publicclassDeviceFilterFactory{privateStringdeviceName;@FactorypublicFiltergetFilter(){PhraseQueryquery=newPhraseQuery();StringTokenizertokenzier=newStringTokenizer(deviceName);while(tokenzier.hasMoreTokens()){Termterm=newTerm("supportedDevices.name",tokenzier.nextToken());query.add(term);}Filterfilter=newQueryWrapperFilter(query);returnnewCachingWrapperFilter(filter);}publicvoidsetDeviceName(StringdeviceName){this.deviceName=deviceName.toLowerCase();}}@Factory注解应用于负责生成Lucene过滤器对象的方法。在这个例子中,我们注解了恰当地命名为getFilter的方法。
不幸的是,构建LuceneFilter对象要求我们更紧密地与原始LuceneAPI合作,而不是HibernateSearch提供的方便的DSL包装器。Lucene完整API非常复杂,要完全覆盖它需要一本完全不同的书。然而,即使这种浅尝辄止也足够深入地为我们提供编写真正有用过滤器的工具。
这个例子通过包装一个Lucene查询来构建过滤器,然后应用第二个包装器以促进过滤器缓存。使用特定类型的查询是org.apache.lucene.search.PhraseQuery,它相当于我们在第三章,执行查询中探讨的DSL短语查询。
让我们回顾一下关于数据在Lucene索引中是如何存储的一些知识。默认情况下,分析器对字符串进行分词,并将它们作为单独的词项进行索引。默认分析器还将字符串数据转换为小写。HibernateSearchDSL通常隐藏所有这些细节,因此开发人员不必考虑它们。
然而,当你直接使用LuceneAPI时,确实需要考虑这些事情。因此,我们的setDeviceName设置器方法手动将deviceName属性转换为小写,以避免与Lucene不匹配。getFilter方法随后手动将此属性拆分为单独的词项,同样是为了与Lucene索引的匹配。
默认情况下,HibernateSearch为更好的性能缓存过滤器实例。因此,每个实例需要引用缓存中的唯一键。在这个例子中,最逻辑的键将是每个实例过滤的设备名称。
首先,我们在过滤器工厂中添加一个新方法,用@Key注解表示它负责生成唯一键。这个方法返回FilterKey的一个子类:
...@KeyPublicFilterKeygetKey(){DeviceFilterKeykey=newDeviceFilterKey();key.setDeviceName(this.deviceName);returnkey;}...自定义FilterKey子类必须实现equals和hashCode方法。通常,当实际包装的数据可以表示为字符串时,你可以委派给String类相应的equals和hashCode方法:
publicclassDeviceFilterKeyextendsFilterKey{privateStringdeviceName;@Overridepublicbooleanequals(ObjectotherKey){if(this.deviceName==null||!(otherKeyinstanceofDeviceFilterKey)){returnfalse;}DeviceFilterKeyotherDeviceFilterKey=(DeviceFilterKey)otherKey;returnotherDeviceFilterKey.deviceName!=null&&this.deviceName.equals(otherDeviceFilterKey.deviceName);}@OverridepublicinthashCode(){if(this.deviceName==null){return0;}returnthis.deviceName.hashCode();}//GETTERANDSETTERFORdeviceName...}建立过滤器定义为了使这个过滤器对我们应用的搜索可用,我们将在App实体类中创建一个过滤器定义:
...@FullTextFilterDefs({@FullTextFilterDef(name="deviceName",impl=DeviceFilterFactory.class)})publicclassApp{...@FullTextFilterDef注解将实体类与给定的过滤器或过滤器工厂类关联,由impl元素指定。name元素是一个字符串,HibernateSearch查询可以用它来引用过滤器,正如我们在下一小节中看到的。
一个entity类可以有任意数量的定义过滤器。复数形式的@FullTextFilterDefs注解支持这一点,通过包裹一个由逗号分隔的一个或多个单数形式的@FullTextFilterDef注解列表。
最后但并非最不重要的是,我们使用FullTextQuery对象的enableFullTextFilter方法为HibernateSearch查询启用过滤器定义:
...if(selectedDevice!=null&&!selectedDevice.equals("all")){hibernateQuery.enableFullTextFilter("deviceName").setParameter("deviceName",selectedDevice);}...这个方法的string参数与查询中涉及的实体类之一的过滤器定义相匹配。在这个例子中,是App上定义的deviceName过滤器。当HibernateSearch找到这个匹配项时,它会自动调用相应的过滤器工厂来获取一个Filter对象。
我们的过滤器工厂使用一个参数,也称为deviceName以保持一致性(尽管它是一个不同的变量)。在HibernateSearch可以调用工厂方法之前,这个参数必须被设置,通过将参数名和值传递给setParameter。
过滤器是在if块中启用的,这样在没有选择设备时(也就是,所有设备选项),我们可以跳过这一步。如果你检查本章版本VAPORwareMarketplace应用的可下载代码包,你会看到HTML文件已经被修改为添加了设备选择的下拉菜单:
在前几章中,我们的示例应用程序在一次大的数据库调用中获取所有匹配的实体。我们在第三章,执行查询中引入了分页,以至少限制数据库调用到的行数。然而,由于我们最初已经在Lucene索引中搜索数据,真的有必要去数据库吗?
休眠搜索提供了投影作为一种减少或至少消除数据库访问的技术。基于投影的搜索只返回从Lucene中提取的特定字段,而不是从数据库中返回完整的实体对象。然后你可以去数据库获取完整的对象(如果需要),但Lucene中可用的字段本身可能就足够了。
本章的VAPORwareMarketplace应用程序版本的搜索结果页面修改为现在使用基于查询的投影。之前的版本页面一次性收到App实体,并在点击每个应用的完整详情按钮之前隐藏每个应用的弹出窗口。现在,页面只接收足够构建摘要视图的字段。每个完整详情按钮触发对该应用的AJAX调用。只有在那时才调用数据库,并且仅为了获取那一个应用的数据。
从JavaScript中进行AJAX调用以及编写响应这些调用的RESTful网络服务的详尽描述,已经超出了本HibernateSearch书籍的范围。
要将FullTextQuery更改为基于投影的查询,请对该对象调用setProjection方法。现在我们的搜索servlet类包含以下内容:
...hibernateQuery.setProjection("id","name","description","image");...该方法接受一个或多个字段名称,从与该查询关联的Lucene索引中提取这些字段。
如果我们到此为止,那么查询对象的list()方法将不再返回App对象的列表!默认情况下,基于投影的查询返回对象数组列表(即Object[])而不是实体对象。这些数组通常被称为元组。
在某些情况下,直接使用元组是很简单的。然而,您可以通过将HibernateORM结果转换器附加到查询来自动将元组转换为对象形式。这样做再次改变了查询的返回类型,从List
...hibernateQuery.setResultTransformer(newAliasToBeanResultTransformer(App.class));...您可以创建自己的自定义转换器类,继承自ResultTransformer,实现您需要的任何复杂逻辑。然而,在大多数情况下,HibernateORM提供的开箱即用的子类已经足够了。
这里,我们使用AliasToBeanResultTransformer子类,并用我们的App实体类对其进行初始化。这将与投影字段匹配,并将每个属性的值设置为相应的字段值。
只有App的一部分属性是可用的。保留其他属性未初始化是可以的,因为搜索结果的JSP在构建其摘要列表时不需要它们。另外,生成的App对象实际上不会附加到Hibernate会话。然而,我们在此之前已经将我们的结果分离,然后再发送给JSP。
默认情况下,Lucene索引是为假设它们不会用于基于投影的查询而优化的。因此,投影需要你做一些小的映射更改,并记住几个注意事项。
首先,字段数据必须以可以轻松检索的方式存储在Lucene中。正常的索引过程优化数据以支持复杂查询,而不是以原始形式检索。为了以可以被投影恢复的形式存储字段的值,你需要在@Field注解中添加一个store元素:
...@Field(store=Store.COMPRESS)privateStringdescription;...这个元素取三个可能值的枚举:
Store.NO是默认值。它使字段被索引用于搜索,但不能通过投影以原始形式检索。
Store.YES使字段以原样包含在Lucene索引中。这增加了索引的大小,但使投影变得可能。
Store.COMPRESS是对妥协的尝试。它也将字段存储原样,但应用压缩以减少整体索引大小。请注意,这更占用处理器资源,并且不适用于同时使用@NumericField注解的字段。
其次,一个字段必须使用双向字段桥。HibernateSearch中所有内置的默认桥都支持这一点。然而,如果你创建自己的自定义桥类型(请参阅第四章,高级映射),它必须基于TwoWayStringBridge或TwoWayFieldBridge。
最后但并非最不重要的是,投影仅适用于实体类本身的基属性。它不是用来获取关联实体或内嵌对象的。如果你尝试引用一个关联,那么你只能得到一个实例,而不是你可能期望的完整集合。
如果你需要与关联或内嵌对象一起工作,那么你可能需要采用我们示例应用程序所使用的方法。Lucene投影检索所有搜索结果的基本属性,包括实体对象的的主键。当我们后来需要与实体对象的关联一起工作时,我们通过数据库调用使用那个主键只检索必要的行。
Lucene过滤器是缩小查询范围到特定子集的强大工具。然而,过滤器对预定义的子集起作用。你必须已经知道你在寻找什么。
有时你需要动态地识别子集。例如,让我们给我们的App实体一个表示其类别的category属性:
...@Column@FieldprivateStringcategory;...当我们为应用执行关键字搜索时,我们可能想知道哪些类别在结果中有所体现以及每个类别下有多少结果。我们还可能想知道发现了哪些价格范围。所有这些信息都有助于用户更有效地缩小查询。
动态识别维度然后通过它们进行过滤的过程称为切片搜索。HibernateSearch查询DSL有一个流程为此,从QueryBuilder对象的facet方法开始:
离散切片请求流程(虚线灰色箭头表示可选路径)
discrete子句表示我们是按单个值分组,而不是按值的范围分组。我们将在下一节探讨范围切片。
createFacetingRequest方法完成此过程并返回一个FacetingRequest对象。然而,还有三个可选的方法,你可以先调用它们中的任何一个,可以任意组合:
includeZeroCounts:它导致HibernateSearch返回所有可能的切片,甚至在当前搜索结果中没有任何点击的那些。默认情况下,没有点击的切片会被悄悄忽略。
maxFacetCount:它限制返回的切片数量。
COUNT_DESC:这与COUNT_ASC正好相反。切片从点击量最高到最低依次列出。
本章版本的VAPORwareMarketplace现在包括以下设置app类别切片搜索的代码:
...List
使用这两个方法,我们的搜索servlet可以遍历所有类别切片,并构建一个集合,用于在搜索结果JSP中显示:
...Map
面元不仅仅限于单一的离散值。一个面元也可以由一个值范围创建。例如,我们可能想根据价格范围对应用程序进行分组——搜索结果中的价格低于一美元、在一到五美元之间,或者高于五美元。
HibernateSearchDSL的范围面元需要将离散面元流程的元素与我们在第三章执行查询中看到的范围查询的元素结合起来:
范围面元请求流程(虚线灰色箭头代表可选路径)
您可以定义一个范围为大于、小于或介于两个值之间(即from–to)。这些选项可以组合使用以定义尽可能多的范围子集。
与常规范围查询一样,可选的excludeLimit方法将其边界值从范围内排除。换句话说,above(5)意味着“大于或等于5”,而above(5).excludeLimit()意味着“大于5,期终”。
可选的includeZeroCounts、maxFacetCount和orderBy方法与离散面元的方式相同。然而,范围面元提供了一个额外的排序顺序选择。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的源代码,现在包括了在每次搜索中找到的类别和价格范围面元。这两种面元类型可以组合使用以缩小搜索结果,当前选中的面元以粗体突出显示。当所有选中任一类型时,该特定面元被移除,搜索结果再次扩大。
HibernateSearchDSL中的所有查询类型都包括onField和andField方法。对于每个查询类型,这两个子句也支持一个boostedTo方法,它接受一个weight因子作为float参数。无论该字段索引时的权重可能是什么,添加一个boostedTo子句就会将它乘以指示的数字:
...luceneQuery=queryBuilder.phrase().onField("name").boostedTo(2).andField("description").boostedTo(2).andField("supportedDevices.name").andField("customerReviews.comments").sentence(unquotedSearchString).createQuery();...在本章的VAPORwareMarketplace应用程序版本中,查询时的提升现在添加到了“确切短语”用例中。当用户用双引号括起他们的搜索字符串以通过短语而不是关键词进行搜索时,我们想要给App实体的名称和描述字段比正常情况下更多的权重。高亮显示的更改将这两个字段在索引时的权重加倍,但只针对确切短语查询,而不是所有查询类型。
我们一直在工作的这个示例应用程序有一个有限的测试数据集,只有十几款应用程序和几款设备。因此,只要你的计算机有合理的处理器和内存资源,搜索查询应该几乎立即运行。
查询运行后,你可以通过调用对象的hasPartialResults()方法来确定是否被中断。这个布尔方法如果在查询在自然结束之前超时就返回true。
第二种方法,使用setTimeout()函数,在概念上和接受的参数上与第一种相似:
另外,这些超时设置只影响Lucene访问。一旦你的查询完成了对Lucene的搜索并开始从数据库中提取实际实体,超时控制就由HibernateORM而不是HibernateSearch来处理。
在下一章中,我们将转向管理和维护的内容,学习如何配置HibernateSearch和Lucene以实现最佳性能。
在本章中,我们将查看Lucene索引的配置选项,并学习如何执行基本维护任务。我们将了解如何切换Lucene索引的自动和手动更新。我们将研究低延迟写操作、同步与异步更新以及其他性能优化选择。
我们将介绍如何为更好的性能对Lucene索引进行碎片整理和清理,以及如何完全不用接触硬盘存储来使用Lucene。最后但并非最不重要的是,我们将接触到Luke这个强大的工具,用于在应用程序代码之外操作Lucene索引。
然而,你有选择将这些操作解耦的选项,如果你愿意,可以手动索引。一些你可能考虑手动方法的常见情况如下:
如果你想使用条件索引,但又不习惯EntityIndexingInterceptor的实验性质(参见第四章,高级映射),你可以使用手动索引作为一种替代方法。
如果你的数据库可能直接被不通过HibernateORM的过程更新,你必须定期手动更新Lucene索引,以保持它们与数据库同步。
要禁用自动索引,请在hibernate.cfg.xml(或使用JPA时的persistence.xml)中设置hibernate.search.indexing_strategy属性为manual,如下所示:
...
这些方法中最重要的是index,它同时处理数据库侧的添加和更新操作。此方法接受一个参数,是任何为HibernateSearch索引配置的实体类的实例。
本章的VAPORwareMarketplace应用程序使用手动索引。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);...HibernateSearch还提供了purgeAll,这是一个方便的方法,用于删除特定实体类型的所有实例。这个方法也需要实体类引用,尽管显然不需要传递特定的ID:
...fullTextSession.purgeAll(App.class);...与index一样,purge和purgeAll都在事务内操作。删除实际上直到事务提交才会发生。如果在回滚的情况下,什么也不会发生。
如果你想在事务提交之前真正地向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(InterruptedExceptione){logger.error("InterruptedwhilewatingonMassIndexer:"+e.getClass().getName()+","+e.getMessage());}...提示如果实际可行,当应用程序离线且不响应查询时,使用批量索引会更好。索引会将系统负载加重,而且Lucene与数据库相比会处于一个非常不一致的状态。
大规模索引与个别更新在两个方面有所不同:
MassIndexer操作不是事务性的。没有必要将操作包装在Hibernate事务中,同样,如果出现错误,你也不能依赖回滚。
MassIndexer确实支持条件索引(参考第四章,高级映射)。如果你为那个实体类注册了一个EntityIndexingInterceptor,它将被调用以确定是否实际索引特定实例。
MassIndexer对条件索引的支持是在HibernateSearch的4.2代中添加的。如果你正在使用一个较老版本的应用程序,你需要将应用程序迁移到4.2或更高版本,以便同时使用EntityIndexingInterceptor和MassIndexer。
将所有这些片段合并在一起,并真正清除已删除实体的过程称为优化。这个过程类似于对硬盘进行碎片整理。HibernateSearch提供了基于手动或自动的基础上的索引优化机制。
SearchFactory类提供了两种手动优化Lucene索引的方法。你可以在应用程序中的任何你喜欢的事件上调用这些方法。或者,你可能会公开它们,并从应用程序外部触发优化(例如,通过一个由夜间cron作业调用的web服务)。
您可以通过FullTextSession对象的getSearchFactory方法获得一个SearchFactory引用。一旦你有了这个实例,它的optimize方法将会碎片化所有可用的Lucene索引:
...fullTextSession.getSearchFactory().optimize();...另外,您可以使用一个带有实体类参数的optimize重载版本。这个方法将优化限制在只对该实体的Lucene索引进行优化,如下所示:
...fullTextSession.getSearchFactory().optimize(App.class);...注意另一个选择是使用MassIndexer重新构建你的Lucene索引(参考大规模更新部分)。从零开始重建索引无论如何都会使其处于优化状态,所以如果你已经定期执行这种类型的维护工作,进一步的优化将是多余的。
一个非常手动的方法是使用Luke工具,完全不在你的应用程序代码中。请参阅本章末尾关于Luke的部分。
一个更简单,但灵活性较低的方法是让HibernateSearch自动为你触发优化。这可以全局或针对每个索引执行。触发事件可以是Lucene更改的阈值数量,或者事务的阈值数量。
VAPORwareMarketplace应用程序的chapter6版本现在在其hibernate.cfg.xml文件中包含了以下四行:
通常这是实体类的名称(例如,App),但如果你设置了该实体的@Indexed注解中的index元素,它也可以是一个自定义名称。
无论你是在全局还是索引特定级别操作,operation_limit.max指的是Lucene更改(即添加或删除)的阈值数量。transaction_limit.max指的是事务的阈值数量。
总的来说,此代码段配置了在100个事务或Lucene更改后对App索引进行优化。所有其他索引将在1,000个事务或更改后进行优化。
publicclassNightlyOptimizerStrategyextendsIncrementalOptimizerStrategy{@Overridepublicvoidoptimize(Workspaceworkspace){Calendarcalendar=Calendar.getInstance();inthourOfDay=calendar.get(Calendar.HOUR_OF_DAY);if(hourOfDay>=0&&hourOfDay<=6){super.optimize(workspace);}}}提示最简单的方法是扩展IncrementalOptimizerStrategy,并用你的拦截逻辑覆盖optimize方法。然而,如果你的策略与默认策略根本不同,那么你可以从自己的基类开始。只需让它实现OptimizerStrategy接口。
...
休眠搜索自带两种索引管理器实现。默认的是基于directory-based的,在大多数情况下这是一个非常合理的选择。
另一个内置选项是近实时。它是一个从基于目录的索引管理器派生的子类,但设计用于低延迟的索引写入。而不是立即在磁盘上执行添加或删除,这个实现将它们排队在内存中,以便更有效地批量写入。
近实时实现比基于目录的默认实现具有更好的性能,但有两个权衡。首先,当在集群环境中使用Lucene时,近实时实现是不可用的(参考第七章,高级性能策略)。其次,由于Lucene操作不会立即写入磁盘,因此在应用程序崩溃的情况下可能会永久丢失。
与本章中介绍的大多数配置属性一样,索引管理器可以在全局默认或每索引的基础上选择。区别在于是否包括default,或者实体索引名称(例如,App)在属性中:
...
编写自定义实现的一种简单方法是继承两个内置选项中的一个,并根据需要重写方法。如果您想从头开始创建自定义索引管理器,那么它需要实现org.hibernate.search.indexes.spi.IndexManager接口。
在全局或每索引级别应用自定义索引管理器与内置选项相同。只需将适当的属性设置为您的实现的全限定类名(例如,com.packtpub.hibernatesearch.util.MyIndexManager),而不是directory-based或near-real-time字符串。
索引管理器协调的组件类型之一是工作者,它们负责对Lucene索引进行实际的更新。
如果您在集群环境中使用Lucene和HibernateSearch,许多配置选项是在工作者级别设置的。我们将在第七章,高级性能策略中更全面地探讨这些内容。然而,在任何环境中都提供了三个关键的配置选项。
默认情况下,工作者执行Lucene更新同步。也就是说,一旦开始更新,主线的执行就会被阻塞,直到更新完成。
...
...
挂起的工作会保存在队列中,等待线程空闲时处理。默认情况下,这个缓冲区的大小是无限的,至少在理论上如此。实际上,它受到可用系统内存量的限制,如果缓冲区增长过大,可能会抛出OutOfMemoryExeception。
因此,为这些缓冲区设置一个全局大小或每个索引大小的限制是一个好主意。
...
内置的索引管理器都使用了一个子类DirectoryBasedIndexManager。正如其名,它们都利用了Lucene的抽象类Directory,来管理索引存储的形式。
在第七章中,我们将探讨一些特殊目录实现,这些实现是为集群环境量身定做的。然而,在单服务器环境中,内置的两种选择是文件系统存储和内存中的存储。
默认情况下,Lucene索引存储在Java应用程序的当前工作目录中。对于这种安排,无需进行任何配置,但在VAPORwareMarketplace应用程序的所有版本中,都明确设置了这个属性在hibernate.cfg.xml(或persistence.xml)中:
...
当使用基于文件系统的索引时,您可能希望使用一个已知的固定位置,而不是当前工作目录。您可以使用indexBase属性指定相对路径或绝对路径。在我们见过的所有VAPORwareMarketplace版本中,Lucene索引都存储在每个Maven项目的target目录下,这样Maven在每次全新构建之前会删除它们:
...
native:当没有指定锁策略属性时,基于文件系统的目录默认采用的策略。它依赖于本地操作系统级别的文件锁,因此如果您的应用程序崩溃,索引锁仍然会被释放。然而,这种策略不适用于您的索引存储在远程网络共享驱动器上时。
simple:这种策略依赖于JVM来处理文件锁。当您的Lucene索引存储在远程共享驱动器上时,使用这种策略更安全,但如果应用程序崩溃或被杀死,锁不会被干净地释放。
single:这种策略不会在文件系统上创建锁文件,而是使用内存中的Java对象(类似于多线程Java代码中的synchronized块)。对于单JVM应用程序,无论索引文件在哪里,这种方法都工作得很好,而且在崩溃后没有锁被释放的问题。然而,这种策略只有在您确信没有任何其他外部JVM进程可能会写入您的索引文件时才是可行的。
none:根本不使用锁。这不是一个推荐的选项。
为了删除未干净释放的锁,请使用本章使用Luke工具部分探索的Luke工具。
出于测试和演示目的,我们这本书中的VAPORwareMarketplace应用程序一直使用内存中的H2数据库。每次应用程序启动时都会重新创建它,应用程序停止时会摧毁它,在此过程中没有任何持久化存储。
Lucene索引能够以完全相同的方式工作。在本章示例应用程序的版本中,hibernate.cfg.xml文件已经被修改以将其索引存储在RAM中,而不是文件系统上:
...
使用现代依赖注入框架时,这不应该是一个问题,因为框架会在内存中保持您的工厂实例,并在需要时可用。即使在我们的基础示例应用程序中,我们也为此原因在StartupDataLoader类中存储了一个单例SessionFactory。
内存中的索引似乎能提供更好的性能,在您的应用程序调整中尝试一下可能是值得的。然而,通常不建议在生产环境中使用基于RAM的目录提供程序。
首先,当数据集很大时,很容易耗尽内存并导致应用程序崩溃。另外,每次重新启动时,您的应用程序都必须从头开始重建索引。由于只有创建内存索引的JVM才能访问该内存,因此无法使用集群。最后但同样重要的是,基于文件系统的目录提供程序已经智能地使用了缓存,其性能出奇地与基于RAM的提供程序相当。
话虽如此,基于RAM的提供程序是测试应用程序的常见方法。单元测试可能涉及相对较小的数据集,因此耗尽内存不是问题。另外,在每次单元测试之间完全且干净地销毁索引可能更是一个特性而非缺点。
基于RAM的目录提供程序默认使用single锁定策略,而且真的没有改变它的意义。
HibernateORM为您的应用程序代码提供了与数据库交互所需的大部分功能。然而,您可能仍然需要使用某种SQL客户端,在应用程序代码的上下文之外手动操作数据库。
Luke的下载文件是一个单片式的可执行JAR文件。双击JAR文件,或者从控制台提示符执行它,会弹出一个图形界面和一个提示您索引位置的输入框,如下面的屏幕快照所示:
前一个屏幕快照显示了Luke启动时的界面。不幸的是,Luke只能访问基于文件系统的索引,而不能访问本章中使用基于RAM的索引。所以在这段示例中,Luke指向了chapter5代码文件目录的Maven项目工作区。App实体的索引位于target/luceneIndex/com.packtpub.hibernatesearch.domain.App。
请注意打开索引对话框顶部附近的强制解锁,如果锁定复选框。如果您有一个索引文件锁没有干净释放(参考锁定策略部分),则可以通过勾选此复选框并打开索引来解决问题。
一旦您打开了一个Lucene索引,Luke就会显示关于索引文档(即实体)数量的各类信息(即,碎片化)和其他详细信息,如下面的屏幕截图所示:
从工具栏顶部的工具菜单中,您可以选择执行诸如检查索引是否损坏或手动优化(即,去碎片化)等基本维护任务。这些操作最好在非高峰时段或全面停机窗口期间执行。
文档标签允许您逐一浏览实体,这可能有一些有限的用途。更有趣的是搜索标签,它允许您使用自由形式的Lucene查询来探索您的索引,如下面的屏幕截图所示:
完整的LuceneAPI超出了本书的范围,但这里有一些基础知识来帮助您入门:
搜索表达式的形式是字段名和期望值,由冒号分隔。例如,要搜索business类别的应用程序,请使用搜索表达式category:business。
记住,默认分析器在索引过程中将术语转换为小写。所以如果你想搜索xPhone,例如,请确保将其输入为xphone。
浏览这些数据将让您了解分析器如何解析您的实体。单词将被过滤掉,除非您配置了@Field注解相反(正如我们用sorting_name所做的那样),否则文本将被分词。如果HibernateSearch查询没有返回您期望的结果,Luke中浏览字段数据可以帮助您发现问题。
在本章中,我们了解了如何手动更新Lucene索引,一次一个实体对象或批量更新,作为让HibernateSearch自动管理更新的一种替代方式。我们了解了Lucene更新操作积累的碎片,以及如何基于手动或自动方法进行优化。
我们探索了Lucene的各种性能调优选项,从低延迟写入到多线程异步更新。我们现在知道如何配置HibernateSearch,在文件系统或RAM上创建Lucene索引,以及为什么您可能会选择其中之一。最后,我们使用Luke工具来检查和执行维护任务,而无需通过应用程序的HibernateSearch代码来操作Lucene索引。
在下一章中,我们将探讨一些高级策略,以提高您的应用程序的性能。这将包括回顾到目前为止介绍的性能提示,然后深入探讨服务器集群和Lucene索引分片。
在本章中,我们将探讨一些高级策略,通过代码以及服务器架构来提高生产应用程序的性能和可伸缩性。我们将探讨运行应用程序的多节点服务器集群选项,以分布式方式分散和处理用户请求。我们还将学习如何使用分片来使我们的Lucene索引更快且更易于管理。
在深入探讨一些提高性能和可伸缩性的高级策略之前,让我们简要回顾一下书中已经提到的某些通用性能优化建议。
当为HibernateSearch映射实体类时,使用@Field注解的可选元素去除Lucene索引中的不必要膨胀(参见第二章,映射实体类):
如果你确实不使用索引时提升(参见第四章,高级映射),那么就没有理由存储实现此功能所需的信息。将norms元素设置为Norms.NO。
默认情况下,除非将store元素设置为Store.YES或Store.COMPRESS(参见第五章,高级查询),否则基于投影的查询所需的信息不会被存储。如果你有不再使用的基于投影的查询,那么在进行清理时删除这个元素。
使用条件索引(参见第四章,高级映射)和部分索引(参见第二章,映射实体类)来减小Lucene索引的大小。
依赖于过滤器在Lucene层面缩小结果,而不是在数据库查询层面使用WHERE子句(参见第五章,高级查询)。
尽可能尝试使用基于投影的查询(参见第五章,高级查询),以减少或消除对数据库调用的需求。请注意,随着数据库缓存的提高,这些好处可能并不总是值得增加的复杂性。
测试各种索引管理器选项(参见第六章,系统配置和索引管理),例如尝试近实时索引管理器或async工作执行模式。
在生产环境中使现代Java应用程序扩展通常涉及在服务器实例的集群中运行它们。HibernateSearch非常适合集群环境,并提供了多种配置解决方案的方法。
最直接的方法需要非常少的HibernateSearch配置。只需为托管您的Lucene索引设置一个文件服务器,并使其可供您集群中的每个服务器实例使用(例如,NFS、Samba等):
具有多个服务器节点的简单集群,使用共享驱动上的公共Lucene索引
集群中的每个应用程序实例都使用默认的索引管理器,以及常用的filesystem目录提供程序(参见第六章,系统配置和索引管理)。
在这种安排中,所有的服务器节点都是真正的对等节点。它们各自从同一个Lucene索引中读取,无论哪个节点执行更新,那个节点就负责写入。为了防止损坏,HibernateSearch依赖于锁定策略(即“简单”或“本地”,参见第六章,系统配置和索引管理)同时写入被阻止。
回想一下,“近实时”索引管理器与集群环境是不兼容的。
这种方法的优点是两方面的。首先是简单性。涉及的步骤仅包括设置一个文件系统共享,并将每个应用程序实例的目录提供程序指向同一位置。其次,这种方法确保Lucene更新对集群中的所有节点立即可见。
然而,这种方法的严重缺点是它只能扩展到一定程度。非常小的集群可能运行得很好,但是尝试同时访问同一共享文件的更多节点最终会导致锁定争用。
另外,托管Lucene索引的文件服务器是一个单点故障。如果文件共享挂了,那么在整个集群中的搜索功能会立即灾难性地崩溃。
当您的可扩展性需求超出简单集群的限制时,HibernateSearch提供了更高级别的模型供您考虑。它们之间的共同点是主节点负责所有Lucene写操作的理念。
集群还可能包括任何数量的从节点。从节点仍然可以初始化Lucene更新,应用程序代码实际上无法区分。然而,在底层,从节点将这项工作委托给主节点实际执行。
在基于文件系统的方法中,所有节点都保留它们自己的Lucene索引的本地副本。主节点实际上在整体主索引上执行更新,所有节点定期从那个整体主索引中读取以刷新它们的本地副本。
在Infinispan基于的方法中,所有节点都从Infinispan索引中读取(尽管仍然建议将写操作委派给主节点)。因此,节点不需要维护它们自己的本地索引副本。实际上,由于Infinispan是一个分布式数据存储,索引的某些部分将驻留在每个节点上。然而,最好还是将整个索引视为一个单独的实体。
奴隶节点将写操作委派给主节点的两种可用机制:
JMS消息队列提供程序创建一个队列,奴隶节点将有关Lucene更新请求的详细信息发送到这个队列。主节点监控这个队列,检索消息,并实际执行更新操作。
然而,JMS消息通常在等待检索时持久化到磁盘上,因此可以在应用程序崩溃的情况下恢复并稍后处理。如果您使用JGroups并且主节点离线,那么在停机期间奴隶节点发送的所有更新请求都将丢失。为了完全恢复,您可能需要手动重新索引您的Lucene索引。
一个基于文件系统或Infinispan的目录提供程序和基于JMS或JGroups的工作程序的主从集群。请注意,当使用Infinispan时,节点不需要它们自己的单独索引副本。
要尝试所有可能的集群策略,需要查阅HibernateSearch参考指南,以及Infinispan和JGroups的文档。然而,我们将从实现使用文件系统和JMS方法的集群开始,因为其他所有内容都只是这个标准主题的变体。
本章版本的VAPORwareMarketplace应用摒弃了我们一直使用的MavenJetty插件。这个插件非常适合测试和演示目的,但它只适用于运行单个服务器实例,而我们现在需要同时运行至少两个Jetty实例。
...StringprojectBaseDirectory=System.getProperty("user.dir");...ServermasterServer=newServer(8080);WebAppContextmasterContext=newWebAppContext();masterContext.setDescriptor(projectBaseDirectory+"/target/vaporware/WEB-INF/web.xml");...masterServer.setHandler(masterContext);masterServer.start();...ServerslaveServer=newServer(8181);WebAppContextslaveContext=newWebAppContext();slaveContext.setDescriptor(projectBaseDirectory+"/target/vaporware/WEB-INF/web-slave.xml");...slaveServer.setHandler(slaveContext);slaveServer.start();...尽管所有这些都在一台物理机器上运行,但我们为了测试和演示目的模拟了一个集群。一个Jetty服务器实例在端口8080上作为主节点启动,另一个Jetty服务器在端口8181上作为从节点启动。这两个节点之间的区别在于,它们使用不同的web.xml文件,在启动时相应地加载不同的监听器。
在这个应用程序的先前版本中,一个StartupDataLoader类处理了所有数据库和Lucene的初始化。现在,两个节点分别使用MasterNodeInitializer和SlaveNodeInitializer。这些依次从名为hibernate.cfg.xml和hibernate-slave.cfg.xml的不同文件加载HibernateORM和HibernateSearch设置。
有许多方法可以配置一个应用程序以作为主节点或从节点实例运行。而不是构建不同的WAR,具有不同的web.xml或hibernate.cfg.xml版本,你可能会使用依赖注入框架根据环境中的某个内容加载正确的设置。
Hibernate的两种版本都设置了config文件中的以下HibernateSearch属性:
hibernate.search.default.directory_provider:在之前的章节中,我们看到这个属性被设置为filesystem或ram。之前讨论过的另一个选项是infinispan。
然而,“主”变体包含了定期刷新整体主Lucene索引的功能。而“从”变体则相反,定期用整体主内容刷新其本地副本。
hibernate.search.default.indexBase:正如我们之前在单节点版本中看到的,这个属性包含了本地Lucene索引的基础目录。由于我们这里的示例集群在同一台物理机器上运行,主节点和从节点对这个属性使用不同的值。
hibernate.search.default.sourceBase:这个属性包含了整体主Lucene索引的基础目录。在生产环境中,这将是某种共享文件系统,挂在并可供所有节点访问。在这里,节点在同一台物理机器上运行,所以主节点和从节点对这个属性使用相同的值。
hibernate.search.default.refresh:这是索引刷新之间的间隔(以秒为单位)。主节点在每个间隔后刷新整体主索引,奴隶节点使用整体主索引刷新它们自己的本地副本。本章的VAPORwareMarketplace应用程序使用10秒的设置作为演示目的,但在生产环境中这太短了。默认设置是3600秒(一小时)。
为了建立一个JMS工作后端,奴隶节点仅需要三个额外的设置:
hibernate.search.default.worker.backend:将此值设置为jms。默认值lucene在之前的章节中已经应用,因为没有指定设置。如果你使用JGroups,那么它将被设置为jgroupsMaster或jgroupsSlave,这取决于节点类型。
hibernate.search.default.worker.jms.connection_factory:这是HibernateSearch在JNDI中查找你的JMS连接工厂的名称。这与HibernateORM使用connection.datasource属性从数据库检索JDBC连接的方式类似。
在这两种情况下,JNDI配置都是特定于你的应用程序运行的应用服务器。要了解JMS连接工厂是如何设置的,请查看src/main/webapp/WEB-INF/jetty-env.xml这个Jetty配置文件。在这个示例中我们使用ApacheActiveMQ,但任何兼容JMS的提供商都会同样适用。
hibernate.search.default.worker.jms.queue:从奴隶节点向Lucene发送写请求的JMS队列的JNDI名称。这也是在应用服务器级别配置的,紧挨着连接工厂。
使用这些工作后端设置,奴隶节点将自动向JMS队列发送一条消息,表明需要Lucene更新。为了看到这种情况的发生,新的MasterNodeInitializer和SlaveNodeInitializer类各自加载了一半的通常测试数据集。如果我们所有的测试实体最终都被一起索引,并且可以从任一节点运行的搜索查询中检索到它们,那么我们就会知道我们的集群运行正常。
尽管HibernateSearch会自动从奴隶节点向JMS队列发送消息,但让主节点检索这些消息并处理它们是你的责任。
在JEE环境中,你可能会使用消息驱动bean,正如HibernateSearch文档所建议的那样。Spring也有一个可以利用的任务执行框架。然而,在任何框架中,基本思想是主节点应该产生一个后台线程来监控JMS队列并处理其消息。
本章的VAPORwareMarketplace应用程序包含一个用于此目的的QueueMonitor类,该类被包装在一个Thread对象中,由MasterNodeInitializer类产生。
要执行实际的Lucene更新,最简单的方法是创建您自己的自定义子类AbstractJMSHibernateSearchController。我们的实现称为QueueController,所做的只是包装这个抽象基类。
当队列监视器从JMS队列中接收到javax.jms.Message对象时,它只是原样传递给控制器的基类方法onMessage。那个内置方法为我们处理Lucene更新。
正如您所看到的,主从集群方法涉及的内容比简单集群要多得多。然而,主从方法在可扩展性方面提供了巨大的优势。
它还减少了单点故障的风险。确实,这种架构涉及一个单一的“主”节点,所有Lucene写操作都必须通过这个节点。然而,如果主节点宕机,从节点仍然可以继续工作,因为它们的搜索查询针对的是自己的本地索引副本。此外,更新请求应该由JMS提供商持久化,以便在主节点重新上线后,这些更新仍然可以执行。
由于我们程序化地启动Jetty实例,而不是通过Maven插件,因此我们将不同的目标传递给每个Maven构建。对于chapter7项目,您应该像以下这样运行Maven:
如果您的实体适合于分区(例如,按语言、地理区域等),分片可能会提供额外的优势。如果您能够可预测地将查询引导到特定的适当分片,性能可能会得到改善。此外,当您能够在物理位置不同的地方存储“敏感”数据时,有时会让律师感到高兴。
...
这个确切的行出现在hibernate.cfg.xml(由我们的“主”节点使用)和hibernate-slave.cfg.xml(由我们的“从”节点使用)中。在集群环境中运行时,你的分片配置应与所有节点匹配。
当一个索引被分成多个分片时,每个分片都包括正常的索引名称后面跟着一个数字(从零开始)。例如,是com.packtpub.hibernatesearch.domain.App.0,而不仅仅是com.packtpub.hibernatesearch.domain.App。这张截图展示了我们双节点集群的Lucene目录结构,在两个节点都配置为两个分片的情况下运行中:
集群中运行的分片Lucene索引的一个示例(注意每个App实体目录的编号)
正如分片在文件系统上编号一样,它们可以在hibernate.cfg.xml中按编号单独配置。例如,如果你想将分片存储在不同的位置,你可能如下设置属性:
...
如果你只是分片以减少文件大小,那么默认策略(由org.hibernate.search.store.impl.IdHashShardingStrategy实现)完全没问题。它使用每个实体的ID来计算一个唯一的哈希码,并将实体在分片之间大致均匀地分布。因为哈希计算是可复制的,策略能够将实体的未来更新引导到适当的分片。
要创建具有更复杂逻辑的自定义分片策略,你可以创建一个新子类,继承自IdHashShardingStrategy,并按需调整。或者,你可以完全从零开始,创建一个实现org.hibernate.search.store.IndexShardingStrategy接口的新类,或许可以参考IdHashShardingStrategy的源代码作为指导。
在本章中,我们学习了如何在现代分布式服务器架构中与应用程序一起工作,以实现可扩展性和更好的性能。我们看到了一个使用基于文件系统的目录提供程序和基于JMS的后端实现的集群,现在有了足够的知识去探索涉及Inifinispan和JGroups的其他方法。我们使用了分片将Lucene索引分成更小的块,并知道如何实施自己的自定义分片策略。
这带我们结束了与HibernateSearch的这次小冒险!我们已经涵盖了关于Hibernate、Lucene和Solr以及搜索的一般性关键概念。我们学会了如何将我们的数据映射到搜索索引中,在运行时查询和更新这些索引,并将其安排在给定项目的最佳架构中。这一切都是通过一个示例应用程序完成的,这个应用程序随着我们的知识从简单到复杂一路成长。
学无止境。HibernateSearch可以与Solr的数十个组件协同工作,以实现更高级的功能,同时也能与新一代的“NoSQL”数据存储集成。然而,现在你已经拥有了足够的核心知识,可以独立探索这些领域,如果你愿意的话。下次再见,感谢您的阅读!您可以在steveperkins.net上找到我,我很乐意收到您的来信。