回到IndexWriter索引器类中来,学习该类添加Document的方法。
这时,需要用到一个非常重要的类:DocumentWriter,该类对Document进行了很多处理,比如“文档倒排”就是其中的一项重要内容。
实例化一个IndexWriter索引器之后,要向其中添加Document,在IndexWriter类中有两个实现该功能的方法:
public void addDocument(Document doc) throws CorruptIndexException, IOException {
addDocument(doc, analyzer);
}
public void addDocument(Document doc, Analyzer analyzer) throws CorruptIndexException, IOException {
ensureOpen(); // 确保IndexWriter是打开的,这样才能向其中添加Document
SegmentInfo newSegmentInfo = buildSingleDocSegment(doc, analyzer); // 构造一个SegmentInfo实例,SegmentInfo是用来维护索引段信息的
synchronized (this) {
ramSegmentInfos.addElement(newSegmentInfo);
maybeFlushRamSegments();
}
}
可以看出,第一个addDocument方法调用了第二个重载的方法,所以关键在于第二个addDocument方法。
这里,ramSegmentInfos是IndexWriter类的一个成员,该ramSegmentInfos是存在于RAMDirectory中的,定义为:
SegmentInfos ramSegmentInfos = new SegmentInfos();
关于SegmentInfos类,可以参考文章 Lucene-2.2.0 源代码阅读学习(18) 。
上面,buildSingleDocSegment()方法,通过给定的Document和Analyzer来构造一个SegmentInfo实例,关于SegmentInfo类,可以参考文章 Lucene-2.2.0 源代码阅读学习(19) ,buildSingleDocSegment()方法的实现如下所示:
SegmentInfo buildSingleDocSegment(Document doc, Analyzer analyzer)
throws CorruptIndexException, IOException {
DocumentWriter dw = new DocumentWriter(ramDirectory, analyzer, this); // 实例化一个DocumentWriter对象
dw.setInfoStream(infoStream); // 设置一个PrintStream infoStream流对象
String segmentName = newRamSegmentName(); // 在内存中新建一个索引段名称
dw.addDocument(segmentName, doc); // 将Document添加到指定的名称为segmentName的索引段文件中
/* 根据指定的segmentName、ramDirectory,
Document的数量为1个,构造一个SegmentInfo对象,根据SegmentInfo的构造函数:
public SegmentInfo(String name, int docCount, Directory dir, boolean isCompoundFile, boolean hasSingleNormFile)
可知,指定构造的不是一个复合文件,也不是一个具有单独norm文件的SegmentInfo对象,因为我们使用的是2.2版本的,从2.1版本往后,就统一使用一个.nrm文件来代替以前使用的norm文件*/
SegmentInfo si = new SegmentInfo(segmentName, 1, ramDirectory, false, false);
si.setNumFields(dw.getNumFields()); // 设置SegmentInfo中Field的数量
return si; // 返回构造好的SegmentInfo对象
}
在内存中新建一个索引段名称,调用了IndexWriter类的一个方法:
final synchronized String newRamSegmentName() { // synchronized,需要考虑线程同步问题
return "_ram_" + Integer.toString(ramSegmentInfos.counter++, Character.MAX_RADIX);
}
初始化一个SegmentInfo实例时,counter的值为0,counter是用来为一个新的索引段命名的,在SegmentInfo类中定义了这个成员:
public int counter = 0;
上面的newRamSegmentName()方法返回的是一个索引段的名称(该名称用来在内存中,与RAMDirectory相关的),即文件名称为_ram_1。
从上面可以看出,IndexWriter类的addDocument()方法中,最重要的调用buildSingleDocSegment()方法,创建一个SegmentInfo对象,从而在buildSingleDocSegment()方法中使用到了DocumentWriter类,这才是关键了。
下面研究DocumentWriter这个核心类,从IndexWriter类中addDocument()方法入手,先把用到DocumentWriter类的一些具体细节拿出来研究。
一个DocumentWriter的构造
DocumentWriter(Directory directory, Analyzer analyzer, IndexWriter writer) {
this.directory = directory;
this.analyzer = analyzer;
this.similarity = writer.getSimilarity();
this.maxFieldLength = writer.getMaxFieldLength();
this.termIndexInterval = writer.getTermIndexInterval();
}
在这个构造方法中,最大的Field长度为10000,即this.maxFieldLength = writer.getMaxFieldLength();,可以在IndexWriter类中找到定义:
public int getMaxFieldLength() {
ensureOpen();
return maxFieldLength;
}
然后maxFieldLength定义为:
private int maxFieldLength = DEFAULT_MAX_FIELD_LENGTH;
其中,DEFAULT_MAX_FIELD_LENGTH值为:
public final static int DEFAULT_MAX_FIELD_LENGTH = 10000;
同理,默认词条索引区间为128,即this.termIndexInterval = writer.getTermIndexInterval();,也可以在IndexWriter类中找到定义。
另外,this.similarity = writer.getSimilarity();,其实DocumentWriter的这个成员similarity=new DefaultSimilarity();。DefaultSimilarity类继承自Similarity抽象类,该类是用来处理有关“相似性”的,与检索密切相关,其实就是对一些数据在运算过程中可能涉及到数据位数的舍入与进位。具体地,Similarity类的定义可查看org.apache.lucene.search.Similarity。
这样,一个DocumentWriter就构造完成了。
DocumentWriter类的addDocument()方法
final void addDocument(String segment, Document doc)
throws CorruptIndexException, IOException {
// 创建一个FieldInfos对象,用来存储加入到索引的Document中的各个Field的信息
fieldInfos = new FieldInfos();
fieldInfos.add(doc); // 将Document加入到FieldInfos中
// postingTable是用于存储所有词条的HashTable
postingTable.clear(); // clear postingTable
fieldLengths = new int[fieldInfos.size()]; // 初始化int[]数组fieldLengths,用来记录当前Document中所有Field的长度
fieldPositions = new int[fieldInfos.size()]; // 初始化int[]数组fieldPositions,用来记录当前Document中所有Field在分析完成后所处位置
fieldOffsets = new int[fieldInfos.size()]; // 初始化int[]数组fieldOffsets,用来记录当前Document中所有Field的offset
fieldStoresPayloads = new BitSet(fieldInfos.size());
fieldBoosts = new float[fieldInfos.size()]; // 初始化int[]数组fieldBoosts,用来记录当前Document中所有Field的boost值
Arrays.fill(fieldBoosts, doc.getBoost()); // 为fieldBoosts数组中的每个元素赋值,根据Document中记录的boost值
try {
// 在将FieldInfos写入之前,要对Document中的各个Field进行“倒排”
invertDocument(doc);
// 对postingTable中的词条进行排序,返回一个排序的Posting[]数组
Posting[] postings = sortPostingTable();
// 将FieldInfos写入到索引目录directory中,即写入到文件segments.fnm中
fieldInfos.write(directory, segment + ".fnm");
// 构造一个FieldInfos的输出流FieldsWriter,将Field的详细信息(包括上面提到的各个数组中的值)写入到索引目录中
FieldsWriter fieldsWriter =
new FieldsWriter(directory, segment, fieldInfos);
try {
fieldsWriter.addDocument(doc); // 将Document加入到FieldsWriter
} finally {
fieldsWriter.close(); // 关闭FieldsWriter输出流
}
// 将经过排序的Posting[]数组写入到索引段文件中(segmentsv.frq文件和segments.prx文件)
writePostings(postings, segment);
// 写入被索引的Field的norm信息
writeNorms(segment);
} finally {
// 关闭TokenStreams
IOException ex = null;
Iterator it = openTokenStreams.iterator(); // openTokenStreams是DocumentWriter类定义的一个链表成员,即:private List openTokenStreams = new LinkedList();
while (it.hasNext()) {
try {
((TokenStream) it.next()).close();
} catch (IOException e) {
if (ex != null) {
ex = e;
}
}
}
openTokenStreams.clear(); // 清空openTokenStreams
if (ex != null) {
throw ex;
}
}
}
DocumentWriter实现对Document的“倒排”
在DocumentWriter类的addDocument()方法中,在对Document中的各个Field输出到索引目录之前,要对所有加入到IndexWriter索引器(一个IndexWriter的构造,指定了一个Analyzer分析器)的Document执行倒排,即调用倒排的方法invertDocument()。
invertDocument()方法的实现如下所示:
// 调用底层分析器接口,遍历Document中的Field,对数据源进行分析
private final void invertDocument(Document doc)
throws IOException {
Iterator fieldIterator = doc.getFields().iterator(); // 通过Document获取Field的List列表doc.getFields()
while (fieldIterator.hasNext()) {
Fieldable field = (Fieldable) fieldIterator.next();
String fieldName = field.name();
int fieldNumber = fieldInfos.fieldNumber(fieldName); // 根据一个Field的fieldName得到该Field的编号number(number是FieldInfo类的一个成员)
int length = fieldLengths[fieldNumber]; // 根据每个Field的编号,设置每个Field的长度
int position = fieldPositions[fieldNumber]; // 根据每个Field的编号,设置每个Field的位置
if (length>0) position+=analyzer.getPositionIncrementGap(fieldName);
int offset = fieldOffsets[fieldNumber]; // 根据每个Field的编号,设置每个Field的offset
if (field.isIndexed()) { // 如果Field被索引
if (!field.isTokenized()) { // 如果Field没有进行分词
String stringValue = field.stringValue(); // 获取Field的String数据值
if(field.isStoreOffsetWithTermVector()) // 是否把整个Field的数据作为一个词条存储到postingTable中
// 把整个Field的数据作为一个词条存储到postingTable中
addPosition(fieldName, stringValue, position++, null, new TermVectorOffsetInfo(offset, offset + stringValue.length()));
else // 否则,不把整个Field的数据作为一个词条存储到postingTable中
addPosition(fieldName, stringValue, position++, null, null);
offset += stringValue.length();
length++;
} else
{ // 需要对Field进行分词
TokenStream stream = field.tokenStreamValue();
if (stream == null) { // 如果一个TokenStream不存在,即为null,则必须从一个Analyzer中获取一个TokenStream流
Reader reader;
if (field.readerValue() != null) // 如果从Field获取的Reader数据不为null
reader = field.readerValue(); // 一个Reader流存在
else if (field.stringValue() != null)
reader = new StringReader(field.stringValue()); // 根据从Field获取的字符串数据构造一个Reader输入流
else
throw new IllegalArgumentException
("field must have either String or Reader value");
// 把经过分词处理的Field加入到postingTable中
stream = analyzer.tokenStream(fieldName, reader);
}
// 将每个Field对应的TokenStream加入到链表openTokenStreams中,等待整个Document中的所有Field都分析处理完毕后,对链表openTokenStreams中的每个链表TokenStream进行统一关闭
openTokenStreams.add(stream);
// 对第一个Token,重置一个TokenStream
stream.reset();
Token lastToken = null;
for (Token t = stream.next(); t != null; t = stream.next()) {
position += (t.getPositionIncrement() - 1); // 每次切出一个词,就将position加上这个词的长度
Payload payload = t.getPayload(); // 每个词都对应一个Payload,它是关于一个词存储到postingTable中的元数据(metadata)
if (payload != null) {
fieldStoresPayloads.set(fieldNumber); // private BitSet fieldStoresPayloads;,BitSet是一个bits的向量,调用BitSet类的set方法,设置该Field的在索引fieldNumber处的bit值
}
TermVectorOffsetInfo termVectorOffsetInfo;
if (field.isStoreOffsetWithTermVector()) { // 如果指定了Field的词条向量的偏移量,则存储该此条向量
termVectorOffsetInfo = new TermVectorOffsetInfo(offset + t.startOffset(), offset + t.endOffset());
} else {
termVectorOffsetInfo = null;
}
// 把该Field的切出的词条存储到postingTable中
addPosition(fieldName, t.termText(), position++, payload, termVectorOffsetInfo);
lastToken = t;
if (++length >= maxFieldLength) {// 如果当前切出的词条数已经达到了该Field的最大长度
if (infoStream != null)
infoStream.println("maxFieldLength " +maxFieldLength+ " reached, ignoring following tokens");
break;
}
}
if(lastToken != null) // 如果最后一个切出的词不为null,设置offset的值
offset += lastToken.endOffset() + 1;
}
fieldLengths[fieldNumber] = length; // 存储Field的长度
fieldPositions[fieldNumber] = position; // 存储Field的位置
fieldBoosts[fieldNumber] *= field.getBoost(); // 存储Field的boost值
fieldOffsets[fieldNumber] = offset; // 存储Field的offset值
}
}
// 所有的Field都有经过分词处理的具有Payload描述的词条,更新FieldInfos
for (int i = fieldStoresPayloads.nextSetBit(0); i >= 0; i = fieldStoresPayloads.nextSetBit(i+1)) {
fieldInfos.fieldInfo(i).storePayloads = true;
}
}
使用快速排序对postingTable进行排序
当FieldInfos中的每个Field进行分词以后,所有切出的词条都放到了一个HashTable postingTable中,这时所有的词条在postingTable中是无序的。在DocumentWriter的addDocument()方法中调用了sortPostingTable()方法,对词条进行了排序,排序使用“快速排序”方式,“快速排序”的时间复杂度O(N*logN),排序速度很快。
sortPostingTable()方法的实现如下所示:
private final Posting[] sortPostingTable() {
// 将postingTable转换成Posting[]数组,便于快速排序
Posting[] array = new Posting[postingTable.size()];
Enumeration postings = postingTable.elements();
for (int i = 0; postings.hasMoreElements(); i++)
array[i] = (Posting) postings.nextElement();
// 调用quickSort()方法,使用快速排序对Posting[]数组进行排序
quickSort(array, 0, array.length - 1);
return array;
}
快速排序的算法都不陌生,在Lucene中也给出了实现,快速排序方法如下:
private static final void quickSort(Posting[] postings, int lo, int hi) {
if (lo >= hi)
return;
int mid = (lo + hi) / 2;
if (postings[lo].term.compareTo(postings[mid].term) > 0) {
Posting tmp = postings[lo];
postings[lo] = postings[mid];
postings[mid] = tmp;
}
if (postings[mid].term.compareTo(postings[hi].term) > 0) {
Posting tmp = postings[mid];
postings[mid] = postings[hi];
postings[hi] = tmp;
if (postings[lo].term.compareTo(postings[mid].term) > 0) {
Posting tmp2 = postings[lo];
postings[lo] = postings[mid];
postings[mid] = tmp2;
}
}
int left = lo + 1;
int right = hi - 1;
if (left >= right)
return;
Term partition = postings[mid].term;
for (; ;) {
while (postings[right].term.compareTo(partition) > 0)
--right;
while (left < right && postings[left].term.compareTo(partition) <= 0)
++left;
if (left < right) {
Posting tmp = postings[left];
postings[left] = postings[right];
postings[right] = tmp;
--right;
} else {
break;
}
}
quickSort(postings, lo, left);
quickSort(postings, left + 1, hi);
}
关于Posting类
该类是为排序服务的,提取了与词条信息有关的一些使用频率较高的属性,定义成了该Posting类,实现非常简单,如下所示:
final class Posting { // 在一个Document中与词条有关的信息
Term term; // 一个词条
int freq; // 词条Term term在该Document中的频率
int[] positions; // 位置
Payload[] payloads; // Payloads信息
TermVectorOffsetInfo [] offsets; // 词条向量的offset(偏移量)信息
Posting(Term t, int position, Payload payload, TermVectorOffsetInfo offset) { // Posting构造器
term = t;
freq = 1;
positions = new int[1];
positions[0] = position;
if (payload != null) {
payloads = new Payload[1];
payloads[0] = payload;
} else
payloads = null;
if(offset != null){
offsets = new TermVectorOffsetInfo[1];
offsets[0] = offset;
} else
offsets = null;
}
}
Document的倒排非常重要。总结一下:
1、该invertDocument()方法遍历了FieldInfos的每个Field,根据每个Field的属性进行分析,如果需要分词,则调用底层分析器接口,执行分词处理。
2、在invertDocument()方法中,对Field的信息进行加工处理,尤其是每个Field的切出的词条,这些词条最后将添加到postingTable中。
上面DocumentWriter类的addDocument()方法中writePostings()方法,是对已经经过倒排的文档,将词条的一些有用信息写入到索引段文件中。
关于writePostings()方法的实现参考文章 Lucene-2.2.0 源代码阅读学习(23)。
最后的总结:
在学习DocumentWriter类的addDocument()方法的过程中,涉及到了该类的很多方法,其中关于文档的倒排的方法是非常重要的。
此外,还涉及到了FieldInfos类和FieldInfo类,他们的关系很像SegmentInfos类和SegmentInfo类。FieldInfos类主要是对Document添加到中的Field进行管理的,可以通过FieldInfos类来访问Document中所有Field的信息。每个索引段(索引段即Segment)都拥有一个单独的FieldInfos。
应该对FieldInfos类和FieldInfo类有一个了解。