Full Text Search với Hibernate và SpringMVC Phần 1: Hello Hibernate Search

Giới thiệu

Về khái niệm Full text search (FTS) các bạn có thể xem tại bài viết này của chị Huyền Châm, mình thấy khá đầy đủ và dễ hiểu.

Tại bài viết này mình sẽ chia sẻ cách để thực hiện FTS với Hibernate trong SpringMVC.

Tại sao lại với Hibernate mà không phải với MySQL hay Postgresql? Vì khi setup FTS ở tầng dưới (database) như vậy, khi dự án thay đổi database engine, sang SQL server chẳng hạn, thì ta lại phải tìm hiểu và làm FTS từ đầu. Còn khi làm FTS tầng trên (Hibernate) thì ta không cần quan tâm đến database là loại gì.

Project demo có ở cuối bài viết.

Mục tiêu

Tạo trang tìm kiếm sách, nhập keyword, kết quả trả về các cuốn sách có liên quan theo tiêu đề, mô tả, tác giả.

Chuẩn bị môi trường

  • Java
  • Spring MVC framework
  • Hibernate
  • PostgreSQL (các loại db khác cũng tương tự thôi)

Code

Tạo bảng

CREATE TABLE book
(
  book_id serial NOT NULL,
  title text,
  description text,
  author text,
  CONSTRAINT book_pkey PRIMARY KEY (book_id)
)

Tạo và configure project

pom.xml Bạn cần thêm 1 số thư viện như sau:

<!-- Hibernate lib -->
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>4.2.15.Final</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-search-engine</artifactId>
            <version>4.3.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-search-orm</artifactId>
            <version>4.3.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-search-orm</artifactId>
            <version>4.3.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate.javax.persistence</groupId>
            <artifactId>hibernate-jpa-2.0-api</artifactId>
            <version>1.0.1.Final</version>
        </dependency>
<!-- Postgresql lib, nếu dùng db khác thì các bạn thay vào đây -->
        <dependency>
            <groupId>postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>9.1-901.jdbc4</version>
        </dependency>

web.xml

<web-app id="WebApp_ID" version="2.4"
         xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee
   http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">

    <display-name>Spring Web MVC Application</display-name>
    <servlet>
        <servlet-name>dispatcher</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>dispatcher</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/dispatcher-servlet.xml</param-value>
    </context-param>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
</web-app>

dispatcher-servlet.xml Bạn cần thay giá trị của /home/framgia/Documents/My Data/DemoFTS/indexes thành đường dẫn đến folder nào bạn muốn chứa index của ứng dụng.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context-3.0.xsd">

    <context:component-scan base-package="vn.va"/>

    <mvc:annotation-driven />

    <bean id="myDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
        <property name="driverClassName" value="org.postgresql.Driver"/>
        <property name="url"
                  value="jdbc:postgresql://localhost:5432/demofts?currentSchema=demofts?useUnicode=true&characterEncoding=UTF-8&characterSetResults=UTF-8"/>
        <property name="username" value="postgres"/>
        <property name="password" value="postgres"/>
        <property name="maxActive" value="8"/>
        <property name="maxIdle" value="4"/>
        <property name="maxWait" value="900000"/>
        <property name="validationQuery" value="SELECT 1"/>
        <property name="testOnBorrow" value="true"/>
    </bean>
    <bean id="mySessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
        <property name="dataSource" ref="myDataSource"/>
        <property name="packagesToScan">
            <array>
                <value>vn.va.entities</value>
            </array>
        </property>
        <property name="hibernateProperties">
            <props>
                <prop key="hibernate.dialect">org.hibernate.dialect.PostgreSQL82Dialect</prop>
                <prop key="hibernate.cache.provider_class">org.hibernate.cache.internal.NoCachingRegionFactory</prop>
                <prop key="hibernate.search.default.directory_provider">
                    org.hibernate.search.store.impl.FSDirectoryProvider
                </prop>
                <prop key="hibernate.search.default.indexBase">
                <!--TODO Thay bằng đường dẫn của bạn -->
                    /home/framgia/Documents/My Data/DemoFTS/indexes
                </prop>
            </props>
        </property>
    </bean>

    <bean id="transactionManager"
          class="org.springframework.orm.hibernate4.HibernateTransactionManager">
        <property name="sessionFactory" ref="mySessionFactory"/>
    </bean>

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix">
            <value>/</value>
        </property>
        <property name="suffix">
            <value>.jsp</value>
        </property>
    </bean>

    <tx:annotation-driven transaction-manager="transactionManager"/>
</beans>

hibernate.cfg.xml Nếu bạn không dùng Postgesql thì hãy sửa file này.

<?xml version="1.0" encoding="UTF-8"?>
<hibernate-configuration>
    <session-factory>
        <!-- Connection settings -->
        <property name="hibernate.connection.driver_class">org.postgresql.Driver</property>
        <property name="hibernate.connection.url">jdbc:postgresql://localhost:5432/demofts?currentSchema=demofts?useUnicode=true&characterEncoding=UTF-8&characterSetResults=UTF-8</property>
        <property name="hibernate.connection.username">postgres</property>
        <property name="hibernate.connection.password">postgres</property>

        <!-- SQL dialect -->
        <property name="hibernate.dialect">org.hibernate.dialect.PostgreSQL82Dialect</property>

        <!-- Print executed SQL to stdout -->
        <property name="show_sql">true</property>

        <!-- Drop and re-create all database on startup -->
        <property name="hibernate.hbm2ddl.auto">create-drop</property>

        <!-- Annotated entities classes -->
        <mapping class="vn.va.entities.Book"/>

    </session-factory>
</hibernate-configuration>

Book.java

@Entity
@Indexed
@Table(name = "demofts.book")
public class Book {
	@Id
	@Column(name = "book_id")
	private Integer bookId;

	@Column(name = "title", nullable= false, length = 128)
	@Field(index= Index.YES, analyze= Analyze.YES, store= Store.NO)
	private String title;

	@Column(name = "description", nullable= false, length = 256)
	@Field(index=Index.YES, analyze=Analyze.YES, store=Store.NO)
	private String description;

	@Column(name = "author", nullable= false, length = 64)
	@Field(index=Index.YES, analyze=Analyze.YES, store=Store.NO)
	private String author;

    // Getter and Setter
    //....
}

CRUD class (BookDAOImpl.java)

Để Hibernate có thể tìm kiếm được thì trước tiên data cần phải được index. Khi bạn insert data bằng entity Book, hibernate sẽ tự động đánh index cho object đó. Vậy nếu với lượng data đã có từ trước thì sao? Bạn chỉ cần chạy hàm sau, tất cả table có Entity tương ứng với annotation @Indexed sẽ được đánh index:

public void indexBooks() throws Exception {
	try {
		Session session = sessionFactory.getCurrentSession();
		FullTextSession fullTextSession = Search.getFullTextSession(session);
		fullTextSession.createIndexer().startAndWait();
	} catch(Exception e) {
		throw e;
	}
}

Add book mới sẽ tự động được index:

public void addBook(String bookTitle, String bookDescription, String bookAuthor) {
	Book book = new Book();
	book.setAuthor(bookAuthor);
	book.setDescription(bookDescription);
	book.setTitle(bookTitle);
	sessionFactory.getCurrentSession().save(book);
}

Để tìm kiếm với input là 1 keyword, sử dụng hàm sau:

public List<Book> search(String keyword) {
	Session session = sessionFactory.getCurrentSession();

	FullTextSession fullTextSession = Search.getFullTextSession(session);

	QueryBuilder qb = fullTextSession.getSearchFactory()
				.buildQueryBuilder().forEntity(Book.class).get();
	org.apache.lucene.search.Query query = qb
				.keyword().onFields("title", "description", "author") // Chỉ định tìm theo cột nào
				.matching(keyword)
				.createQuery();

	org.hibernate.Query hibQuery =
				fullTextSession.createFullTextQuery(query, Book.class);

	List<Book> results = hibQuery.list();
	return results;
}

Kết quả trả về là tất cả các Book có title, description hoặc author liên quan đến từ khóa chúng ta nhập vào. Và thứ tự kết quả sẽ được sort theo "độ liên quan" đến từ khóa. Độ liên quan được tính bằng công thức Lucene Scoring và bạn hoàn toàn có thể custom công thức này để thay đổi thứ tự kết quả search theo ý muốn, chi tiết các bạn tham khảo tại đây.

Controller

Để tập trung vào việc tìm kiếm dữ liệu, mình viết Controller trả về json chứ không viết giao diện html.

@Controller
public class HomeController {
	@Autowired
	BookDAO bookDAO;

	@ResponseBody
	@RequestMapping(value = "/indexData", method = RequestMethod.GET)
	public String indexData() {
		try {
			bookDAO.indexBooks();
		} catch (Exception e) {
			e.printStackTrace();
		}
		return "Indexed at " + new Date().toGMTString();
	}

	@ResponseBody
	@RequestMapping(value = "/search", method = RequestMethod.GET)
	public List<Book> search(@RequestParam(value = "keyword") String keyword) {
		return bookDAO.search(keyword);
	}
}

Như các bạn thấy, có 2 controller (tương ứng 2 api). Trước khi GET \search?keyword=xxx, mình sẽ GET /indexData trước. API này chỉ cần gọi 1 lần khi chạy ứng dụng để index data đã có từ trước trong bảng book. Bạn có thể tự động index bằng nhiều cách khác nhau (mình sẽ không đề cập ở đây để tránh mất tập trung vào việc search 😄 )

Thử nghiệm

Mình có db với data ban đầu như sau: Screenshot from 2016-12-04 14:42:29.png

Với từ khóa Nguyễn A, kết quả trả về:

[
  {
    "title": "Doraemon",
    "author": "Nguyễn Văn A",
    "bookId": 5,
    "description": "Mèo máy Đô rê mon"
  },
  {
    "title": "Harry Lu",
    "author": "Nguyễn Văn B",
    "bookId": 2,
    "description": "Find the perfect gift, whether you're looking for handpicked fresh fruit, beautiful flowers, handcrafted gift baskets or decadent chocolates."
  }
]

Kết quả cỏ cả sách của Nguyễn Văn A và Nguyễn Văn B, và Nguyễn Văn A được xếp trên do có "liên quan" đến từ khóa hơn.

Với từ Fantasy novels, kết quả:

[
  {
    "title": "Fantasy Writer",
    "author": "Nguyễn Văn B",
    "bookId": 2,
    "description": "Find the perfect gift, whether you're looking for handpicked fresh fruit, beautiful flowers, handcrafted gift baskets or decadent chocolates."
  },
  {
    "title": "Harry Potter",
    "author": "J. K. Rowling",
    "bookId": 1,
    "description": "Harry Potter is a series of fantasy novels written by British author J. K. Rowling."
  }
]

What's next?

Với cách làm đơn giản, nhanh chóng, Full Text Search là lựa chọn không hề tồi khi bạn muốn làm chức năng search cho ứng dụng.

Tuy nhiên có 1 điểm hạn chế: search chữ Nguyen thì không ra kết quả Nguyễn, search てんき (chữ hiragana) thì không ra 天気 (kanji). Nguyên nhân là vì bộ Analyzer mặc định của Hibernate không tự động làm việc này. Ở phần sau chúng ta sẽ cùng tìm hiểu sâu hơn về Analyzer từ đó tạo ra API full text search tiếng Nhật, chức năng rất cần thiết khi làm bất cứ 1 dự án nào với khách hàng Nhật Bản.

Cảm ơn các bạn đã đọc, download sample project tại đây.