Статьи

Прозрачная индексация с помощью Hibernate Search

Примечание куратора: эта статья была написана Эндрю Боллом. Он является разработчиком OSI ( Open Software Integrators ). Вы также можете проверить их блог здесь

Возьмем, к примеру, печально известное приложение «Адресная книга бабушки», которое моя компания использует для обучения и проверки новых технологий. Он имеет простой пользовательский интерфейс с несколькими полями. Вы можете найти нашу версию SpringMVC / RDBMS здесь и нашу версию NodeJS / MongoDB с примером AJAX и Appcelerator здесь. У приложения может быть экран, который выглядит следующим образом:

настолько простой, что даже бабушка могла его использовать, верно? Но как насчет кода, если вы используете RDBMS? Один «тупой» способ написать это так:

public List<Address> searchAddresses(String name, boolean nameExact,
  String address, boolean addressExact,
  String email, boolean emailExact,
  String phone, boolean phoneExact) {

  boolean addedOneCondition = false;
  StringBuilder sb = new StringBuilder();
  sb.append("SELECT a FROM Address a");

  if (name != null || address != null || email != null ||
  phone != null ) {
  sb.append(" WHERE ");
  }

  if (name != null) {
  addedOneCondition = true;
  if (nameExact) {
  sb.append(" a.name = :name");
  } else {
  sb.append(" LOWER(a.name) LIKE :name");
  }
  }

  if (address != null) {
  if (addedOneCondition) {
  sb.append(" OR ");
  } else {
  addedOneCondition = true;
  }

  if (addressExact) {
  sb.append(" a.address = :address ");
  } else {
  sb.append(" LOWER(a.address) LIKE :address");
  }
  }

  if (email != null) {
  if (addedOneCondition) {
  sb.append(" OR ");
  } else {
  addedOneCondition = true;
  }

  if (emailExact) {
  sb.append(" a.email = :email");
  } else {
  sb.append(" LOWER(a.email) LIKE :email");
  }
  }

  if (phone != null) {
  if (addedOneCondition) {
  sb.append(" OR ");
  } else {
  addedOneCondition = true;
  }

  if (phoneExact) {
  sb.append(" a.phone = :phone");
  } else {
  sb.append(" LOWER(a.phone) LIKE :phone");
  }
  }

  Query q = em.createQuery(sb.toString(), Address.class);
  if (name != null) {
  if (nameExact) {
  q.setParameter("name", name);
  } else {
  q.setParameter("name", "%" + name + "%");
  }
  }
  if (address != null) {
  if (addressExact) {
  q.setParameter("address", address);
  } else {
  q.setParameter("address", "%" + address + "%");
  }
  }
  if (email != null) {
  if (emailExact) {
  q.setParameter("email", email);
  } else {
  q.setParameter("email", "%" + email + "%");
  }
  }
  if (phone != null) {
  if (phoneExact) {
  q.setParameter("phone", phone);
  } else {
  q.setParameter("phone", "%" + phone + "%");
  }
  }

  return q.getResultList();
}

Это работает ужасно, даже если вы добавляете индекс для каждого столбца и комбинацию столбцов, по которым вы могли бы искать (подход, который, вероятно, расстроит любого достаточно компетентного администратора баз данных, поскольку это снизит производительность записи.)

SQL-запрос в худшем случае сгенерированный из приведенного выше кода будет выглядеть следующим образом:

SELECT * FROM ADDRESS WHERE
LOWER("name") LIKE '%sue%' OR
LOWER("address") LIKE '%Morgan St.%' OR
LOWER("phone") LIKE '%555.555.5555%' OR
LOWER("email") LIKE '%[email protected]%';

Обратите внимание, что нет индексов ни для чего, кроме столбца «id». В PostgreSQL «EXPLAIN ANALYZE VERBOSE» в вышеприведенном запросе показывает, что каждая строка таблицы будет сканироваться для выполнения этого запроса, проверяя совпадения каждого шаблона:

Seq Scan on public.address  (cost=0.00..10.60 rows=1 width=2072) (actual time=0.049..0.052 rows=1 loops=1)
  Output: id, address, email, name, phone
  Filter: (((address.name)::text ~~* '%sue%'::text) OR ((address.address)::text ~~* '%Morgan St.%'::text) OR ((address.phone)::text ~~* '%555.555.5555%'::text) OR ((address.email)::text ~~* '%[email protected]%'::text))
 Total runtime: 0.099 ms

Но это даже не начинает царапать поверхность для таких проблем, как различия в форматах телефонных номеров, псевдонимы (я ввел «Сью» или «Сьюзен»?) И т. Д. Почему я не могу просто позволить Google искать данные для меня ? Что ж, с Hibernate Search вы можете достичь чего-то очень похожего, со всеми инструментами с открытым исходным кодом и минимальными усилиями. Hibernate Search основан на широко известном проекте Apache Lucene, который очень хорошо умеет индексировать данные для полнотекстового поиска, включая автоматическое разбиение слов на корневые слова и их склонения («основание»), а также позволяет создавать списки синонимов.

Итак, как нам получить Hibernate Search, чтобы включить полнотекстовый поиск для нашего примера объекта? Первым шагом является добавление необходимых репозиториев JBoss в наш файл Maven pom.xml, если их еще нет:

<repositories>
  <!-- ... -->
  <repository>
  <id>jboss-public-repository-group</id>
  <name>JBoss Public Maven Repository Group</name>
  <url>https://repository.jboss.org/nexus/content/groups/public-jboss/</url>
  <layout>default</layout>
  <releases>


  <enabled>true</enabled>
  <updatePolicy>never</updatePolicy>
  </releases>
  <snapshots>
  <enabled>true</enabled>
  <updatePolicy>never</updatePolicy>
  </snapshots>
  </repository>
</repositories>
<pluginRepositories>
  <!-- ... ->
  <pluginRepository>
  <id>jboss-public-repository-group</id>
  <name>JBoss Public Maven Repository Group</name>
  <url>https://repository.jboss.org/nexus/content/groups/public-jboss/</url>
  <layout>default</layout>
  <releases>
  <enabled>true</enabled>
  <updatePolicy>never</updatePolicy>
  </releases>
  <snapshots>
  <enabled>true</enabled>
  <updatePolicy>never</updatePolicy>
  </snapshots>
  </pluginRepository>
</pluginRepositories>

Затем мы можем взять нашу аннотированную сущность JPA и добавить несколько аннотаций (отмечены жирным шрифтом ниже):

@Entity
@NamedQueries(
  {@NamedQuery(name="Address.findAll",
  query="select a from Address a"),
  @NamedQuery(name="Address.findByName",
  query="select a from Address a where a.name = ?1")})
@Indexed
@AnalyzerDef(name = "customanalyzer",
  tokenizer = @TokenizerDef(factory =
  StandardTokenizerFactory.class),
  filters = {
  @TokenFilterDef(factory = LowerCaseFilterFactory.class),
  @TokenFilterDef(factory = SnowballPorterFilterFactory.class, params = {
  @Parameter(name = "language", value = "English")
  })
  })
public class Address {
  @Id
  @GeneratedValue(strategy=GenerationType.AUTO)
  private Long id;

  @Field(index=Index.TOKENIZED, store=Store.NO)
  private String name;
  @Field(index=Index.TOKENIZED, store=Store.NO)
  private String email;
  @Field(index=Index.TOKENIZED, store=Store.NO)
  private String phone;
  @Field(index=Index.TOKENIZED, store=Store.NO)
  private String address;

  /* . . . */
}

Most of these annotations are fairly straightforward to understand. @Indexed indicates that we want Hibernate Search to manage indexes for this entity. @Field indicates that a particular property is to be indexed. We can specify that we want indexed fields to be tokenized (that is, split into parts, usually words) when indexed or treated as a single token. This means that “Abe” will match “Abe Lincoln” without having to specify that we want to allow extra characters with a  search pattern such as “Abe*”.

The more interesting annotations also have to do with some of the more interesting functionality that Lucene (and by extension) Hibernate Search provides. The @AnalyzerDef gives some extra directions on the kind of processing that we want to happen to the content before indexing takes place. For example, the LowerCaseFiterFactory.class token filter will convert all text to lowercase before indexing occurs. The Snowball-Porter filter factory does stemming of tokens before they are indexed — that is, root words (“stems”) are extracted, so “hiking”, “hiker”, and “hikers” would all get indexed as “hike”.

After adding a few properties to the JPA META-INF/persistence.xml to tell Hibernate Search where to store the Lucene indexes, we can write a totally different search method as follows:

public List<Address> fullTextSearch(String stringToMatch) {
  FullTextEntityManager ftem = org.hibernate.search.jpa.Search.getFullTextEntityManager(em);

  // build up a Lucene query
  QueryBuilder qb = ftem.getSearchFactory().buildQueryBuilder()
  .forEntity(Address.class).get();
  org.apache.lucene.search.Query luceneQuery = qb
  .keyword()
  .onFields("name", "address", "phone", "email")
  .matching(stringToMatch.toLowerCase())
  .createQuery();

  // wrap the Lucene query in a JPA query
  Query jpaWrappedQuery = ftem.createFullTextQuery(luceneQuery,
  Address.class);

  return jpaWrappedQuery.getResultList();
}

All of this indexing is done transparently by Hibernate Search as entities are persisted, updated, and removed. The performance is light-years ahead of the linear scans of tables done by a relational database. Not to mention, the complete feature set of Apache Lucene is available. What’s not to like? The Hibernate Search project has very good documentation (indeed, much of this implementation comes from that documentation). A simple implementation is on Andrew Ball’s copy of the SpringGrannyMVC project on github at https://github.com/cortextual/OSIL (please use the “search” branch). Happy searching!