HibernateSearch

转载 2016年04月09日 10:01:11

创建实体类

既然目的是为了展示软件应用,那么首当其冲的实体类就是App类了,现在我们将它定义成下面这样,拥有4个字段:

  • id:主键信息
  • name:App的名字
  • description:App的介绍
  • image:App图片的链接

@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(Stringname, Stringimage, Stringdescription) {

        this.name = name;

        this.image = image;

        this.description = description;

    }

    // 省略了众多的gettersetter

}

为使用Hibernate Search而修改实体类

有了实体类型后,我们需要告诉Hibernate Search如何来利用Lucene对该实体进行管理。

在最基本的场景中,我们只需要向该实体类型添加两个注解:

首先是添加@Indexed注解:

import org.hibernate.search.annotations.Indexed;

 

@Entity

@Indexed

public class App implements Serializable

// ...

这个注解告诉Lucene去为App实体类型创建索引。注意,并不是每个实体类型都需要这个注解,只有确定将会作为搜索目标的实体类才需要使用它。

其次,需要向具体的字段添加@Field注解:

import org.hibernate.search.annotations.Field;

 

// ...

 

@Id

@GeneratedValue

privateLong id;

 

@Column

@Field

privateString name;

 

@Column(length=1000)

@Field

privateString description;

 

@Column

privateString image;

 

// ...

这里我们向namedescription字段添加了@Field注解,表示这两个字段将会作为搜索的目标字段。同时注意到image字段并没有被@Field标注,这是因为我们不需要将图片的名字也作为可搜索的字段。

建立查询(单个查询)

向实体类添加了必要的注解后,我们就可以对它们建立查询了。主要会使用到FullTextSessionQueryBuilder类型:

import org.hibernate.Session;

import org.hibernate.search.FullTextSession;

import org.hibernate.search.Search;

 

// ...

 

Session session = StartupDataLoader.openSession();

FullTextSession fullTextSession = Search.getFullTextSession(session);

fullTextSession.beginTransaction();

 

// ...

首先建立一个Session并开始一个事务。紧接着,就需要通过传入的关键字(Keyword)来建立查询了:

import org.hibernate.search.query.dsl.QueryBuilder;

 

// ...

 

String searchString = request.getParameter("searchString");

QueryBuilder queryBuilder = fullTextSession.getSearchFactory()

    .buildQueryBuilder().forEntity( App.class ).get();

org.apache.lucene.search.Query luceneQuery = queryBuilder

    .keyword()

    .onFields("name", "description")

    .matching(searchString)

    .createQuery();

非常直观的代码,forEntity用来指定对哪个实体进行查询,onFields用来指定对哪些字段进行查询。将上面的代码翻译成更加容易理解的语言是这样的:

App实体的namedescription字段创建一个匹配searchString参数的基于关键字的查询。

这因为这种API的设计十分流畅,它也被称为Hibernate Search DSL(Domain-SpecificLanguage)另外,注意到以上的queryBuilder对象创建的查询类型是org.apache.lucene.search.Query。这就是 Hibernate SearchLucene建立联系的一种方式。在Lucene得到搜索结果后,类似地也会将结果转换成一个org.hibernate.Query象:

org.hibernate.Query hibernateQuery = fullTextSession.createFullTextQuery(luceneQuery,App.class);

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

request.setAttribute("apps", apps);

因此,Hibernate Search封装了大量的Lucene使用细节,让只了解Hibernate的开发人员也能够轻松的为应用加上全文搜索功能。

配置文件

<propertiy key=”hibernate.search.default.indexBase”value=” d:/indexDir”/>

导入包Hibernate Search

这里我们考虑使用maven时需要添加的依赖,最关键的就是:

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

实体类型映射

域映射选项(Field Mapping Options)

我们已经知道@Field注解用来让某个域可以被全文搜索到。实际上,在添加该注解后,Hibernate Search会将一些有意义的默认设置添加到相应的Lucene索引中。

这些被添加的默认设置通常都可以通过@Field的属性进行设置,下面对这些属性进行简要介绍:

  • analyze:告诉Lucene是否直接保存该域的数据或者将该域进行某种处理(Analysis, Parsing, Processing, etc)后再进行保存。可以设置成Analyze.NO或者Analyze.YES。在后面的“执行查询”一文中会对它们进行更详细的说明。
  • index:告诉Lucene当前域是否需要被索引,默认值是Index.YES。也许这个属性会显得有些奇怪,既然已经被@Field标注了,那为什么要设置是否需要被索引呢?其实,在一些高级查询中这种情况是存在的,在后面的“高级查询”一文中会进行说明。
  • indexNullAs:声明如何处理域值为null的情况。默认情况下,null值会被忽略并且也不会被添加到索引中。但是,我们也可以将null值索引成某些特殊的值。在“高级映射”中会进行介绍。
  • name:用来给该域在Lucene索引中赋予一个名字,默认使用的就是该域的名字进行设置。
  • norms:用来决定是否存储用在索引时提升(Index-time Boosting)的信息。默认设置为Norms.YES。在“高级映射”中会进行说明。
  • store: 通常情况下,域会以一种优化后的形式被保存到索引中。这样做虽然可以提升搜索性能,但是在取得搜索结果时,该域的原始数据可能也会被丢失。默认设置是 Store.NO,也就意味着原始数据也许会被丢失。当设置成Store.YES或者Store.COMPRESS后,在获取搜索结果时可以直接从 Lucene索引中取得,而不需要再访问一次数据库。在“高级查询”中会进行说明。

一域多映射

比如,当一个域需要同时被设置成能搜索能排序时,就需要设置多个映射:

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

目前,只需要了解一个域可以被设置多个@Field就够了,而且每个@Fieldname属性必须不同。因此上面代码中的一个@Fieldname被设置成了sorting_name,另一个@Field使用的是默认的name

映射数值类型的域

前面使用@Field的例子都是针对字符串类型的,显然数值类型在某些情况下也需要被索引。但是在默认情况下,数值类型也会当做字符类型被索引,因此在对数值类型的域进行排序等操作时的效率十分低下。

为了提高这种情况下的性能,Hibernate Search提供了一个注解@NumericField用来处理IntegerLongFloatDouble等数值类型:

@Column
@Field
@NumericField
privatefloat price;

当一个域存在多个@Field时,如果需要对某个@Field使用@NumericField,那么还需要设置@NumericFieldforField属性为对应的name来完成关联。

实体间的关系

一旦一个实体类型被标注为@Indexed,那么Hibernate Search仅仅会为该实体创建一个Lucene索引。因此,我们可以为每个需要被搜索的实体创建单独的Lucene索引,然而这种做法是十分低效的。

当我们使用Hibernate ORM对实体进行建模时,实体间的关系通常已经被诸如@ManyToMany@ManyToOne等注解表示了。因此,我们可以利用这一点来建立更加高效的Lucene索引。

关联的实体

为了表示某个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(Stringmanufacturer, Stringname, Set<App>supportedApps) {
        this.manufacturer = manufacturer;
        this.name = name;
        this.supportedApps = supportedApps;
    }
 
    // Getters and setters for all fields...
}

supportedApps这一域的@ManyToMany中的cascade属性设置为CascadeType.ALL的作用是为了让DeviceApp中任一实体发生改变时,索引都会被自动更新。除了保证cascade的属性外,还需要将以上多对多的关系设置成双向的:

// App实体类中
 
@ManyToMany(fetch=FetchType.EAGER, cascade = { CascadeType.ALL })
@IndexedEmbedded(depth=1)
privateSet<Device>supportedDevices;

另外,在Device实体类的supportedApps域上我们还使用了另外一个注解@ContainedIn。这个注解的作用是告诉Lucene,在App实体类型的索引中需要同时包含对应Device的数据。

同时,在App实体类中我们也使用了新的注解叫做@IndexedEmbedded。这个注解和@ContainedIn正好是一对,需要同时使用。它的目的是为了防止循环依赖,在Device中我们声明了在App的索引中需要包含Device信息,而在这里我们限制了包含的信息层次,即depth=1意义。它表示App中包含了Device信息,但是该Device信息不会再包含其对应的App信息,从而防止了循环引用。

查询关联的实体

一旦实体之间建立了联系,那么在查询中的声明就十分简单了:

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

onFields方法中,我们不仅声明了App实体上的namedescription域,还声明了关联的Device实体的name域。此时,如果我们使用某种设备的名称作为关键字进行App实体的搜索,就能够获取到支持该种设备的所有App实例了。

嵌入对象(Embedded Objects)

关联的实体本身是独立的,它们拥有独立的数据库表结构和Lucene索引。这意味着当我们删除某一个App实体的时候,与其关联的Device实体并不会被同时删除。

而与之相反,嵌入对象并不是独立的,它们没有独立的数据库表结构和Lucene索引。顾客对于某个App实体的评价信息就可以被建模为嵌入对象。当某个App实体被删除之后,相关联的评价信息也应该同时被删除。可以建模如下:

@Embeddable
public class CustomerReview {
 
    @Field
    private String username;
    private int stars;
 
    @Field
    private String comments;
 
    publicCustomerReview() {
    }
 
    public CustomerReview(Stringusername, intstars, Stringcomments) {
        this.username = username;
        this.stars = stars;
        this.comments = comments;
    }
    // Getter and setter methods...
}

这类实体使用@Embeddable进行标注,而不是使用@Entity。它表示CustomerReview对象的生命周期是由包含它的实体类型所决定的。

@Field注解的使用方式和之前并无二致。但是,对于@Embeddable类型的实体,Hibernate Search并不会为它创建单独的Lucene索引。因此@Field此时只会向包含它的实体的索引中添加必要的信息。

App实体类型中,是这样于CustomerReview建立关联的:

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

和之前使用的@ManyToMany这一类表示实体间关系的注解不同,以上代码中使用的是@ElementCollection来表示实体和嵌入对象的关系。如果关联的嵌入对象只有一个,那么@ElementCollection也是不需要的。

紧接着的@Fetch注解是Hibernate特有的一个注解,它用来保证多个CustomerReview实例是通过多个SELECT语句而不是一个OUTER JOIN进行获取的。这避免了Hibernate在读取嵌入对象时可能存在的重复记录,具体细节可以参考这里。然而在嵌入对象的数量十分巨大时,使用这种Eager的方式并不好,考虑到这里的应用场景,每个App实体的评论对象不会太多,所以使用Eager读取方式是合理的。

在查询中对嵌入对象进行查询的方式和对关联对象的查询是一致的:

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

onFields方法中使用了customerReviews.comments现在我们的应用不仅能够搜索App实体,还能够搜索其关联的Device实体和CustomerReview嵌入对象了。

部分索引(Partial Indexing)

重申一遍:对于关联实体,它们会拥有自己的索引。对于嵌入对象,它们的信息只会存在于包含它的实体的索引中。

但是,需要注意的是嵌入对象可能被嵌入到不止一个实体中。比如嵌入对象Address就可以用在和地址相关的任何实体类型中。

通常而言,@Field注解用来告诉HibernateSearch那些域是需要被索引和搜索的。但是我们能不能根据使用的场合不同,而进一步进行区分呢?我们举一个例子来说明这个应用场景:

App中,含有其对应的CustomerReview嵌入对象,而这个对象中有两个字段usernamecomments@Field注解标注了。显然,在对App进行全文搜索时,评论信息来自于哪个用户是不需要被搜索到的。所以我们想在App的索引信息中只包含CustomerReview comments域,而不包含username域。

我们可以通过@IndexedEmbedded注解的includePath属性来进行声明:

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

此时CustomerReviewusername信息就不会被添加到App的索引中。这样做满足我们的需求,同时也节省了空间,提高了性能。而在具体的查询中,我们也需要注意不能够再使用customerReviews.username

映射API

前面我们介绍了如何使用Hibernate Search提供的注解来完成从HibernateLucene的映射。当然,完全不使用那些注解,仅仅使用映射API也是能够完成映射的。

使用映射API在映射信息不固定时是非常有效的,比如运行时的映射信息会根据运行环境的不同而发生改变等等。同时,使用映射API也是在你无法对实体类型进行修改时,建立映射的唯一方法。

映射API的核心是SearchMapping类,它保存了Hibernate Search的配置信息,而这些配置信息通常是来源于散步在各个实体类中的注解。该类提供的方法都非常直观,比如entity方法对应的就是 @Entity注解,indexed方法对应的就是@Indexed注解。

如果你需要查阅更多关于映射API的信息和用法,可以参考这里

以下的映射代码能够完成以上使用注解完成映射工作:

public class SearchMappingFactory {
    @Factory
    public SearchMappinggetSearchMapping() {
        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;
    }
}

使用的类名和方法名都不重要,重要的是方法需要被@org.hibernate.search.annotations.Factory注解标注。然而,在hibernate.cfg.xml配置文件中添加一个属性来使用该方法:

...
<property name="hibernate.search.model_mapping">
    a.b.c.SearchMappingFactory
</property>
...

此时,当Hibernate ORM打开一个session时,Hibernate Search就会委托Luecene为相应实体建立索引和其它相关信息了。

基础查询

目前我们只用到了基于关键字的查询,实际上Hibenrate Search DSL还提供了其它的查询方式,下面我们就来一探究竟。

映射API和查询API

对于映射API,我们可以通过使用Hibernate提供的注解来完成映射工作,同时我们也可以使用JPA提供的注解来完成。类似的,对于查询API,我们也可以从HibernateJPA提供的查询API中进行选择。每种方式都有它的优点和缺点,比如当我们使用Hibernate提供的查询API时,意味着可以使用更多的特性,毕竟HibernateSearch就是建立在Hibernate之上的。而当我们选择JPA的查询API时,意味着应用可以更方便的切换ORM的实现,比如我们想将 Hibernate替换成EclipseLink

Hibernate Search DSL

所谓的Hibernate Search DSL,实际上就是用于编写查询代码的一些列API

import org.hibernate.search.query.dsl.QueryBuilder;
 
// ...
 
String searchString = request.getParameter("searchString");
QueryBuilder queryBuilder = fullTextSession.getSearchFactory()
    .buildQueryBuilder().forEntity( App.class ).get();
org.apache.lucene.search.Query luceneQuery = queryBuilder
    .keyword()
    .onFields("name", "description")
    .matching(searchString)
    .createQuery();

它采用链式编程的方式将查询中关键的部分封装成一个个方法进行连续调用。当下,很多API都被设计成这样。比如jQueryAPI,以及Java 8中最新的Stream类型的API等。同时,一些设计模式如建造者模式也大量地使用了这种技术。

关键字查询(Keyword Query)

基于关键字的查询,是最为基本的一种查询方式。目前见到的例子都是基于关键字查询的。为了执行这种查询,第一步是得到一个QueryBuilder对象,并且说明需要查询的目标实体:

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

反映到代码中是这样的:

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

onFields方法可以看做是多个onField方法的组合,为了方便一次性地声明所有查询域。如果onFields中接受的某个域在对应实体的索引中不存在相关信息,那么查询会报错。所以,需要确保传入到onFields方法中的域确实是存在于实体的索引中的。

对于matching方法,通常而言它需要接受的是一个字符串对象,表示查询的关键字。但是实际上借助FieldBridge,传入到该方法的参数可以是任意类型。在高级映射一文中会对FieldBridge进行介绍。

对于传入的关键字字符串,它也许包含了多个关键字(使用空白字符分隔,就像我们使用搜索引擎时)Hibernate Search会默认地将它们分割成一个个的关键字,然后逐个进行搜索。

最终,createQuery方法会结束DSL的定义并返回一个Lucene查询对象。最后,我们可以通过 FullTextSession(Hibernate)或者FullTextEntityManager(JPA)来得到最终的Hibernate Search查询对象(FullTextQuery)

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

模糊查询(Fuzzy Query)

当我们使用搜索引擎时,它都能够很聪明地对一些输入错误进行更正。而在Hibernate Search中,我们也可以通过模糊查询来让查询更加智能。

当使用了模糊查询后,当关键字和目标字串之间的匹配程度低于设置的某个阈值时,HibernateSearch也会认为匹配成功而返回结果。这个阈值的范围在01之间:0代表任何字串都算匹配,而1则代表只有完全符合才算匹配。所以当这个阈值取了0 1之间的某个值时,就代表查询能够支持某种程度的模糊。

当使用Hibernate Search DSL来定义模糊查询时,可能的流程如下:

它一开始使用的也是keyword方法来定义一个基于关键字的查询,毕竟模糊查询也只是关键字查询的一种。它在最后也会使用onField/onFields来指定查询的目标字段。

只不过在keywordonField/onFields方法中间会定义模糊查询的相关参数。

fuzzy方法会使用0.5作为模糊程度的默认值,越接近0就越模糊,越接近1就越精确。因此,这个值是一个折中的值,在多种环境中都能够通用。

如果不想使用该默认值,还可以通过调用withThreshold方法来指定一个阈值:

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

除了withThreshold方法外,还可以使用withPrefixLength方法来指定每个词语中,前多少个字符需要被排除在模糊计算中。

通配符查询(Wildcard Query)

在通配符查询中,问号(?)会被当做一个任意字符。而星号(*)则会被当做零个或者多个字符。

Hibernate Search DSL中使用通配符搜索的流程如下:

需要使用wildcard方法来指定它是一个支持通配符的查询。

精确短语查询(Exact Phrase Query)

前面提到过,Hibernate Search会在执行查询前将关键字使用空白字符进行分割,然后对得到的词语逐个查询。然而,有时候我们需要查询的就是一个完整的短语,不需要Hibernate Search多此一举。在搜索引擎中,我们通过使用双引号来表示这种情况。

Hibernate Search DSL中,可以通过短语查询来完成,一下是流程图:

sentence方法接受的参数必须是一个String类型,这一点和matching有所不同。 withSlop方法接受一个整型变量作为参数,它提供了一种原始的模糊查询方式:短语中额外可以出现的词语数量。比如我们要查询的是“Hello World”,那么在使用withSlop(1)后,“Hello Big World”也会被匹配。

那么在具体的代码中,我们可以首先进行判断,如果搜索字符串被引号包含了,那么就使用短语查询:

if(isQuoted(searchString)) {
    luceneQuery = queryBuilder
        .phrase()
        .onField("name")
        .andField("description")
        .andField("supportedDevices.name")
        .andField("customerReviews.comments")
        .sentence(searchStringWithQuotesRemoved)
        .createQuery();
}

范围查询(Range Query)

范围查询的流程:

顾名思义,范围查询通过给定上限值和下限值来对某些域进行的查询。因此,日期类型和数值类型通常会作为此类查询的目标域。

abovebelow方法用来单独指定下限值和上限值。而fromto方法必须成对使用。它们可以结合excludeLimit来将区间从闭区间转换为开区间:

比如from(5).to(10).excludeLimit()所代表的区间就是:5 <= x < 10

下面是一个查询拥有4星及以上评价的App实体:

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

布尔(组合)查询(Boolean(Combination) Query)

如果一个查询满足不了你的需求,那么你可以使用布尔查询将若干个查询结合起来。下面是它的流程:

使用bool方法来表明这个查询是一个组合查询,会组合多个子查询。它至少需要包含一个must子查询或者一个should查询。mustshould分别表示的是逻辑与(Logical-AND)和逻辑或(Logical-OR)的语义。

一般,不要同时使用mustshould,因为这会让should中的查询毫无意义。只有在需要根据相关度对结果的排序进行调整时,才会将mustshould联合使用。

比如,下述代码用来查询支持设备xPhone并且拥有5星评价的App实体:

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

排序(Sorting)

默认情况下,查询结果应该按照其和查询条件间的相关度进行排序。关于相关度排序,会在后续的文章中介绍。

但是我们也能够不再使用相关度作为排序的依据,转而我们可以使用日期,数值类型甚至字符串的顺序作为排序依据。比如,对App的搜索结果,我们可以使用其名字在字母表中的顺序进行排序。

为了支持对于某个域的排序,我们需要向索引中添加一些必要的信息。在对字符串类型的域进行索引时,默认的分析器会将该域的值进行分词,所以对于某个值 “Hello World”,在索引中会有两个入口对“Hello”“World”进行单独保存。这样做能够让查询更具效率,但是当我们需要对该域进行排序时,分词器是不需要的。

因此,我们可以对该域设置两个@Field注解:

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

一个用来建立标准的索引,一个用来建立用于排序的索引,其中指定了analyze=Analyze.NO,默认情况下分词器是被使用的。

这个域就可以被用来创建LuceneSortField对象,并集合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

执行此查询后,得到的结果会按照App名字,从A-Z进行排序。实际上,SortField还能够接受第三个boolean类型的参数,当传入true时,排序结果会被颠倒即从Z-A

分页(Pagination)

当搜索会返回大量结果时,通常都不可能将它们一次性返回,而是使用分页技术一次只返回并显示一部分数据。

对于Hibernate SearchFullTextQuery对象,可以使用如下代码完成分页:

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

setFirstResult指定的是偏移量,它通常是通过页码(0开始) * 一页中的记录数计算得到。比如以上代码中的10实际上就是 2 * 5,因此它透露出来的信息是:显示第3页的5条数据。

而为了得到查询的结果数量,可以通过getResultSize方法获得:

int resultSize = hibernateQuery.getResultSize();

在使用getResultSize方法时,不涉及到任何的数据库操作,它仅仅通过Lucene索引来得到结果。

高级映射

前面介绍的可搜索的域基本上都是字符串类型,实际上可搜索的类型是非常丰富的。

本文会介绍以下几个方面的内容:

  • Lucene对实体进行索引的过程
  • 借助Solr组件对这个过程的改进
  • 修改域的重要程度,从而让基于相关度的排序更加有意义
  • 动态决定是否对一个实体类型进行索引

桥接器(Bridges)

实体类型中可以使用的类型是无穷无尽的,但是对于Lucene索引而言,任何类型归根到底都会以字符串来表示。所以,在对实体的域进行索引时,这些域最终需要被转换为字符串类型的对象。

Hibernate Search的术语中,这个过程叫做桥接,而实现这个过程的对象就被称为桥接器。在Hibernate Search中,已经存在很多自带的桥接器用来处理大多数常见类型到字符串类型的转换。

一对一(One-to-One)自定义转换

这是最常见的情况,表示的是实体中的一个Java属性和一个Lucene索引域的关联。下面介绍一些常见类型的映射和转换。

日期域的映射

日期类型的值首先会被转换成GMT时间,然后将它以“yyyyMMddHHmmssSSS”的字符串保存到索引中。

当一个日期类型被@Field标注是,这个过程是自动发生的。但是你也可以显式的使用@DateBridge,这样能够对存储到索引中的字符串进行更加精细的控制,比如我们只关注日期中的yyyyMMdd部分:

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

处理null

默认情况下,只要值是null,无论它的类型是什么都不会被保存到索引中。但是,通过@FieldindexNullAs属性也能够对这一行为进行修改:

@Column
@Field(indexNullAs=Field.DEFAULT_NULL_TOKEN)
privateString description;

indexNullAs的默认值是Field.DO_NOT_INDEX_NULL。当使用Field.DEFAULT_NULL_TOKEN时,Hibernate Search会将null值以一个可配置的全局值保存到索引中。

这个可配置的全局值可以在hibernate.cfg.xml或者persistence.xml中:

hibernate.search.default_null_token=xxx

如果没有使用了Field.DO_NOT_INDEX_NULL但没有配置这个全局值,那么Hibernate Search会使用字符串“null”作为默认值。但是需要注意的是,这个是一个全局的值,意味着任何类型的null值都会以该值保存到索引中。如果需要为不同的类型设置不同的值作为null的替代,那么需要使用自定义的桥接器。

自定义的字符串转换

StringBridge

为了自定义地将一个Java属性映射到一个索引域,可以通过实现Hibernate Search提供的StringBridge接口来完成由Java属性到索引域单向的转换。

比如,对于App有一个属性叫做currentDiscountPercentage用来表示当前的折扣信息。为了计算方便,这个属性的类型时 Float,但是在搜索的时候,我们希望这类信息能够通过更加有意义的方式搜索到,比如25%的折扣可以通过25搜索到,而不是0.25。此时,就需要为该属性实现一个桥接器了:

import org.hibernate.search.bridge.StringBridge;
/** Converts values from 0-1 into percentages (e.g. 0.25 -> 25) */
public class PercentageBridge implements StringBridge {
    public StringobjectToString(Objectobject) {
        try {
            float fieldValue = ((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方法会将输入转换成最终存储到索引中的字符串。同时注意到,当代码产生任何异常时,都会返回一个0。这也是处理null值的一种方式,毕竟当传入的objectnull时,这里是会抛出异常并被捕捉到的。

为了让以上的桥接器起作用,还需要在相应实体的域上添加一个注解:

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

这样一来,该桥接器就会在对该域建立索引时被调用了。

TwoWayStringBridge

除了StringBridge外,还可以通过实现TwoWayStringBridge借口来完成自定义的映射。正如它名字所表示的那样,它提供了双向映射的功能,来完成Java域到Index域的双向转化。

正是因为它是双向转换的,所以在实现该接口时还需要实现一个stringToObject的方法来完成从索引域到实体域的转化:

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

仅仅当实体域需要被当做Lucene索引中的ID域时,才需要实现它。也就意味着在该实体中,当需要转换被@Id或者@DocumentId标注的域,可以考虑使用它。

ParameterizedBridge

这是桥接器灵活性的体现。在实现桥接器时,甚至可以将配置参数传入其中。此时的桥接器需要额外实现ParameterizedBridge接口(StringBridge或者TwoWayStringBridge还是需要实现的)

比如我们需要根据具体的需求来指定折扣信息的精度,比如当discount的值是0.2533时,使用之前的桥接器得到的结果只会得到一个取整后的结果25,而显然我们能够显示的更加精确比如25.33。因此,可以通过定义一个参数来表达小数点后应该保留的位数信息:

public class PercentageBridge implements StringBridge, ParameterizedBridge {
    public static final StringDECIMAL_PLACES_PROPERTY = "decimal_places";
    private int decimalPlaces = 2; // default
    public StringobjectToString(Objectobject) {
        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 voidsetParameterValues(Map<String, String>parameters) {
        try {
            this.decimalPlaces = Integer.parseInt(parameters.get(DECIMAL_PLACES_PROPERTY) );
        } catch(Exception e) {}
    }
}

此时,桥接器期待接受一个名为decimal_places的参数。该参数会被保存到变量decimalPlaces中。如果没有参数被传入,那么也会使用默认的2作为decimalPlaces的值。

现在的问题就是,如何传入这个参数。答案是,通过@FieldBridge注解的params属性传入:

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

另外需要注意的是,StringBridgeTwoWayStringBridge的实现都不是线程安全的。所以要避免在其中保存任何状态,如果确实需要保存某种状态的话,可以考虑让桥接器实现ParameterizedBridge来进行参数的传入。

使用FieldBridge完成更复杂的映射

以上的映射都只是一对一的映射,即一个实体域对应一个索引域。其实,映射还可以是一对多或者多对一的关系,即一个实体域对应多个索引域或者多个实体域对应一个索引域。

映射一个实体域到多个索引域

比如当我们对文件名这一域建立索引时,会希望能够通过文件名或者文件扩展名进行搜索。那么就需要建立两个索引域,这时可以考虑使用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 voidset(Stringname, Objectvalue, Documentdocument, 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);
    }
}

set方法的参数解释如下:

  • String name:表示该实体域的名字
  • Object value:表示当前被映射的实体域的值
  • Document document:表示的是Lucene用来表达该实体的索引结构
  • LuceneOptions luceneOptions:为了和Lucene交互,如向索引中添加更多的域

set方法中的参数中的LuceneOptions对象就是为了让代码能够和Lucene交互。而Document对象。我们可以使用luceneOptions.addFieldToDocument(name+".file",file, document);来完成索引域的添加。

最后,通过使用@FieldBridge来使用它:

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

映射多个实体域到一个索引域

为了实现将多个实体域映射到一个索引域,需要在实体类层次上进行一些操作。所以此时桥接器不再是单纯的域桥接器(Field Bridge),而是一个类桥接器(Class Bridge)

比如对于Device实体类型,我们不想为它的manufacturername分别建立索引,而希望将它们作为一个整体,建立一个索引:

public class DeviceClassBridge implements FieldBridge {
    public voidset(Stringname, Objectvalue, Documentdocument, LuceneOptionsluceneOptions) {
        Device device = (Device) value;
        String fullName = device.getManufacturer() + " " + device.getName();
        luceneOptions.addFieldToDocument(name + ".name", fullName, document);
    }
}

此时就可以在该实体类型上使用它了:

@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

之前我们接触到了为了实现双向转换的TwoWayStringBridge。而同样的,FieldBridge接口也有其双向的版本:TwoWayFieldBridge。和TwoWayStringBridge类似,在被@Id或者@DocumentId标注的域上使用 @FieldBridge时必须使用TwoWayFieldBridge

在单向的FieldBridge中,我们只需使用set方法来指明从实体域到索引域是如何映射的。而在双向的TwoWayFieldBridge中,我们还需要实现一个get方法来得到索引中的字符串表示并进行必要的转换:

publicObject 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");
}
 
publicString objectToString(Object object) {
    // "file" is already a String, otherwise it would need conversion
    return object;
}

解析(Analysis)

当一个实体域被Lucene索引时,往往还会经历一个语法分析(Parsing)和转换(Conversion)的步骤,这些步骤被称为解析。在前文中,我们提到过Hibernate Search会默认对字符串类型的实体域进行分词,而这个分词过程就需要用到解析器(Analyzer)。在需要对实体域进行排序的场合,需要禁用这个默认的分词行为。

在解析过程中,还可以借助Apache Solr提供的组件来完成更多的操作。为了弄清楚Solr组件是如何参与到这个过程中并完成更多的操作,需要首先明白Lucene在进行解析时经理的三个步骤:

  • 字符过滤(Character Filtering)
  • 分词(Tokenization)
  • 词条过滤(Token Filtering)

在第一个阶段,会使用零个或者多个字符过滤器(Character Filter)来帮助完成这个过程。它们会在字符这个水平上对数据源进行操作,比如将特定的字符进行替换,删除等等。

在第二个阶段,分词器会根据其定义的规则对数据源进行分词,得到一系列的token。这样做能够让基于关键字的搜索更具效率。

在第三个阶段,会使用零个或者多个词条过滤器来将不需要的token从数据中移除。

经历了以上三个阶段后,数据才会真正地被保存到索引中。下面对这三个阶段进行详细的介绍。

字符过滤(Character Filtering)

当需要创建自定义的解析器时,字符过滤的定义是可选的。目前有三个可选的字符过滤器:

·        MappingCharFilterFactory 这个过滤器会将特定的字符或者字符序列根据定义进行替换,比如将1替换成one,将2替换成two等。被替换的字符和替换字符通过 java.util.Properties资源文件进行声明,这个资源文件需要置于classpath上。比如1=one就表示将1替换成one

·        PatternReplaceCharFilter 会基于正则表达式进行操作。正则表达式通过参数pattern传入,而替换的字符通过replacement参数传入。

·        HTMLStripCharFilterFactory 这个过滤器在处理HTML文本时非常有用,它会移除HTML的标签,同时也会将转义字符替换成其原始的形式,比如将&\gt;替换成为>

分词(Tokenization)

和第一阶段及第三阶段使用的过滤器不一样,分词阶段使用的分词器必须有且只有一个。

常用的分词器举例如下:

·        WhitespaceTokenizerFactory 通过简单地对空白字符进行分割来得到结果,比如“Hello World”的分词结果就是HelloWorld

·        LetterTokenizerFactory 这个分词器在WhitespaceTokenizerFactory上更进一步,对于非字母类型的字符也会进行分割,所以“Pleasedon't go”这句话会被分割成:Please, don, t, go

·        StandardTokenizerFactory 这是默认使用的分词器。它会通过空白字符进行分割,同时会忽略掉多余的字符。多余的字符可以是各种标点符号等。比如“it's 25.5 degrees outside!!!”的分词结果是:it's,25.5, degrees, and outside。对分词没有特殊要求时,使用默认分词即能够达到较好的效果。

词条过滤(Token Filtering)

词条过滤可以说是整个解析功能中最丰富多彩的一个阶段了,Solr提供了很多的可用组件。下面列举一些:

·        StopFilterFactory 这个过滤器会将停用词(Stop Word)全部丢弃,同时也会将一些太常见的词语过滤拆掉,因为一般而言这些词语是不会被当做关键字进行查询的,比如 atheifforandor等词都在此列。更详细的停用词和常见词汇过滤表,可以搜索此Filter的文档。

·        PhoneticFilterFactory 当你使用搜索引擎时,也许已经发现了它能够很智能地自动对一些输入错误进行更正。更正的方法之一就是查询发音相似的词语。它会将发音相似的词语也保存到索引中,因此当输入了错误的单词时,或许仍然能够返回期望的结果。

·        SnowballPorterFilterFactory这个过滤器名中的SnowballPorter指代的是词干提取(Stemming)算法,所谓的词干提取,就是将词干从词汇中抽取出来,比如 developerdevelopment这两个词汇在进行词干提取后,得到的结果都是develop。因此当你输入develop作为搜索关键字时,包含developerdevelopment的内容也会被返回。这个过滤器需要接受一个language作为参数,比如English

定义和选择解析器(Analyzer)

定义解析器实际上就是根据解析的三个步骤,定义或者选择每个步骤需要使用的组件的过程。解析器只是一个统称,用来包含它所使用的各种字符过滤器,分词器和词条过滤器。解析器可以通过静态或者动态的方式进行定义:

静态定义解析器

无论使用哪种方式定义解析器,都会使用@AnalyzerDef。比如,我们这里会为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")
            })
        }
)

可以清晰的发现,在定义中,charFilters用来定义字符过滤器;tokenizer有且只有一个用来定义分词器;filters用来定义词条过滤器。另外,charFiltersfilters的执行顺序是通过定义它们的顺序决定的。这一点需要特别注意,以防出现意料之外的结果。

实际上,一个类型可以定义多个解析器。此时对每个域就可以使用不同的解析器来实现具体需求了。

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

定义了解析器后,使用@Analyzer注解来使用以上定义的解析器:

@Column(length = 1000)
@Field
@Analyzer(definition="appAnalyzer")
privateString description;

@Analyzer注解不仅可以用在单独的域,还可以直接用在类上。此时类中所有被索引的域(即被@Field标注的域)都会使用该解析器。

动态定义解析器

在支持多语言的应用中,语言的切换势必造成需要索引的数据的改变。而根据每种语言的特点,往往需要定义不同的解析器。所以这个定义的过程应该是一个动态的过程。为了实现这一过程,可以使用@AnalyzerDiscriminator注解,它和@Analyzer一样,可以使用在域或者类上。在以下代码中,我们将该注解使用在类之上:

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

具体而言,CustomerReviewDiscriminator需要实现Discriminator接口来完成解析器的动态选择工作,它通过评论的语言来分别使用英语解析器或者法语解析器:

public class CustomerReviewDiscriminator implements Discriminator {
    public StringgetAnalyzerDefinitionName(Objectvalue, Objectentity, Stringfield) {
        if( entity == null || !(entity instanceof CustomerReview) ) {
            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方法的第一个参数就是当前域,第二个参数entitynull。而像上述代码那样当@AnalyzerDiscriminator用在类型上时,传入的第一个参数是 null,第二个参数是当前的实例对象。

getAnalyzerDefinitionName方法返回的是null时,会告诉Hibernate Search使用默认的解析器。

提升搜索结果的相关度

搜索结果的默认排序是根据结果和搜索关键字之间的相关度进行的。这就意味着如果一个结果中有两个域对搜索关键字匹配成功,而另外一个结果只有一个域匹配成功,那么会认为第一个结果的相关度更高。

Hibernate Search允许我们通过提升某些实体或者某些域的重要程度来对相关度的计算造成影响,从而最终得到更加有意义的搜索结果。这个提升的过程可以是静态的或者动态的,即我们可以根据运行时的环境来动态地调节某些实体和域的重要程度。

索引时的静态提升

可以通过使用@Boost注解来设定实体或者域的重要性权重,@Boost的默认值是1.0F。当设置的值大于1.0F时,表示重要性被提升了,当设置的值小于1.0F时,表示重要性被降低了。

比如当进行如下设置时:

@Boost(2.0f)
public class App implements Serializable {
    // ...
 
    @Boost(1.5f)
    private String name;
 
    @Boost(1.2f)
    private String description;
 
    // ...
}

App的重要性相比那些没有被提升的实体如Device,提升了一倍。除此之外,App实体中的namedescription域也被提升了。这些提升会进行合并和叠加,意味着namedescription的重要性会发生如下变化:

name1.0F -> 1.0F * 2.0F *1.5F = 3.0F description1.0F -> 1.0F * 2.0F * 1.2F = 2.4F

索引时的动态提升

对于App的评价,我们希望5星评价会有更高的权重。这就是动态提升的一个典型用例,我们需要通过检查评价对象的星级来决定该评价的权重。此时需要使用@DynamicBoost注解结合实现BoostStrategy接口来完成:

@DynamicBoost(impl=FiveStarBoostStrategy.class)
public class CustomerReview
public class FiveStarBoostStrategy implements BoostStrategy {
    public floatdefineBoost(Objectvalue) {
        if(value == null || !(value instanceof CustomerReview)) {
            return 1;
        }
        CustomerReview customerReview = (CustomerReview) value;
        if(customerReview.getStars() == 5) {
            return 1.5f;
        } else {
            return 1;
        }
    }
}

@DynamicBoost被应用在类型上时,传入到defineBoost方法中的参数是该实体的当前实例。@DynamicBoost被应用在实体域上时,传入到defineBoost方法中的参数是该域的值。

这种模式在Hibernate Search的诸多接口中都有体现,传入的参数值会根据注解是应用到类或者域而发生不同,这一点需要注意。

以上代码的功能很简单,只有当评价是5星评价时才会将该评论的重要性提升一些。

条件索引(Conditional Indexing)

对于某些实例,我们也许并不想将它们设置为可搜索的。比如典型的当实例的active属性被设置为false时,往往意味着该实例不应该被搜索到。这个时候就需要应用条件索引了,即只有当实体符合某种要求时,它才会被索引。

@Indexed注解中有一个名为interceptor的属性,它能够帮助我们完成条件索引。为该属性进行配置后,正常的索引过程会被拦截,从而让索引行为更具可控性。

比如,我们可以在App实体中添加一个active属性来控制App的实例是否需要被索引:

@Column
privateboolean active;
 
public App(String name, String image, String description) {
    this.name = name;
    this.image = image;
    this.description = description;
    this.active = true;
}
 
publicboolean isActive() {
    return active;
}
 
publicvoid setActive(boolean active) {
    this.active = active;
}

在正常情况下,active被设置成true,意味着它是可以被搜索到的。而当它变成false时,即意味着对应的App实例信息应该从索引中移除,通过@Indexed注解的interceptor属性:

@Entity
@Indexed(interceptor = IndexWhenActiveInterceptor.class)
public class App {
    // ...
}

该拦截器IndexWhenActiveInterceptor需要实现EntityIndexingInterceptor接口:

public class IndexWhenActiveInterceptor implements EntityIndexingInterceptor<App> {
    /** Only index newly-created App's when they are active */
    public IndexingOverrideonAdd(Appentity) {
        if(entity.isActive()) {
            return IndexingOverride.APPLY_DEFAULT;
        }
        return IndexingOverride.SKIP;
    }
 
    public IndexingOverrideonDelete(Appentity) {
        return IndexingOverride.APPLY_DEFAULT;
    }
 
    /** Index active App's, and remove inactive ones */
    public IndexingOverrideonUpdate(Appentity) {
        if(entity.isActive()) {
            return IndexingOverride.UPDATE;
        } else {
            return IndexingOverride.REMOVE;
        }
    }   
 
    public IndexingOverrideonCollectionUpdate(Appentity) {
        return onUpdate(entity);
    }
}

EntityIndexingInterceptor接口中定义了四个会被Hibernate Search调用的方法,调用的时机则是根据实体对象的生命周期决定:

·        onAdd当实体对象被创建时发生调用。

·        onDelete当实体对象从数据库中移除时发生调用。

·        onUpdate当已经存在的实体对象被更新时发生调用。

·        onCollectionUpdate当实体对象是通过批量的方式完成更新时发生调用。一般而言,直接调用onUpdate方法就可以了。

以上的每个方法都需要返回IndexingOverride枚举类型,它拥有四个枚举值:

·        IndexingOverride.SKIP用来告诉Hibernate Search不要为该实例更新Lucene索引。

·        IndexingOverride.REMOVE用来告诉Hibernate Search删除该实例的Lucene索引,如果该实例本身就没有被索引,那么不会执行任何操作。

·        IndexingOverride.UPDATE用来告诉Hibernate Search为该实例更新Lucene索引,如果该实例没有被索引过,就为它添加索引。

·        IndexingOverride.APPLY_DEFAULT代表了默认行为。在onAdd中使用它意味着会为该实例添加索引;在onDelete中使用意味着会将该实例的索引信息移除;在onUpdate或者 onCollectionUpdate中使用则是用来更新实例的索引。

了解了EntityIndexingInterceptor接口和IndexingOverride的用法,上述代码的作用就一目了然了。

高级查询

在介绍了更多的高级映射功能之后,是时候回顾一下之前介绍过的查询功能了,看看如何借助这些高级的映射功能来使用一些高级的查询功能。本文会通过以下几个方面进行介绍:

  • 如何在不和数据库进行任何交互的前提下,借助Lucene的力量来动态的筛选结果
  • 如何通过使用基于投影(Projection)的查询来获取需要的属性,从而避免与数据库的交互
  • 如何使用分面搜索(Faceted Search)对搜索结果进行划分
  • 如何使用查询时提升(Boosting)
  • 如何给查询设置时间限制

过滤(Filtering)

虽然是全文搜索,但是我们有时候需要将搜索的结果限定到某个范围内。比如,当我们只需要搜索特定设备上的支持的App,有以下几个思路:

·        将限定范围作为搜索关键字传入到查询对象中。但是稍微想想就会发现问题:这样做只会增大搜索的范围而导致更多的结果被返回,因为搜索关键字变多了。

·        使用布尔查询,向其中添加must子查询。这样做是可行的,只不过这样做会让DSL难以维护,失去其简洁的特点。同时,如果需要过滤逻辑相对比较复杂的话,使用DSL会让代码变的臃肿。

·        由于Hibernate Search中的FullTextQuery是继承自Hibernate ORM Query(或者相应的JPA Query)对象。所以我们可以考虑使用类似ResultTransformer这种对象进行过滤。但是这样做的问题是会让代码和数据库之间的交互变的更多,导致性能的下滑。

实际上,针对这一类问题Hibernate Search提供了一套更优雅和高效的解决方案:过滤器(Filter)

过滤器会将过滤的逻辑封装到其中,然后在运行时通过动态地使用这些过滤器来完成需要的过滤操作。过滤行为是针对Lucene索引的,被过滤的内容绝对不会出现在最终的搜索结果中。因此从某种意义上而言,它也减小了最终需要从数据库中获取的数据量。

创建一个过滤器工厂

过滤器对应着Lucene中的org.apache.lucene.search.Filter类型。因此,对于简单的过滤器直接创建Filter类型的一个子类就够了。但是,如果想在运行时根据条件动态地生成Filter实例,就需要使用过滤器工厂:

public class DeviceFilterFactory {
    private String deviceName;
 
    @Factory
    public FiltergetFilter() {
        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 voidsetDeviceName(StringdeviceName) {
        this.deviceName = deviceName.toLowerCase();
    }
}

上述代码中最关键的就是@Factory注解的使用。它表明了getFilter方法能够返回一个过滤器实例。在getFilter的实现中,必须要使用一些Lucene的原生API,它虽然没有Hibernate Search DSL方便,但是也并不难理解。

最终返回的过滤器的类型时CachingWrapperFilter,使用它是为了将过滤器进行缓存来避免创建不必要的重复Filter,它封装了 QueryWrapperFilter实例,而后者则建立在一个Query对象上。这个Query对象表示的就是进行筛选操作所必要的查询。这里我们想精确的匹配设备名称,因此使用的查询类型时短语查询(PhraseQuery)

让我们回顾一下数据在被Lucene索引时所经历的过程:

  • 解析器会进行字符过滤,分词和词条过滤,然后将每个词条都抽象为Lucene中的一个数据单元(即Term类型,上面的代码中有用到)。
  • 在将数据写入索引前,默认的解析器会将字符串数据转换为小写的形式。

但是在使用Hibernate Search时,这些Lucene细节都不需要开发人员费心。可是,当像上述代码那样使用底层LuceneAPI时,就需要注意这些细节了。因此,在setDeviceName方法中,我们会将传入的deviceName转换为小写的。然后在创建Query型时,会将分词得到的每个词条都先转换为Term类型,再添加到Query中。

添加过滤器键(Filter Key)

正因为在创建过滤器时,我们使用了CachingWrapperFilter完成了一次封装用来缓存该过滤器。所以当需要从缓存中取回某个过滤器时,我们还需要使用一个Key,这个Key就是所谓的过滤器键(Filter Key)。这里我们使用需要过滤的设备名称作为键,配合@Key实现如下:

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

该方法也实现在DeviceFilterFactory类型中。getKey方法返回的DeviceFilterKey类型,是FilterKey的一个子类型。它作为Key,自然而然就需要覆盖equalshashCode方法:

public class DeviceFilterKey extends FilterKey {
    private String deviceName;
 
    @Override
    public booleanequals(ObjectotherKey) {
        if(this.deviceName == null || !(otherKey instanceof DeviceFilterKey)) {
            return false;
        }
        DeviceFilterKey otherDeviceFilterKey = (DeviceFilterKey) otherKey;
        return otherDeviceFilterKey.deviceName != null && this.deviceName.equals(otherDeviceFilterKey.deviceName);
    }
 
    @Override
    public inthashCode() {
        if(this.deviceName == null) {
            return 0;
        }
        return this.deviceName.hashCode();
    }
 
    // GETTER AND SETTER FOR deviceName...
}

实际上,通过使用Apache Commons库中的相关API可以很方便的对这两个方法完成覆盖。

建立过滤器定义

完成了@Factory@Key需要的创建过滤器和获取过滤器的方法,下面需要做就是使用它。通过@FullTextFilterDefs@FullTextFilterDef完成定义:

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

@FullTextFilterDefname属性中定义的值可以在Hibernate Search的查询中引用到,下面会进行介绍。@FullTextFilterDefs的使用也说明了每个类型能够定义多个Filters

在查询中使用过滤器

现在是万事俱备只欠东风。最后是在代码中对定义的过滤器进行调用:

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

通过判断从前台传入的selectedDevice的值来决定是否启用过滤器。前台通常可以使用一个下拉菜单来让用户选择从而缩小搜索范围。

enableFullTextFilter方法中接受的参数就是在Filter定义中的name属性值。然后调用setParameter方法,它转而会调用DeviceFilterFactory中的setDeviceName方法完成所需参数的注入。

投影(Projection)

在目前使用的查询中,最终都会通过和数据库进行交互来得到我们所需要的记录。尽管当数据量较大时,我们可以采用分页(Pagination)的技术来限制每次获取的数据量,不过无论获取的数据量多小,都难免需要和数据库进行交互。然而,我们是否能通过直接读取Lucene索引来获得感兴趣的数据呢?

答案是肯定的,Hibernate Search提供了一种叫做投影的技术来消除或者减少查询对于数据库的依赖。基于投影的查询只会返回实体对象在Lucene索引中已经存在的数据,而不是返回读取数据库后得到的完整实体对象。

通常而言,全文搜索返回的结果通常会以一种摘要的形式呈现给用户。这也意味着,详细的信息往往在这一阶段是不需要的。只有当用户点击了诸如更多信息种按钮或者链接后,具体的信息才需要被读取并展现。在显示摘要信息时,需要的信息在大多数情况下已经存在于Lucene建立的索引中了,而这正是投影操作的用武之地。

让查询建立在投影上

我们可以通过setProjection方法来让一个查询转换成基于投影的查询:

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

传入到setProjection方法中的域名需要是存在于Lucene索引中的域,即它们需要被@Field标注。除此之外,还需要对@Fieldstore属性进行配置,在后面会进行介绍。

将投影结果转换为对象

在执行了基于投影的查询后,返回的对象类型并不是我们需要的App,而是Object[]。因此索引位04的值分别就是idnamedescriptionimage

Object[]也被称为元组(Tuple)类型,可是Java语言并不在其语言层次支持元组这种类型,所以我们往往需要将它转换成相应的实体类型。我们可以借助Hibernate ORM提供的ResultTransformer来完成这个转换,具体而言是AliasToBeanResultTransformer类型:

hibernateQuery.setResultTransformer(new AliasToBeanResultTransformer(App.class));

该类型会将投影操作的对象域和对应实体类之间的域进行比较,实现从元组到实体类型的转换。比如我们在投影操作中指定了description域,那么当 AliasToBeanResultTransformer发现了App类中也存在同名的域时,就会将元组中的description赋值到正在创建的 App实例的description中。

因为投影操作只是针对App实体类型的一部分域,所以最终经过转换得到的App实例也不是一个完整的实例,它只包含了部分的域。但是,这些域已经能够支持概要视图的显示了。

使Lucene域能够被投影

默认情况下,Lucene在建立索引时会认为投影操作不会被使用。因此,索引的建立也会被相应地优化。所以在需要使用投影操作时,还需要进行一些修改。

首先,域数据需要被保存到索引中,从而让投影操作能够直接从索引中获取到数据。为了让索引同时也保存域数据,需要修改@Fieldstore属性:

@Field(store=Store.COMPRESS)
privateString description;

Store枚举类型中有三个选项:

·        Store.NO 这是默认值。它会对域进行解析从而为它建立索引,但是它不会将域值也保存到索引中。因此,使用该选项时是不能支持投影操作的。

·        Store.YES 在建立索引的同时,它会将域值也保存到索引中,用来支持投影操作。但是这样显然会增加索引的空间占用。

·        Store.COMPRESS 前面两个选项的折中方案。它仍然会在索引中保存域值,但是它会通过使用压缩算法来减小索引的空间占用。因此,在使用该选项时,意味着建立索引的过程会消耗更多的计算资源。另外,在处理被@NumericField注解标注的域时,是不能够使用它的。

同时,在使用Store.YES或者Store.COMPRESS时,该域必须要使用一个双向域桥接器(Bi-directional FieldBridge)。但是不要紧张,Hibernate Search已经为JDK中的基本类型提供了一套默认的双向域桥接器。只不过,当需要在你自定义的类型上使用投影操作时,就需要你为它提供一个双向域桥接器了。它必须基于TwoWayStringBridge或者TwoWayFieldBridge

最后,投影只能应用于实体类型中的的基础属性(Basic Property),比如字符串类型等。它不能够获取到关联对象和嵌入对象中的相关属性。

在使用投影操作时,如果确实有必要获取到关联对象或者嵌入对象时,可以考虑首先获得到实体对象的主键信息,然后通过该主键信息去读取其关联对象表中的相应记录,因为在关联对象表中往往会有一个外键用来表示关联信息。

分面搜索(Faceted Search)

首先,不要被分面搜索这个名字给唬住了。它只是Hibernate Search中的一个术语而已,背后的概念其实相当简单,给出一个前端的效果图,大家就明白了:

没错,所谓的分面搜索这么高大上的术语实际上就是我们常说的按照类别进行筛选。所以在后文中,就直接使用分类搜索,我认为这样更直观一点。

注意它和之前介绍的过滤器的区别。过滤器需要了解对应实体的类型,比如在使用deviceName作为过滤器的目标字段时,我们首先要定义有哪些可用的 deviceName。然后将它们显示在前端供用户选择,用户选择某个deviceName之后再进行搜索,得到的结果即是被筛选后的结果。

而分类搜索则不同,它不需要你事先做任何定义。在指定类型目标字段后,得到的搜索结果会按照该字段的值进行分类。就像上图中显示的那样,当我们搜索显示器时,会根据结果自身按照目标字段进行一次分类,然后将各个分类显示出来供用户选择。

比如,当我们需要按照App实体的类型进行分类时,我们通常需要知道可以在分类字段上将结果分为哪些类型以及有多少记录属于该类型,比如AApp属于 BusinessBApp属于Game等等。除了按照类型进行分类外,还可以按照价格这种属性进行分类,比如MApp的价格在20元以上,N App的价格在10元以下等。

按照类型和价格进行分类,背后的原理显然是不同的。前者这种分类方式是一种基于离散值的分类,而后者则是一种基于连续值的分类。下面一一进行介绍。

离散分面(Discrete Facets)

对于基于离散值的分类,Hibernate Search提供的API调用流程如下:

举个实际按照category字段进行分类的例子:

FacetingRequest categoryFacetingRequest = queryBuilder
    .facet()
    .name("categoryFacet")
    .onField("category")
    .discrete()
    .orderedBy(FacetSortOrder.FIELD_VALUE)
    .includeZeroCounts(false)
    .createFacetingRequest();
 
hibernateQuery.getFacetManager().enableFaceting(categoryFacetingRequest);

facet方法首先打开了通向分类搜索的大门。 name方法接受一个String作为参数,表示的是这个FacetingRequest的名字,供后续代码对它进行引用。 onField方法和前面的用法类似,表示的是目标字段。 discrete方法表示这个分类搜索是基于离散值的。 orderedBy中接受一个FacetSortOrder枚举类型作为参数,可以选择的值包括:

  • COUNT_ASC:按照属于某个类型的记录数量作为排序关键字,进行从小到大的排序。
  • COUNT_DESC:和COUNT_ASC类似,只不过是进行从大到小的排序。
  • FIELD_VALUE:按照类型本身的字母顺序进行排序,比如Business类型会出现在Game类型前。

includeZeroCounts方法接受一个布尔值作为参数,如果是true那么表示即使没有和该类型匹配的记录,也会显示该类型,此时会将所有可用类型都列举出来。反之当传入的是false时,没有匹配项的类型是不会被显示的。除了上述调用的方法之外,可以发现在流程图中还有一个maxFacetCount方法。顾名思义,它能够限制返回的类型的数量。

最后,对查询对象调用getFacetManager方法获取分类管理器并通过enableFaceting启用该分类。

另外需要注意的是,仅仅使用以上代码是无法获取到分类信息的。只有在调用了真正的查询方法,比如hibernateQuery.list()之后,才能够得到分类信息:

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

getFacets方法中,传入了之前用于定义分类搜索的那串字符。有了每个分类的信息,我们就可以用它来进行一些统计工作了:

Map<String, Integer> categories = new TreeMap<String, Integer>();
for(Facet categoryFacet : categoryFacets) {
    categories.put(categoryFacet.getValue(),categoryFacet.getCount());
 
    // 设置选择的分类,重新执行查询来得到筛选后的结果
    if(categoryFacet.getValue().equalsIgnoreCase(selectedCategory)) {
        hibernateQuery.getFacetManager().getFacetGroup("categoryFacet").selectFacets(categoryFacet);
        apps = hibernateQuery.list();
    }
}

Facet类型中定义了一个getValuegetCount方法用于获取到该分类的类型信息和具体的匹配数量信息。除了进行基本的统计工作外,以上代码还会在当前处理的分类和用户选择的分类相同时进行一些处理。

具体而言,它会通知HibernateQuery查询对象当前选中的分类是哪一个。然后,通过再次调用查询对象的list方法来获取该分类下的记录。

范围分面(Range Facets)

范围分面作为另一种分类搜索的方式,其流程如下:

可以注意到它是通过将离散分面的API和范围查询的API进行整合来完成定义流程的。

includeZeroCountsmaxFacetCountorderBy等方法的使用方式和在离散分面中的类似。

下面是一个定义范围分的例子:

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

使用range方法表示此分类是一个基于范围的分类。 belowexcludeLimit的联合使用定义了一个小于1元的分类;fromto定义了一个介于1元和5元的分类;aboveexcludeLimit联合定义了一个大于5元的分类。

类似地,最后也需要启用该分类查询。通过enableFaceting方法。在执行了一次查询后,也可以对分类信息进行统计和处理:

Map<String, Integer> priceRanges = new TreeMap<String, Integer>();
for(Facet priceRangeFacet : priceRangeFacets) {
    priceRanges.put(priceRangeFacet.getValue(), priceRangeFacet.getCount());
 
    // 设置选择的分类,重新执行查询来得到筛选后的结果
    if(priceRangeFacet.getValue().equalsIgnoreCase(selectedPriceRange)) {
        hibernateQuery.getFacetManager().getFacetGroup("priceRangeFacet").selectFacets(priceRangeFacet);
        apps = hibernateQuery.list();
    }
}

当然,在同时使用了多个分类后,可以统一执行一次查询来完成多个筛选:

Map<String, Integer> categories = new TreeMap<String, Integer>();
for(Facet categoryFacet : categoryFacets) {
    categories.put(categoryFacet.getValue(),categoryFacet.getCount());
 
    // 设置选择的分类
    if(categoryFacet.getValue().equalsIgnoreCase(selectedCategory)) {
        hibernateQuery.getFacetManager().getFacetGroup("categoryFacet").selectFacets(categoryFacet);
    }
}
 
Map<String, Integer> priceRanges = new TreeMap<String, Integer>();
for(Facet priceRangeFacet : priceRangeFacets) {
    priceRanges.put(priceRangeFacet.getValue(), priceRangeFacet.getCount());
 
    // 设置选择的分类
    if(priceRangeFacet.getValue().equalsIgnoreCase(selectedPriceRange)) {
        hibernateQuery.getFacetManager().getFacetGroup("priceRangeFacet").selectFacets(priceRangeFacet);
    }
}
 
apps = hibernateQuery.list();

最后得到的效果如下所示:

查询时提升(Query-time Boosting)

我们已经介绍了如何在索引时实现静态和动态方式的提升。实际上,在查询时同样可以对某些域进行提升。

使用查询时提升的关键在于onFieldandField方法的使用,在使用它们后能够使用boostedTo方法来指定该域的权重:

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

当使用的是短语查询时,我们为了强调这是个短语查询因此能够更加精确,所以加倍了namedescription字段的权重。

查询的时间限制

在实际的生产环境中,由于查询涉及到的数据量可能会相当大,因此如果不对查询进行时间上的限制的话,对服务器的性能会有较大的影响。

为了处理这种用例,Hibernate Search提供了两种方法来对查询进行时间限制。注意这里的时间限制是指对Lucene索引进行查询的时间限制。如果在规定的时间窗口内完成了索引的查询,然后到了对数据库记录进行获取的阶段的话,这个时间限制就不再有效了,毕竟对数据库的操作是由Hibernate ORM负责的,如果需要限制该阶段的时间,可以查看Hibernate的相关API

·        FullTextQuery类型的limitExecutionTime方法

    hibernateQuery.limitExecutionTimeTo(2, TimeUnit.SECONDS);

使用该方法后,当查询索引的时间超过了规定的值后。当前查询得到的结果会被返回,这个结果只是期待结果的一个子集。我们可以通过调用hasPartialResults来判断结果是否只是一部分。

·        FullTextQuery类型的setTimeout方法

    hibernateQuery.setTimeout(2, TimeUnit.SECONDS);

使用该方法后,如果查询索引没有在规定时间内完成,那么会直接抛出一个QueryTimeoutException异常,而不是返回部分结果。

另外,无论是哪一种方法,指定的超时时间都不可能被精确的执行。通常而言,实际停止操作的时间会比指定的时间稍微长那么一点。

 

 

相关文章推荐

[Hibernate Search] (3) 基础查询

基础查询 目前我们只用到了基于关键字的查询,实际上Hibenrate Search DSL还提供了其它的查询方式,下面我们就来一探究竟。 映射API和查询API 对于映射API,我们可以通过使用...

集成Hibernate Search做全文检索

集成Hibernate Search做全文检索

基于Spring的Hibernate Search全文检索功能示例

数据库:Oracle 9i JDBC驱动:OJDBC14 开发环境:Eclipse-JEE Spring版本:Spring 2.0.6 Hibernate版本:Hibernate Core 3.2.5...

Hibernate Search-----为已有数据建立索引

Hibernate Search-----为已有数据建立索引

[Hibernate Search] (4) 实体类型的高级映射功能

高级映射 前面介绍的可搜索的域基本上都是字符串类型,实际上可搜索的类型是非常丰富的。 本文会介绍以下几个方面的内容: Lucene对实体进行索引的过程借助Solr组件对这个过程的改进...

Hibernate Search 的常用注解

Hibernate Search 的常用注解 1. @Indexed    -> index指定索引名称   2. @Field    -> name 指定当前属性在LuceneDocument中存储...

hibernate search 嵌入和关联实体映射

总的包图: @DocumentId//嵌入和关联实体映射 这个很重要,但主体与组件的关联错误时,往往是这个没有配置 @IndexedEmbedded(dept...

Hibernate常用注解标记

@Entity //声明该类是一个Hibernate持久化类 @Table(name="cl") //指定该类映射的表 @Proxy(lazy = false) //禁止懒加载 public clas...

Hibernate search(全文检索)

搜索引擎 全文搜索引擎 全文搜索引擎是名副其实的搜索引擎,国外代表有Google,国内则有著名的百度搜索。它们从互联网提取各个网站的信息(以网页文字为主),建立起数据库,并能检索与用户查询条件相匹...

分布式Hibernate search

分布式Hibernate Search与Apache Tomcat6,ActiveMQ 和Spring.今天我将跟大家分享我的经验,以master/slave(s)方式配置分布式Hibernate S...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:HibernateSearch
举报原因:
原因补充:

(最多只允许输入30个字)