Hibernate Search是一个库,它通过自动索引实体,将Hibernate ORM与Apache Lucene或Elasticsearch集成,从而实现高级搜索功能:全文搜索、地理空间搜索、聚合等。更多信息,请参阅hibernate.org上的Hibernate Search。

Hibernate库可以与许多框架一起使用,我们努力确保每个人都能享受到Hibernate的便利。因此,在最近发布的Quarkus 1.0的第一个发布候选版本中,Hibernate ORM、Search和Validator已经被包括在内。

特别是Hibernate Search,它通过将全文搜索的强大功能添加到基于ORM的应用程序中,已经在最新版本(6.0.0.Beta2)中包含,该版本增加了与Elasticsearch的首类兼容性。

让我们利用这个Quarkus版本的机会,更深入地了解Hibernate Search 6及其在Quarkus中的应用。

上下文

什么是Quarkus?

Quarkus是一个用于应用程序开发的Java框架。它集成了许多Java库,并提供了工具以简化开发,尤其是在实时编码方面。基于Quarkus的应用程序可以在传统的HotSpot配置上运行,也可以(通过GraalVM)作为本地二进制文件运行。

无论在HotSpot上运行还是作为本地二进制文件,基于Quarkus的应用程序真正区别于其他应用的是它们的小内存占用和令人印象深刻的快速启动时间。这意味着基于Quarkus的应用程序非常适合在容器中运行。

Hibernate Search是一个Java库,它通过添加高级搜索功能(特别是全文搜索)来增强传统的基于ORM的应用程序。它集成到Hibernate ORM中,自动将实体索引到Elasticsearch,并提供一个Java API,以便无缝地根据Elasticsearch索引搜索实体。

目标

在本博客文章中,我们将探讨如何编写一个REST服务,该服务负责管理客户列表及其分配的业务经理。

我们将使用Quarkus框架来快速轻松地编写一个容器就绪的应用程序,使用Hibernate ORM来处理对数据库的持久化,以及使用Hibernate Search来为应用程序添加全文搜索功能。

应用程序将由PostgreSQL支持,用于存储规范化的关系数据,以及由Elasticsearch支持,以便以非规范化、易于搜索的方式索引部分数据。

代码

您可以在GitHub上的hibernate/hibernate-demos存储库中找到生成的REST应用程序代码:https://github.com/hibernate/hibernate-demos/tree/master/hibernate-search/hsearch-quarkus

先决条件

以下部分描述的应用程序是使用以下工具构建的

GraalVM是构建应用程序原生镜像所必需的,但我们将不需要安装它:在构建原生镜像时,我们只需在容器中构建应用程序(这比看起来简单得多!)。

初始CRUD应用程序

在我们向应用程序添加全文搜索之前,我们需要一些数据来工作。本节将解释如何使用Quarkus创建一个基本的CRUD应用程序。

如果您已经熟悉这些,可以直接跳过本节,直接转到添加Hibernate Search

初始化项目

Quarkus提供了一个Maven插件来初始化新项目的布局。只需运行以下命令

$ mvn io.quarkus:quarkus-maven-plugin:1.0.0.CR1:create \
    -DprojectGroupId=org.hibernate.demos \
    -DprojectArtifactId=hsearch-quarkus \
    -DclassName="org.hibernate.demos.quarkus.ClientResource" \
    -Dpath="/client" \
    -Dextensions="hibernate-orm-panache, resteasy-jsonb, jdbc-postgresql"

然后转到创建的目录

$ cd hsearch-quarkus

该目录将包含您开始编码所需的一切

$ tree .
hsearch-quarkus
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── docker
    │   │   ├── Dockerfile.jvm
    │   │   └── Dockerfile.native
    │   ├── java
    │   │   └── org
    │   │       └── hibernate
    │   │           └── demos
    │   │               └── quarkus
    │   │                   └── ClientResource.java
    │   └── resources
    │       ├── application.properties
    │       └── META-INF
    │           └── resources
    │               └── index.html
    └── test
        └── java
            └── org
                └── hibernate
                    └── demos
                        └── quarkus
                            ├── ClientResourceTest.java
                            └── NativeClientResourceIT.java

17 directories, 10 files

测试

Quarkus创建的测试是好的,在一个真实的应用程序中,我们应该随着向我们的应用程序添加更多功能来更新它们。

然而,为了简洁起见,我们在这里不会处理测试。我们只需删除它们

$ rm -rf src/test

环境

在开发环境中可靠地运行PostgreSQL和Elasticsearch的最简单方法可能是使用Docker。

一个docker-compose配置文件可以在这里找到。它包括两个Elasticsearch节点和一个PostgreSQL实例。

我们可以这样启动它

$ docker-compose -f environment-stack.yml -p hsearch-quarkus-env up

并且可以这样停止它,删除所有docker卷

$ docker-compose -f environment-stack.yml -p hsearch-quarkus-env down -v

配置属性

数据库访问的配置将进入Quarkus的主要配置文件:src/main/resources/application.properties

quarkus.ssl.native=false (1)

quarkus.datasource.url=jdbc:postgresql://${POSTGRES_HOST}/${POSTGRES_DB} (2)
quarkus.datasource.driver=org.postgresql.Driver
quarkus.datasource.username=${POSTGRES_USER}
quarkus.datasource.password=${POSTGRES_PASSWORD}
%dev.quarkus.datasource.url=jdbc:postgresql:hsearch_demo (3)
%dev.quarkus.datasource.username=hsearch_demo
%dev.quarkus.datasource.password=hsearch_demo

quarkus.hibernate-orm.database.generation=create (4)
%dev.quarkus.hibernate-orm.database.generation=drop-and-create (5)
%dev.quarkus.hibernate-orm.sql-load-script=test-dataset.sql (6)
1 我们不会使用SSL,所以让我们禁用它,以便容器更紧凑。
2 数据源被硬编码为PostgreSQL,但连接信息是从环境变量中提取的。这允许在云环境中更容易地部署。
3 在我们的开发环境中,我们将始终使用相同的连接信息,硬编码在本文件中。
4 默认情况下,我们将在启动时让Hibernate ORM创建或更新数据库模式。在现实场景中,我们应该使用Flyway
5 在我们的开发环境中,我们将在每次启动时(或热重载时)删除并重新创建数据库模式。
6 在我们的开发环境中,我们将使用简单的测试数据集填充新创建的数据库。您可以在此找到引用的SQL文件:这里

领域模型

我们的领域模型很简单:一个Client实体和一个BusinessManager实体。每个客户最多分配一个业务经理,该经理将处理与该客户的全部业务。

由于我们使用Quarkus,因此我们将利用Panache来避免在编写Hibernate ORM实体时编写一些样板代码。

  • 无需定义ID:它已在PanacheEntity超类中定义。

  • 无需定义直接的getter/setter:公共字段就足够了。Quarkus将处理所有事情,确保它“正常工作”。

package org.hibernate.demos.quarkus.domain;

import javax.persistence.Entity;
import javax.persistence.ManyToOne;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

@Entity
public class Client extends PanacheEntity {

        public String name;

        @ManyToOne
        public BusinessManager assignedManager;

}
package org.hibernate.demos.quarkus.domain;

import java.util.ArrayList;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.OneToMany;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

@Entity
public class BusinessManager extends PanacheEntity {

        @OneToMany(mappedBy = "assignedManager")
        public List<Client> assignedClients = new ArrayList<>();

        public String name;

        public String email;

        public String phone;

}

DTO

REST服务将使用DTO来清晰地定义预期的输入和输出。如果您感兴趣,可以在此找到DTO类的详细信息:这里

请注意,我们利用MapStruct在实体和DTO之间进行双向转换,这需要在POM文件中添加以下内容:

<?xml version="1.0"?>
<project xsi:schemaLocation="..." xmlns="..." xmlns:xsi="...">
  ...
  <properties>
    ...
    <org.mapstruct.version>1.3.1.Final</org.mapstruct.version>
  </properties>
  ...
  <dependencies>
    ...
    <dependency>
      <groupId>org.mapstruct</groupId>
      <artifactId>mapstruct</artifactId>
      <version>${org.mapstruct.version}</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      ...
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>${compiler-plugin.version}</version>
        <configuration>
          <annotationProcessorPaths>
            <path>
              <groupId>org.mapstruct</groupId>
              <artifactId>mapstruct-processor</artifactId>
              <version>${org.mapstruct.version}</version>
            </path>
          </annotationProcessorPaths>
          <compilerArgs>
            <compilerArg>
              -Amapstruct.suppressGeneratorTimestamp=true
            </compilerArg>
            <compilerArg>
              -Amapstruct.suppressGeneratorVersionInfoComment=true
            </compilerArg>
          </compilerArgs>
        </configuration>
      </plugin>
    </plugins>
  </build>
  ...
</project>

CRUD

现在我们已准备好实现REST服务。让我们更新由Quarkus生成的ClientResource类。

package org.hibernate.demos.quarkus;

import javax.inject.Inject;
import javax.transaction.Transactional;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import org.hibernate.demos.quarkus.domain.Client;
import org.hibernate.demos.quarkus.domain.BusinessManager;
import org.hibernate.demos.quarkus.dto.BusinessManagerCreateUpdateDto;
import org.hibernate.demos.quarkus.dto.ClientCreateUpdateDto;
import org.hibernate.demos.quarkus.dto.ClientMapper;
import org.hibernate.demos.quarkus.dto.ClientRetrieveDto;
import org.hibernate.demos.quarkus.dto.BusinessManagerRetrieveDto;

@Path("/")
@Transactional
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ClientResource {

        @Inject
        ClientMapper mapper;

        @PUT
        @Path("/client")
        public ClientRetrieveDto createClient(ClientCreateUpdateDto dto) {
                Client client = new Client();
                mapper.fromDto( client, dto );
                client.persist();
                return mapper.toDto( client );
        }

        @GET
        @Path("/client/{id}")
        public ClientRetrieveDto retrieveClient(@PathParam("id") Long id) {
                Client client = findClient( id );
                return mapper.toDto( client );
        }

        @POST
        @Path("/client/{id}")
        public void updateClient(@PathParam("id") Long id, ClientCreateUpdateDto dto) {
                Client client = findClient( id );
                mapper.fromDto( client, dto );
        }

        @DELETE
        @Path("/client/{id}")
        public void deleteClient(@PathParam("id") Long id) {
                findClient( id ).delete();
        }

        @PUT
        @Path("/manager")
        public BusinessManagerRetrieveDto createBusinessManager(BusinessManagerCreateUpdateDto dto) {
                BusinessManager businessManager = new BusinessManager();
                mapper.fromDto( businessManager, dto );
                businessManager.persist();
                return mapper.toDto( businessManager );
        }

        @POST
        @Path("/manager/{id}")
        public void updateBusinessManager(@PathParam("id") Long id, BusinessManagerCreateUpdateDto dto) {
                BusinessManager businessManager = findBusinessManager( id );
                mapper.fromDto( businessManager, dto );
        }

        @DELETE
        @Path("/manager/{id}")
        public void deleteBusinessManager(@PathParam("id") Long id) {
                findBusinessManager( id ).delete();
        }

        @POST
        @Path("/client/{clientId}/manager/{managerId}")
        public void assignBusinessManager(@PathParam("clientId") Long clientId, @PathParam("managerId") Long managerId) {
                unAssignBusinessManager( clientId );
                Client client = findClient( clientId );
                BusinessManager manager = findBusinessManager( managerId );
                manager.assignedClients.add( client );
                client.assignedManager = manager;
        }

        @DELETE
        @Path("/client/{clientId}/manager")
        public void unAssignBusinessManager(@PathParam("clientId") Long clientId) {
                Client client = findClient( clientId );
                BusinessManager previousManager = client.assignedManager;
                if ( previousManager != null ) {
                        previousManager.assignedClients.remove( client );
                        client.assignedManager = null;
                }
        }

        private Client findClient(Long id) {
                Client found = Client.findById( id );
                if ( found == null ) {
                        throw new NotFoundException();
                }
                return found;
        }

        private BusinessManager findBusinessManager(Long id) {
                BusinessManager found = BusinessManager.findById( id );
                if ( found == null ) {
                        throw new NotFoundException();
                }
                return found;
        }
}

您可能在上面的实现中注意到一些不寻常的方法

  • entity.persist()entity.delete()方法用于在数据库中创建和删除实体。

  • Client.findById( id )BusinessManager.findById( id )用于从数据库中检索实体。

这些都是Panache特有的惯用语。您可以在这里找到更多信息。

运行应用程序

现在我们可以启动应用程序。

如果尚未完成,让我们启动PostgreSQL

$ docker-compose -f environment-stack.yml -p hsearch-quarkus-env up

然后让我们以开发模式编译并运行应用程序

$ ./mvnw clean compile quarkus:dev
[INFO] Scanning for projects...
[INFO]
[INFO] ----------------< org.hibernate.demos:hsearch-quarkus >-----------------
[INFO] Building hsearch-quarkus 1.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ hsearch-quarkus ---
[INFO] Deleting /home/yrodiere/workspaces/contributor-support/hibernate-demos/hibernate-search/hsearch-quarkus/target
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ hsearch-quarkus ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Copying 3 resources
[INFO]
[INFO] --- maven-compiler-plugin:3.8.1:compile (default-compile) @ hsearch-quarkus ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 9 source files to /home/yrodiere/workspaces/contributor-support/hibernate-demos/hibernate-search/hsearch-quarkus/target/classes
[INFO]
[INFO] --- quarkus-maven-plugin:1.0.0.CR1:dev (default-cli) @ hsearch-quarkus ---
Listening for transport dt_socket at address: 5005
2019-11-06 16:01:06,961 INFO  [io.qua.dep.QuarkusAugmentor] (main) Beginning quarkus augmentation
2019-11-06 16:01:08,703 INFO  [io.qua.dep.QuarkusAugmentor] (main) Quarkus augmentation completed in 1742ms
2019-11-06 16:01:11,270 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) SQL Warning Code: 0, SQLState: 00000
2019-11-06 16:01:11,270 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) relation "client" does not exist, skipping
2019-11-06 16:01:11,271 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) SQL Warning Code: 0, SQLState: 00000
2019-11-06 16:01:11,271 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) table "businessmanager" does not exist, skipping
2019-11-06 16:01:11,272 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) SQL Warning Code: 0, SQLState: 00000
2019-11-06 16:01:11,272 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) table "client" does not exist, skipping
2019-11-06 16:01:11,272 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) SQL Warning Code: 0, SQLState: 00000
2019-11-06 16:01:11,273 WARN  [org.hib.eng.jdb.spi.SqlExceptionHelper] (main) sequence "hibernate_sequence" does not exist, skipping
2019-11-06 16:01:11,273 WARN  [io.agr.pool] (main) Datasource '<default>': JDBC resources leaked: 0 ResultSet(s) and 1 Statement(s)
2019-11-06 16:01:11,346 WARN  [io.agr.pool] (main) Datasource '<default>': JDBC resources leaked: 0 ResultSet(s) and 1 Statement(s)
2019-11-06 16:01:11,809 INFO  [io.quarkus] (main) Quarkus 1.0.0.CR1 started in 5.259s. Listening on: http://0.0.0.0:8080
2019-11-06 16:01:11,812 INFO  [io.quarkus] (main) Profile dev activated. Live Coding activated.
2019-11-06 16:01:11,813 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, hibernate-search-elasticsearch, jdbc-postgresql, narayana-jta, resteasy, resteasy-jsonb]

我们可以调用REST服务并检查数据是否已存在

$ curl -X GET http://localhost:8080/client/2

{
    "assignedManager": {
        "email": "dschrute@dundermifflin.net",
        "id": 1,
        "name": "Dwight Schrute",
        "phone": "+1-202-555-0151"
    },
    "id": 2,
    "name": "Aperture Science Laboratories"
}

依赖项

当使用Quarkus生成项目时,我们添加了几个扩展,但不是Hibernate Search扩展。现在让我们添加它。

$ mvn io.quarkus:quarkus-maven-plugin:1.0.0.CR1:add-extension \
    -Dextensions="hibernate-search-elasticsearch"

它将自动将必要的依赖项添加到POM中

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-hibernate-search-elasticsearch</artifactId>
</dependency>

配置属性

由于我们将连接到Elasticsearch集群,因此需要在application.properties中添加一些配置属性。

quarkus.hibernate-search.elasticsearch.version=7.4 (1)
quarkus.hibernate-search.elasticsearch.hosts=${ES_HOSTS} (2)
%dev.quarkus.hibernate-search.elasticsearch.hosts=http://localhost:9200 (3)

quarkus.hibernate-search.elasticsearch.index-defaults.lifecycle.strategy=create (4)
%dev.quarkus.hibernate-search.elasticsearch.index-defaults.lifecycle.strategy=drop-and-create (5)
%dev.quarkus.hibernate-search.elasticsearch.index-defaults.lifecycle.required-status=yellow (6)
1 Hibernate Search需要知道它将要连接到的Elasticsearch的版本,因为Elasticsearch的不同版本有不同的功能。
2 连接信息从环境变量中提取。这允许在云环境中更容易地进行部署。
3 在我们的开发环境中,我们将始终使用相同的连接信息,硬编码在本文件中。
4 默认情况下,如果不存在,Hibernate Search将在启动时创建Elasticsearch模式。
5 在我们的开发环境中,我们将在每次启动(或热重载)时删除并重新创建索引。
6 在我们的开发环境中,即使索引处于黄色状态(未复制),我们也允许应用程序启动。

映射

Hibernate Search现在知道要将索引数据发送到何处,但不知道要发送什么。

定义哪些实体部分需要被索引到Elasticsearch中被称为映射。在Hibernate Search中映射实体最简单的方法是使用注解。

@Entity
@Indexed (1)
public class Client extends PanacheEntity {

        @FullTextField(analyzer = "standard") (2)
        public String name;

        @ManyToOne
        public BusinessManager assignedManager;

}
1 我们希望将每个实体映射到索引的都需要用@Indexed注解。默认情况下,索引名称将是实体名称(在这种情况下是client),但可以使用@Indexed(index = "myindexname")来覆盖。
2 默认情况下,发送到每个实体的索引文档为空,这并不太有用。通过定义字段来添加新内容。在这里,我们定义了一个字段,其内容将从name属性中提取。它是一个全文字段,即索引时会将其拆分为单词。其他类型的字段也存在,可以通过不同的注解来实现。目前我们使用的是“标准”分析器;我们将在下面更深入地讨论这个问题。

实时编码

我们现在可以启动应用程序并使用Hibernate Search。

多亏了Quarkus的实时编码功能,如果我们在执行更改时已经使用quarkus:dev启动了应用程序,我们只需要调用我们的REST服务来触发重新加载

$ curl -X GET 'http://localhost:8080/client/2'

然后,当应用程序重新启动时,将出现以下日志

2019-11-06 16:03:37,804 INFO  [io.qua.dev] (vert.x-worker-thread-2) Changed source files detected, recompiling [/home/yrodiere/workspaces/contributor-support/hibernate-demos/hibernate-search/hsearch-quarkus/src/main/java/org/hibernate/demos/quarkus/domain/Client.java]
2019-11-06 16:03:38,179 INFO  [io.qua.dev] (vert.x-worker-thread-2) File change detected: /home/yrodiere/workspaces/contributor-support/hibernate-demos/hibernate-search/hsearch-quarkus/src/main/resources/application.properties
2019-11-06 16:03:38,203 INFO  [io.quarkus] (vert.x-worker-thread-2) Quarkus stopped in 0.025s
2019-11-06 16:03:38,206 INFO  [io.qua.dep.QuarkusAugmentor] (vert.x-worker-thread-2) Beginning quarkus augmentation
2019-11-06 16:03:38,433 INFO  [io.qua.dep.QuarkusAugmentor] (vert.x-worker-thread-2) Quarkus augmentation completed in 227ms
2019-11-06 16:03:38,806 WARN  [io.agr.pool] (vert.x-worker-thread-2) Datasource '<default>': JDBC resources leaked: 0 ResultSet(s) and 1 Statement(s)
2019-11-06 16:03:38,857 WARN  [io.agr.pool] (vert.x-worker-thread-2) Datasource '<default>': JDBC resources leaked: 0 ResultSet(s) and 1 Statement(s)
2019-11-06 16:03:40,260 INFO  [io.quarkus] (vert.x-worker-thread-2) Quarkus 1.0.0.CR1 started in 2.056s. Listening on: http://0.0.0.0:8080
2019-11-06 16:03:40,260 INFO  [io.quarkus] (vert.x-worker-thread-2) Profile dev activated. Live Coding activated.
2019-11-06 16:03:40,260 INFO  [io.quarkus] (vert.x-worker-thread-2) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, hibernate-search-elasticsearch, jdbc-postgresql, narayana-jta, resteasy, resteasy-jsonb]
2019-11-06 16:03:40,260 INFO  [io.qua.dev] (vert.x-worker-thread-2) Hot replace total time: 2.457s

索引创建

根据我们的配置,Hibernate Search将在启动时自动创建Elasticsearch索引,无论是正常启动还是热重载。

Hibernate Search首次启动之前,Elasticsearch集群中没有任何内容

$ curl -X GET 'http://localhost:9200/_mappings?pretty'
{ }

Hibernate Search启动后,我们可以看到一个新创建的client索引,其映射与我们的Hibernate Search映射一致

$ curl -X GET 'http://localhost:9200/_mappings?pretty'
{
  "client" : {
    "mappings" : {
      "dynamic" : "strict",
      "properties" : {
        "name" : {
          "type" : "text",
          "analyzer" : "standard"
        }
      }
    }
  }
}

初始索引

索引已经存在,但它仍然是空的:我们找不到我们最喜欢的客户,“Aperture Science Laboratories”。

$ curl -X GET 'http://localhost:9200/_search?pretty&q=aperture'
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

正如我们将看到的,Hibernate Search通常在通过Hibernate ORM持久化数据时自动索引数据。然而,索引现有数据库中已有的数据是不同的:由于可能有很多数据需要索引,并且操作非常消耗资源,Hibernate Search将仅在明确请求时执行索引操作。

让我们将我们的服务更改为添加一个“reindex”方法

// ...
@Transactional
// ...
public class ClientResource {
        // ...

        @Inject
        EntityManagerFactory entityManagerFactory; (1)

        // ...

        @POST
        @Path("/client/reindex")
        @Transactional(TxType.NEVER) (2)
        public void reindex() throws InterruptedException {
                Search.mapping( entityManagerFactory ) (3)
                                .scope( Client.class ) (4)
                                .massIndexer() (5)
                                .startAndWait(); (6)
        }

        // ...
}
1 我们需要EntityManagerFactory来访问Hibernate Search API。
2 虽然这个类中的方法默认是事务性的,但大量索引可能需要很长时间,并且会创建自己的短暂ORM会话和事务。因此,我们为这个方法禁用了自动事务包装。
3 获取Hibernate Search映射,这是不与特定ORM会话相关的索引操作的入口点。
4 针对Client实体类型。
5 创建一个“mass indexer”,负责重新索引Client实体。
6 开始重新索引,并阻塞线程直到完成。

然后让我们触发重新索引

$ curl -X POST http://localhost:8080/client/reindex

我们将在应用程序日志中看到几行信息

2019-11-06 16:05:01,007 INFO  [org.hib.sea.map.orm.mas.mon.imp.SimpleIndexingProgressMonitor] (Hibernate Search: Mass indexing - Client - ID loading - 1) HSEARCH000027: Going to reindex 5 entities
2019-11-06 16:05:01,138 INFO  [org.hib.sea.map.orm.mas.mon.imp.SimpleIndexingProgressMonitor] (vert.x-worker-thread-5) HSEARCH000028: Reindexed 5 entities

现在我们可以看到实体已经索引到Elasticsearch中

$ curl -X GET 'http://localhost:9200/_search?pretty&q=aperture'
{
  "took" : 38,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.2300112,
    "hits" : [
      {
        "_index" : "client",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.2300112,
        "_source" : {
          "name" : "Aperture Science Laboratories"
        }
      }
    ]
  }
}

为了获得更好的开发体验,如果测试数据集很小,可以在启动时自动触发重新索引,只需将此方法添加到ClientResource中即可

// ...
@Transactional
// ...
public class ClientResource {
        // ...

        @Transactional(TxType.NEVER)
        void reindexOnStart(@Observes StartupEvent event) throws InterruptedException {
                if ( "dev".equals( ProfileManager.getActiveProfile() ) ) {
                        reindex();
                }
        }

        // ...
}

自动索引

虽然大量索引在某些情况下很方便,但更方便的是根本不必关心索引。Hibernate Search提供了所谓的自动索引:每次通过Hibernate ORM实体管理器/会话创建、更新或删除实体时,Hibernate Search都会检测这些更改,并适当地重新索引相关实体。

自动索引默认启用,完全透明,无需配置。我们可以简单地使用REST服务的现有方法。

让我们考虑客户“Wayne Enterprises”,它在我们的数据库中缺失

$ curl -X GET 'http://localhost:9200/_search?pretty&q=wayne'
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

如果我们通过现有的API创建这个新客户

$ curl -X PUT http://localhost:8080/client/ -H "Content-Type: application/json" -d '{"name":"Wayne Enterprises"}'

{
    "id": 9,
    "name": "Wayne Enterprises"
}

... 那么将自动将一个新的文档添加到索引中

$ curl -X GET 'http://localhost:9200/_search?pretty&q=wayne'
{
  "took" : 384,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.5404451,
    "hits" : [
      {
        "_index" : "client",
        "_type" : "_doc",
        "_id" : "9",
        "_score" : 1.5404451,
        "_source" : {
          "name" : "Wayne Enterprises"
        }
      }
    ]
  }
}

由于Elasticsearch的近实时特性,索引更新可能存在一小段时间延迟(小于一秒)。有关更多信息,请参阅本指南的该部分

在自动索引方面有几个需要注意的事项。最值得注意的是

  1. 当更改实体之间的关联时,为了使Hibernate Search能够正确处理更新,您需要正确更新关联的双方

  2. Hibernate Search不 aware of changes to entities through JPQL or SQL INSERT/UPDATE/DELETE queries: only changes performed on entity objects are detected. When using these queries, you should take care of reindexing the relevant entities manually afterwards.

有关更多信息,请参阅本指南的该部分

搜索

如上所述,Hibernate Search将数据索引到Elasticsearch中,因此可以直接使用Elasticsearch API或通过Java包装器来搜索这些索引。

另一种选择是使用Hibernate Search的自身搜索API,它不需要额外的依赖。其主要优势是它会为您处理大部分转换工作:您使用Java API,传递Java对象作为参数(StringIntegerLocalDate等),并得到Java对象作为结果,而无需操作JSON。

一个特别有趣的功能是能够在搜索时返回管理的Hibernate ORM实体。命中将不仅由索引名称、文档标识和文档源表示,就像直接调用Elasticsearch API那样(尽管Hibernate Search也可以这样做):Hibernate Search将自动将这些转换为实体引用并从数据库中加载相应的实体,以便REST服务可以返回Elasticsearch中没有索引的额外数据。

以下是我们将为REST API添加的简单搜索方法的示例。它利用实体加载来显示分配的业务经理,并在响应中显示其姓名、电话和电子邮件,尽管这些信息未推送到Elasticsearch。

// ...
public class ClientResource {
        // ...

        @GET
        @Path("/client/search")
        public List<ClientRetrieveDto> search(@QueryParam("terms") String terms) {
                List<Client> result = Search.session( Panache.getEntityManager() ) (1)
                                .search( Client.class ) (2)
                                .predicate( f -> f.simpleQueryString() (3)
                                                .field( "name" ) (4)
                                                .matching( terms ) (5)
                                                .defaultOperator( BooleanOperator.AND ) (6)
                                )
                                .fetchHits( 20 ); (7)

                return result.stream().map( mapper::toDto ).collect( Collectors.toList() ); (8)
        }

        // ...
}
1 获取搜索会话,这是需要Hibernate ORM会话的索引操作的起点。
2 启动针对Client实体类型的搜索。
3 定义所有搜索命中必须匹配的谓词。这里将是一个“简单查询字符串”,即本质上是一系列术语,但还有许多其他谓词可用。
4 要求单词出现在name字段中。
5 传递要匹配的术语。
6 仅在找到所有术语时才匹配,而不是默认情况下的至少找到一个术语。
7 获取搜索命中。结果是Client的一个列表,这是一个受管理的实体。
8 将受管理的实体转换为DTO。

以下是调用此API的结果:所有数据都是从数据库中加载的。

$ curl -X GET 'http://localhost:8080/client/search/?terms=aperture%20science'

[
    {
        "assignedManager": {
            "email": "dschrute@dundermifflin.net",
            "id": 1,
            "name": "Dwight Schrute",
            "phone": "+1-202-555-0151"
        },
        "id": 2,
        "name": "Aperture Science Laboratories"
    }
]

虽然上述搜索查询运行良好,但我们只需运行带有ILIKE谓词的SQL查询即可实现类似的结果。性能可能不会太出色,但它会工作。

为了了解像Elasticsearch这样的专用全文搜索引擎的优点,让我们查找名称包含单词“laboratory”的客户。

curl -X GET 'http://localhost:8080/client/search/?terms=laboratory'

[
]

我们没有找到任何匹配项。这很烦人,因为我们的一位客户名叫“Aperture Science Laboratories”。这不是一个精确匹配,但仍然,我们的应用程序的用户会期望在输入“laboratory”时看到这位客户(单数)。

全文搜索使我们能够处理这种“非精确”匹配,这得益于所谓的分析。简单来说,分析是在索引(转换索引文本)和搜索(转换搜索查询的术语)过程中转换文本的过程。它用于从文本中提取标记(单词),但也用于规范化这些单词。例如,配置正确的分析器会将“Laboratories”转换为“laboratory”,这样当我们搜索单词“laboratory”时,名称“Aperture Science Laboratories”也会匹配。

为了利用分析,我们需要配置分析器。Hibernate Search提供API以轻松配置分析器,并在创建索引时自动将分析器定义推送到Elasticsearch。

我们只需要实现一个分析器配置器

package org.hibernate.demos.quarkus.search;

import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurationContext;
import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurer;

public class ClientElasticsearchAnalysisConfigurer implements ElasticsearchAnalysisConfigurer {
        @Override
        public void configure(ElasticsearchAnalysisConfigurationContext context) {
                context.analyzer( "english" ).custom() (1)
                                .tokenizer( "standard" ) (2)
                                .tokenFilters( "lowercase", "stemmer_english", "asciifolding" ); (3)
                context.tokenFilter( "stemmer_english" ) (4)
                                .type( "stemmer" )
                                .param( "language", "english" );
        }
}
1 声明一个名为english的自定义分析器。
2 将分词器设置为standard,即要求分析器通过在空格、制表符、标点符号等处拆分文本来生成单词。
3 应用三个标记过滤器以转换提取的单词:lowercase将单词转换为小写,stemmer_english是一个自定义过滤器(见下文),asciifolding将重音字符替换为其ASCII对应字符(《déjà-vu》⇒《deja-vu》)。
4 声明一个名为stemmer_english的自定义标记过滤器。此标记过滤器是一个词干提取器,意味着它将规范化单词的结尾(《laboratories》⇒《laboratory》),并且我们配置它以处理英语语言。

然后,我们需要通过在application.properties中设置配置属性来告诉Hibernate Search使用我们的配置器

quarkus.hibernate-search.elasticsearch.analysis.configurer=org.hibernate.demos.quarkus.search.ClientElasticsearchAnalysisConfigurer

最后,我们需要在我们的全文字段上设置正确的主分析器

@Entity
@Indexed
public class Client extends PanacheEntity {

        @FullTextField(analyzer = "english") (1)
        public String name;

        @ManyToOne
        public BusinessManager assignedManager;

}
1 将分析器从standard更改为english

在这些更改之后,我们需要重新启动应用程序并重新索引数据。Quarkus会自动完成此操作,因此我们可以立即测试更改的结果

$ curl -X GET 'http://localhost:8080/client/search/?terms=laboratory'

[
    {
        "assignedManager": {
            "email": "dschrute@dundermifflin.net",
            "id": 1,
            "name": "Dwight Schrute",
            "phone": "+1-202-555-0151"
        },
        "id": 2,
        "name": "Aperture Science Laboratories"
    }
]

成功了:现在文本“laboratory”可以匹配名称“Aperture Science Laboratories”。

分析器是非常强大的工具,具有大量的配置选项。要了解更多有关Elasticsearch中的分析器信息,请参阅本说明文档的该部分,其中包含一些指向可用分析器、分词器和标记过滤器的链接。

索引实体图

自动索引实体很方便,但我们可以说,在不使用Hibernate Search的情况下完成此操作也是合理的简单,只需将我们的实体转换为JSON并将其手动发送到Elasticsearch,每次创建/更新/删除客户端时即可。这将涉及额外的样板代码,但这可能是一个选择。

然而,大多数情况下,我们不会只索引一个实体的数据,而是从实体图中索引数据。例如,让我们假设我们想要将业务经理的姓名作为客户端的一部分进行索引,这样我们就可以通过搜索“lapin”轻松地获取由业务经理Phyllis Lapin管理的所有客户端的列表。

事情开始变得复杂起来

  1. 当业务经理的姓名发生变化时,我们需要加载和重新索引分配的客户端。

  2. 当业务经理的其他属性发生变化(例如电话号码)时,我们不需要重新索引分配的客户端,因为这些其他属性没有索引。

这两个要求会使手动重新索引实体变得难以高效实现:代码需要了解在索引客户端时业务经理的哪些属性被使用,需要跟踪业务经理的哪些属性实际上已更改,然后根据这些更改决定是否加载客户端进行重新索引。

将类似的几个关联添加到Client实体或(更糟)添加几个嵌套级别,简单的样板代码很快就会变成时间陷阱。

幸运的是,Hibernate Search 可以透明地处理所有这些。为了将业务经理的姓名作为客户端的一部分进行索引,只需两步即可。

首先,我们将在业务经理中声明一个字段

@Entity
public class BusinessManager extends PanacheEntity {

        @OneToMany(mappedBy = "assignedManager")
        public List<Client> assignedClients = new ArrayList<>();

        @FullTextField(analyzer = "english") (1)
        public String name;

        public String email;

        public String phone;

}
1 定义一个全文字段,其内容将从 name 属性中提取。
@Entity
@Indexed
public class Client extends PanacheEntity {

        @FullTextField(analyzer = "english")
        public String name;

        @ManyToOne
        @IndexedEmbedded (1)
        public BusinessManager assignedManager;

}
1 将分配的经理定义为客户端中的 "indexed-embedded",这意味着在索引时,业务经理中定义的所有索引字段都将嵌入到客户端中。简单来说,在为客户端生成的索引文档中会出现一个新字段:assignedManager.name

映射就到这里:Hibernate Search 将知道每当业务经理的姓名发生变化时,它必须重新索引分配的客户端。

为了利用这个新的 assignedManager.name 字段,让我们更改我们的搜索方法

// ...
public class ClientResource {
        // ...

        @GET
        @Path("/client/search")
        public List<ClientRetrieveDto> search(@QueryParam("terms") String terms) {
                List<Client> result = Search.session( Panache.getEntityManager() )
                                .search( Client.class )
                                .predicate( f -> f.simpleQueryString()
                                                .fields( "name", "assignedManager.name" ) (1)
                                                .matching( terms )
                                                .defaultOperator( BooleanOperator.AND )
                                )
                                .fetchHits( 20 );

                return result.stream().map( mapper::toDto ).collect( Collectors.toList() );
        }

        // ...
}
1 不仅要搜索 name 字段,还要搜索 assignedManager.name 字段。

我们现在可以测试更改了。由于映射更改,需要重新索引,但 Quarkus 的热重载应该会处理它,因此我们可以立即向我们的服务发送请求

$ curl -X GET 'http://localhost:8080/client/search/?terms=lapin'

[
    {
        "assignedManager": {
            "email": "plapin@dundermifflin.net",
            "id": 6,
            "name": "Phyllis Lapin",
            "phone": "+1-202-555-0153"
        },
        "id": 7,
        "name": "Stark Industries"
    },
    {
        "assignedManager": {
            "email": "plapin@dundermifflin.net",
            "id": 6,
            "name": "Phyllis Lapin",
            "phone": "+1-202-555-0153"
        },
        "id": 8,
        "name": "Parker Industries"
    }
]

在菲利斯·拉平与鲍勃·范斯结婚后,我们现在可以更新她的姓名和电子邮件

$ curl -X POST http://localhost:8080/manager/6 -H "Content-Type: application/json" -d '{"name": "Phyllis Vance", "email": "pvance@dundermifflin.net"}'

由于 Hibernate Search 更新了索引,"lapin" 将不再匹配

$ curl -X GET 'http://localhost:8080/client/search/?terms=lapin'

[
]

...但 "vance" 将匹配

$ curl -X GET 'http://localhost:8080/client/search/?terms=vance'

[
    {
        "assignedManager": {
            "email": "pvance@dundermifflin.net",
            "id": 6,
            "name": "Phyllis Vance"
        },
        "id": 7,
        "name": "Stark Industries"
    },
    {
        "assignedManager": {
            "email": "pvance@dundermifflin.net",
            "id": 6,
            "name": "Phyllis Vance"
        },
        "id": 8,
        "name": "Parker Industries"
    }
]

在容器中运行

创建项目时,Quarkus 添加了 Dockerfile,以便在 JVM 模式或原生二进制模式下将应用程序容器化。

然而,为了省去下载和安装 GraalVM,我们将使用一个 多阶段 Docker 构建,它将在容器中构建我们的应用程序,然后为我们的应用程序生成容器。

让我们在 src/main/docker/Dockerfile.multistage 中添加一个 Dockerfile

## Stage 1 : build with maven builder image with native capabilities
FROM quay.io/quarkus/centos-quarkus-maven:19.2.1 AS build
COPY src /usr/src/app/src
COPY pom.xml /usr/src/app
USER root
RUN chown -R quarkus /usr/src/app
USER quarkus
RUN mvn -f /usr/src/app/pom.xml -Pnative clean package

## Stage 2 : create the docker final image
FROM registry.access.redhat.com/ubi8/ubi-minimal
WORKDIR /work/
COPY --from=build /usr/src/app/target/*-runner /work/application
RUN chmod 775 /work
EXPOSE 8080
CMD ["./application", "-Dquarkus.http.host=0.0.0.0"]

然后让我们构建它(这将需要一些时间,我们在这里编译的是原生二进制)

$ docker build -f src/main/docker/Dockerfile.multistage -t quarkus/hsearch-quarkus .
[... lots of logs ...]
Successfully tagged quarkus/hsearch-quarkus:latest

容器镜像现在已准备好使用。如果尚未完成,让我们启动一个环境

$ docker-compose -f environment-stack.yml -p hsearch-quarkus-env up

一旦一切准备就绪,让我们启动我们的应用程序

$ docker run --rm -it --network=host \
        -e POSTGRES_HOST=localhost \
        -e POSTGRES_DB=hsearch_demo \
        -e POSTGRES_USER=hsearch_demo \
        -e POSTGRES_PASSWORD=hsearch_demo \
        -e ES_HOSTS=http://localhost:9200 \
        quarkus/hsearch-quarkus
2019-11-07 16:13:50,806 INFO  [io.quarkus] (main) hsearch-quarkus 1.0-SNAPSHOT (running on Quarkus 1.0.0.CR1) started in 1.320s. Listening on: http://0.0.0.0:8080
2019-11-07 16:13:50,807 INFO  [io.quarkus] (main) Profile prod activated.
2019-11-07 16:13:50,807 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, hibernate-search-elasticsearch, jdbc-postgresql, narayana-jta, resteasy, resteasy-jsonb]

好的,这有点慢。但这只是因为应用程序初始化了数据库和 Elasticsearch 架构。我们再试一次

docker run --rm -it --network=host \
        -e POSTGRES_HOST=localhost \
        -e POSTGRES_DB=hsearch_demo \
        -e POSTGRES_USER=hsearch_demo \
        -e POSTGRES_PASSWORD=hsearch_demo \
        -e ES_HOSTS=http://localhost:9200 \
        quarkus/hsearch-quarkus
2019-11-07 16:14:00,332 INFO  [io.quarkus] (main) hsearch-quarkus 1.0-SNAPSHOT (running on Quarkus 1.0.0.CR1) started in 0.090s. Listening on: http://0.0.0.0:8080
2019-11-07 16:14:00,332 INFO  [io.quarkus] (main) Profile prod activated.
2019-11-07 16:14:00,332 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, hibernate-search-elasticsearch, jdbc-postgresql, narayana-jta, resteasy, resteasy-jsonb]

大约 100ms,这对于一个在启动时打开连接到数据库和 Elasticsearch 集群的 REST + CRUD 应用程序来说相当不错。

应用程序现在已准备好接受命令

$ curl -X PUT http://localhost:8080/client/ -H "Content-Type: application/json" -d '{"name":"Wayne Enterprises"}'

{
    "id": 1,
    "name": "Wayne Enterprises"
}
$ curl -X GET 'http://localhost:8080/client/search/?terms=enterprise'

[
    {
        "id": 1,
        "name": "Wayne Enterprises"
    }
]

……

我们现在是我们自己的 REST 应用程序的快乐所有者,它提供 CRUD 操作和更高级的全文搜索操作,并打包为容器镜像。

因为软件开发是一项永无止境的任务,我们仍然可以改进一些东西

  • 利用 Flyway 改善健壮性,以处理数据库模式升级。

  • 如果需要,向我们的原生二进制添加 SSL 支持

  • 利用许多 Quarkus 扩展 添加安全层、跟踪、容错等。

  • 向我们的应用程序添加更多搜索功能

    • 我们可以 更精细地调整我们的分析器 以获得更好的搜索命中(见 精确率和召回率)。对于业务经理的姓名来说,english 分析器并不是一个非常合适的匹配,特别是因为对人物名称进行词干提取只会导致更多误报。

    • 我们可以 索引不仅仅是文本,包括枚举、数字、日期/时间值,甚至是空间坐标(点)。

    • 甚至可以通过 自定义桥梁 索引自定义类型

    • 还有其他 谓词 可用,例如 range("between"/"greater than"/...),空间谓词等等。

    • 我们可以明确地对搜索结果进行排序,而不是依赖于默认的排序方式(按相关性)。

    • 当从数据库加载数据不是可选的时候,我们可以使用投影直接从Elasticsearch加载数据。

    • 我们可以通过聚合实现分面搜索(列出每个客户类别的搜索结果数量)。

  • 以及任何你能想到的!

反馈、问题、想法?

要联系Hibernate团队,请使用以下渠道


回到顶部