Среди множества документов, количество и размер которых могут быть очень большими, необходимо отобрать только те из них, которые отвечают какому-либо условию - например содержат ту или иную фразу. Как решать подобную задачу ? Можно обойти последовательно все документы и проверить наличие искомой фразы в каждом из них. Но сколько времени и ресурсов может уйти на поиск фразы в документе размером скажем 1000000 слов и более ? Умножаем это число на количество документов... Предложенное решение слишком прямолинейно - пока мы будем что-то искать, пользователь может уже подзабыть что же именно он хотел и зачем это было надо.
Чтобы иметь возможность осуществлять поиск текста максимально быстро, предварительно необходимо его проиндексировать - преобразовать в какой-то другой формат, который позволит избежать вышеупомянутых проблем. В нашем случае источником информации является сайт, а документы это не что иное как текстовое содержимое его отдельных HTML-страниц. Для обхода всех страниц сайта нам понадобится crawler. Это будет ещё один подпроект в мультимодульном Maven проекте наряду с Server. Ещё у них будет общая зависимость Common для совместно используемых констант:
~$ cd lucene-tutorial/
~$ tree
.
├── common
│ ├── pom.xml
│ └── src
│ └── main
│ └── java
│ └── common
│ └── LuceneBinding.java
├── crawler
│ ├── pom.xml
│ └── src
│ └── main
│ ├── java
│ │ └── crawler
│ │ ├── App.java
│ │ ├── HtmlHelper.java
│ │ ├── LuceneIndexer.java
│ │ └── SimpleCrawler.java
│ └── resources
│ └── log4j.properties
├── pom.xml
└── server
├── pom.xml
├── src
│ └── main
│ ├── java
│ │ └── server
│ │ └── SearchServlet.java
│ └── webapp
│ └── WEB-INF
│ └── web.xml
└── static.war
18 directories, 13 files
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>tutorial.lucene</groupId>
<artifactId>parent</artifactId>
<packaging>pom</packaging>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>6.0.0</version>
</dependency>
</dependencies>
<modules>
<module>server</module>
<module>crawler</module>
<module>common</module>
</modules>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
crawler/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tutorial.lucene</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>crawler</artifactId>
<packaging>jar</packaging>
<name>Lucene Tutorial Crawler</name>
<dependencies>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.9.1</version>
</dependency>
<dependency>
<groupId>tutorial.lucene</groupId>
<artifactId>common</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
common/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tutorial.lucene</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>common</artifactId>
<packaging>jar</packaging>
<name>Crawler and Server Shared Constants</name>
</project>
crawler/src/main/java/crawler/SimpleCrawler.java
package crawler;
import java.util.HashSet;
import java.util.NavigableSet;
import java.util.Set;
import java.util.TreeSet;
import org.apache.log4j.Logger;
class SimpleCrawler {
public interface ICrawlEvents {
void onVisit(String url, String html, String seed);
boolean shouldVisit(String url, String seed);
}
private static final Logger logger = Logger.getLogger(SimpleCrawler.class.getName());
private final ICrawlEvents events;
private NavigableSet<String> linksToCrawl;
private Set<String> linksCrawled;
private final String seed;
public SimpleCrawler(final String seed, final ICrawlEvents events) {
this.events = events;
this.seed = seed;
};
public boolean hasLinksToCrawl() {
return !this.linksToCrawl.isEmpty();
}
public void run() {
this.linksCrawled = new HashSet<String>();
this.linksToCrawl = new TreeSet<String>();
this.linksToCrawl.add(this.seed);
while (this.hasLinksToCrawl()) {
final String strURL = this.linksToCrawl.pollFirst();
this.linksCrawled.add(strURL);
try {
final String html = HtmlHelper.download(strURL);
int linksAdded = 0;
for (final String l : HtmlHelper.extractLinks(html, this.seed)) {
if (this.events.shouldVisit(l, this.seed) && !this.linksCrawled.contains(l)
&& !this.linksToCrawl.contains(l)) {
this.linksToCrawl.add(l);
linksAdded++;
}
}
SimpleCrawler.logger.info(
String.format("Fetched: [%s] %d new links", strURL, linksAdded));
this.events.onVisit(strURL, html, this.seed);
} catch (final Exception ex) {
SimpleCrawler.logger.error(String.format("Fetch error: [%s].", strURL), ex);
}
}
}
}
Все ссылки хранятся в оперативной памяти - для маленьких и средних по величине сайтов такое решение вполне приемлемо. С внешним миром crawler общается посредством интерфейса ICrawlEvents
, а с HTML работает через статический класс HtmlHelper
. Последний активно использует прекрасную библиотеку для работы с Html в Java - JSoup, которая работает в стиле JQuery посредством css-селекторов. Также стоит обратить внимание на обязательное наличие временной задержки (politeness) между Http-запросами к сайту - большинство хостеров следят за количеством запросов с одного IP, поэтому при работе с реальным веб-сайтом за пределами localhost необходимо соблюдать толерантность, если не хотите получить бан конечно.
crawler/src/main/java/crawler/HtmlHelper.java
package crawler;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
final class HtmlHelper {
public static Collection<String> extractLinks(final String html, final String seed) {
final Document document = Jsoup.parse(html, seed);
final Set<String> linksSet = new HashSet<String>();
for (final Element link : document.select("a[href]")) {
final String strLink = link.attr("abs:href").trim().toLowerCase();
if (!strLink.isEmpty()) {
linksSet.add(strLink);
}
}
return Collections.unmodifiableCollection(linksSet);
}
public static String download(final String link) throws IOException, InterruptedException {
IOException ioe;
int retry = 5;
do {
try {
/* Crawling a real web site politeness > 5s */
Thread.sleep(1);
final Document bDoc = Jsoup.connect(link).userAgent("Mozilla").timeout(30000).get();
return bDoc.html();
} catch (final IOException ex) {
ioe = ex;
}
} while (--retry > 0);
throw ioe;
}
public static String extractTitle(final String html) {
final Document doc = Jsoup.parse(html);
final Elements elements = doc.select("table table div table font");
if (elements != null && !elements.isEmpty()) {
return elements.first().text();
}
return null;
}
public static String extractContent(final String html) {
final Document doc = Jsoup.parse(html);
final Elements elements = doc.select("table table div table div");
if (elements != null && !elements.isEmpty()) {
return elements.first().text();
}
return doc.select("table table div table").first().text();
}
}
Теперь точка входа, которая будет запускать crawler и передавать необходимую информацию соответствующему классу LuceneIndexer
для индексации:
crawler/src/main/java/crawler/App.java
package crawler;
import org.apache.log4j.Logger;
public class App {
private static final Logger logger = Logger.getLogger(App.class.getName());
private final static String crawlSeed = "http://localhost:8080";
public static void main(final String[] args) {
try (final LuceneIndexer luceneIndexer = new LuceneIndexer()) {
luceneIndexer.new_index();
/* Start crawler and perform indexing */
new SimpleCrawler(App.crawlSeed, new SimpleCrawler.ICrawlEvents() {
/* Proceed page - add to Lucene index */
@Override
public void onVisit(final String url, final String html, final String seed) {
luceneIndexer.add(url, html);
}
/* Skip any external links */
@Override
public boolean shouldVisit(final String url, final String seed) {
return url.startsWith(seed);
}
}).run();
} catch (final Exception ex) {
App.logger.error("Unable to start !", ex);
}
}
}
Таким образом мы подошли, пожалуй, к ключевому моменту данной статьи - процессу создания индекса в Lucene. Главным классом тут является IndexWriter
- которому необходимо знать директорию, где будут храниться файлы индекса. Опция OpenMode.CREATE
указывает на необходимость удалять старый индекс, если тот на момент инициализации в указанной директории уже существует. Согласно документации, IndexWriter
потокобезопасный:
crawler/src/main/java/crawler/LuceneIndexer.java
package crawler;
import java.io.Closeable;
import java.io.IOException;
import org.apache.log4j.Logger;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import common.LuceneBinding;
class LuceneIndexer implements Closeable {
private static final Logger logger = Logger.getLogger(LuceneIndexer.class.getName());
/* IndexWriter is completely thread safe */
private IndexWriter indexWriter = null;
@Override
public void close() throws IOException {
if (this.indexWriter != null) {
LuceneIndexer.logger.info("Closing Index < " +
LuceneBinding.INDEX_PATH + " > NumDocs: " + this.indexWriter.numDocs());
this.indexWriter.close();
this.indexWriter = null;
LuceneIndexer.logger.info("Index Closed OK!");
} else {
throw new IOException("Index already closed");
}
}
public void new_index() throws IOException {
final Directory directory = FSDirectory.open(LuceneBinding.INDEX_PATH);
final IndexWriterConfig iwConfig = new IndexWriterConfig(LuceneBinding.getAnalyzer());
iwConfig.setOpenMode(OpenMode.CREATE);
this.indexWriter = new IndexWriter(directory, iwConfig);
}
public void add(final String url, final String html) {
final String title = HtmlHelper.extractTitle(html);
final String content = HtmlHelper.extractContent(html);
LuceneIndexer.logger.info("***** " + url + " *****");
if (title != null) {
LuceneIndexer.logger.info(title);
}
LuceneIndexer.logger.info(content);
final Document doc = new Document();
final FieldType urlType = new FieldType();
urlType.setIndexOptions(IndexOptions.DOCS);
urlType.setStored(true);
urlType.setTokenized(false);
urlType.setStoreTermVectorOffsets(false);
urlType.setStoreTermVectorPayloads(false);
urlType.setStoreTermVectorPositions(false);
urlType.setStoreTermVectors(false);
doc.add(new Field(LuceneBinding.URI_FIELD, url, urlType));
final FieldType tokType = new FieldType();
tokType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
tokType.setStored(true);
tokType.setTokenized(true);
tokType.setStoreTermVectorOffsets(true);
tokType.setStoreTermVectorPayloads(true);
tokType.setStoreTermVectorPositions(true);
tokType.setStoreTermVectors(true);
if (title != null) {
doc.add(new Field(LuceneBinding.TITLE_FIELD, title, tokType));
}
doc.add(new Field(LuceneBinding.CONTENT_FIELD, content, tokType));
try {
if (this.indexWriter != null) {
this.indexWriter.addDocument(doc);
}
} catch (final IOException ex) {
LuceneIndexer.logger.error(ex);
}
}
}
Если внимательно посмотреть на содержимое каждой html-странички нашего сайта, то помимо ненавязчивой рекламы и прочего мусора можно выделить две единицы более - менее полезной информации: название (title) публикации и её содержимое (content). Извлечь данную информацию из html нам поможет уже упомянутая выше библиотека JSoup, после чего можно добавлять Document
с соответствующими полями в индекс Lucene.
Lucene может сохранять / извлекать различные типы данных. В нашем случае все данные текстовые, первое поле - url, параметр setStored(true)
указывает на необходимость хранить в индексе оригинальное (неизменное) значение, а остальные false
на то, что поиск по этому полю осуществляться не будет, т.е. это поле несет дополнительную информацию о найденном документе, которую мы планируем использовать в будущем. Поля title и content индексируются одинаково - setTokenized(true)
указывает на то, что по данному полю будет осуществляться поиск а также просит Lucene задействовать механизмы анализа содержимого данного документа на этапе создания индекса (самый простой пример анализа - выделение слов из набора букв и пробелов между ними), параметр setStoreTermVector(true)
позволяет сохранить дополнительную информацию о позициях тех или иных слов в теле документа - такой подход значительно ускоряет процесс подсветки найденных вхождений.
Класс LuceneBinding
- это своеобразный мост между индексатором (crawler) и поиском (SearchServlet
), он содержит общую информацию и используется совместно обоими приложениями:
common/src/main/java/common/LuceneBinding.java
package common;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
/* This class is used both by Crawler and SearchServlet */
public final class LuceneBinding {
public static final Path INDEX_PATH = Paths.get(
System.getProperty("user.home"), "lucene-tutorial-index");
public static final String URI_FIELD = "uri";
public static final String TITLE_FIELD = "title";
public static final String CONTENT_FIELD = "content";
public static Analyzer getAnalyzer() {
return new StandardAnalyzer();
}
}
Сборка и запуск
Перед запускам crawler должен быть запущен Jetty сервер с сайтом, который мы хотим проиндексировать. Чтобы корректно отображались русские буквы в консоли Windows может понадобиться обозначить правильную кодировку Cp866 в файле crawler/src/main/resources/log4j.properties
~$ mvn clean install && bash -c 'mvn -pl server/ jetty:run & sleep 10 && \
mvn -pl crawler/ exec:java -Dexec.mainClass="crawler.App" & \
trap "kill -TERM -$$" SIGINT ; wait'
...
INFO [crawler.App.main()] Closing Index < /home/oleg/lucene-tutorial-index > NumDocs: 53
INFO [crawler.App.main()] Index Closed OK!
Ctrl+C
Далее мы бегло заглянем в индекс, после чего реализуем простой поиск.