Lucene实现SearchManager近实时搜索

lucene通过NRTManager这个类来实现近实时搜索,所谓近实时搜索即在索引发生改变时,通

过线程跟踪,在相对很短的时间反映给给用户程序的调用

NRTManager通过管理IndexWriter对象,并将IndexWriter的一些方法(增删改)例如

addDocument,deleteDocument等方法暴露给客户调用,它的操作全部在内存里面,所以如果

你不调用IndexWriter的commit方法,通过以上的操作,用户硬盘里面的索引库是不会变化的,所

以你每次更新完索引库请记得commit掉,这样才能将变化的索引一起写到硬盘中,实现索引更新后的同步

用户每次获取最新索引(IndexSearcher),可以通过两种方式,第一种是通过调用

NRTManagerReopenThread对象,该线程负责实时跟踪索引内存的变化,每次变化就调用

maybeReopen方法,保持最新代索引,打开一个新的IndexSearcher对象,而用户所要的

IndexSearcher对象是NRTManager通过调用getSearcherManager方法获得SearcherManager对

象,然后通过SearcherManager对象获取IndexSearcher对象返回个客户使用,用户使用完之

后调用SearcherManager的release释放IndexSearcher对象,最后记得关闭NRTManagerReopenThread;

第二种方式是不通过NRTManagerReopenThread对象,而是直接调用NRTManager的

maybeReopen方法来获取最新的IndexSearcher对象来获取最新索引

一:单独使用SearchManager

1.// 实现近实时搜索//所以就不通过Indexread来创建search

private SearcherManager manager;//是线程安全的

2.在构造方法中实现SearchManager

// 创建SearcherManage
	manager = new SearcherManager(directory, new SearcherWarmer() {
                         @Override
			public void warm(IndexSearcher arg0) throws IOException {
				System.out.println("索引改变了...");
			}
 }, Executors.newCachedThreadPool());

3.在检索的query方法中

// 获得一个search
IndexSearcher search = manager.acquire();

4./*

* maybeReopen会自动检查是否需要重新打开

* 比如重复执行search02几次,中间一次删除一条数据

* 这个删除的数据需要对writer进行commit才行

* 那么使用maybeReopen就可以检测到硬盘中的索引是否改变

* 并在下次查询的时候把删除的这条给去掉

*/

   manager.maybeReopen();

5.通过SearchManager关闭search

finally {
	try {
		// 通过manager关闭search
		manager.release(search);
	} catch (IOException e) {
		e.printStackTrace();
}

6.对索引操作时记得

writer.deleteDocuments(new Term("id", "1"));
// 使用SearchManage需要对writer进行commit
writer.commit();
package org.itat.index;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.Executors;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.SearcherWarmer;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.util.Version;

/**
 * 只使用SearcherManager,不使用NRTManager
 *
 * SearcherManager的maybeReopen会自动检查是否需要重新打开,比如重复执行search02几次,
 * 中间的一次删除一条数据这个删除的数据需要对writer进行commit才行,这样硬盘上的索引才会生效
 * 那么使用maybeReopen就可以检测到硬盘中的索引是否改变,并在下次查询的时候就进行生效
 *
 * 但是:
 * 光使用SearcherManager的话做不到实时搜索,为什么呢?
 * 因为使用SearcherManager需要进行writer.commit才会检测到,但是我们知道writer的commit是非常
 * 消耗性能的,我们不能经常性的commit,那需要怎么做呢?
 * 我们只能把添加修改删除的操作在内存中生效,然后使用内存中的索引信息并且在搜索时能起到效果,
 * 过一段时间累计到一定程序才进行writer.commit
 */
public class IndexUtil1 {
	private String[] ids = {"1","2","3","4","5","6"};
	private String[] emails = {"[email protected]","[email protected]","[email protected]","[email protected]","[email protected]","[email protected]"};
	private String[] contents = {
			"welcome to visited the space,I like book",
			"hello boy, I like pingpeng ball",
			"my name is cc I like game",
			"I like football",
			"I like football and I like basketball too",
			"I like movie and swim"
	};
	private int[] attachs = {2,3,1,4,5,5};
	private String[] names = {"zhangsan","lisi","john","jetty","mike","jake"};
	private Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_35);
	private SearcherManager mgr = null;//是线程安全的

	private Directory directory = null;

	public IndexUtil1() {
		try {
			directory = FSDirectory.open(new File("D:\\Workspaces\\realtime\\index"));
			mgr = new SearcherManager(
				directory,
				new SearcherWarmer() {
				/**
				 * 索引一更新就要重新获取searcher,那获取searcher的时候就会调用这个方法
				 * 执行maybeReopen的时候会执行warm方法,在这里可以对资源等进行控制
				 */
					@Override
					public void warm(IndexSearcher search) throws IOException {
						System.out.println("has change");
					}
				},
				Executors.newCachedThreadPool()
			);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	/**
	 * 删除索引数据,默认不会完全删除,被放入索引回收站
	 */
	public void delete(String id) {
		IndexWriter writer = null;

		try {
			writer = new IndexWriter(directory,
					new IndexWriterConfig(Version.LUCENE_35,analyzer));
			//参数是一个选项,可以是一个Query,也可以是一个term,term是一个精确查找的值
			//此时删除的文档并不会被完全删除,而是存储在一个回收站中的,可以恢复
			//执行完这个操作,索引文件夹下就会多出一个名叫_0_1.del的文件,也就是删除的文件在这个文件中记录了
			writer.deleteDocuments(new Term("id",id));
			writer.commit();
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (LockObtainFailedException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				if(writer!=null) writer.close();
			} catch (CorruptIndexException e) {
				e.printStackTrace();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

	public void query() {
		try {
			IndexReader reader = IndexReader.open(directory);
			//通过reader可以有效的获取到文档的数量
			System.out.println("numDocs:"+reader.numDocs());//存储的文档数//不包括被删除的
			System.out.println("maxDocs:"+reader.maxDoc());//总存储量,包括在回收站中的索引
			System.out.println("deleteDocs:"+reader.numDeletedDocs());
			reader.close();
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
	/**
	 *	索引文件后缀为.fmn为保存的是域的名称等
	 * .fdt和.fdx保存的是Store.YES的信息,保存域里面存储的数据
	 * .frq表示这里的域哪些出现多少次,哪些单词出现多少次,
	 * .nrm存储一些评分信息
	 * .prx存储一些偏移量等
	 * .tii和.tis专门存储索引里面的所有内容信息
	 */
	public void index() {
		IndexWriter writer = null;
		try {
			//在2.9版本之后,lucene的就不是全部的索引格式都兼容的了,所以在使用的时候必须写明版本号
			writer = new IndexWriter(directory, new IndexWriterConfig(Version.LUCENE_35, analyzer));
			writer.deleteAll();//清空索引
			Document doc = null;
			for(int i=0;i<ids.length;i++) {
				doc = new Document();
				doc.add(new Field("id",ids[i],Field.Store.YES,Field.Index.NOT_ANALYZED_NO_NORMS));
				doc.add(new Field("email",emails[i],Field.Store.YES,Field.Index.NOT_ANALYZED));
				doc.add(new Field("email","test"+i+"@test.com",Field.Store.YES,Field.Index.NOT_ANALYZED));
				doc.add(new Field("content",contents[i],Field.Store.NO,Field.Index.ANALYZED));
				doc.add(new Field("name",names[i],Field.Store.YES,Field.Index.NOT_ANALYZED_NO_NORMS));
				//存储数字
				//NumberTools.stringToLong("");已经被标记为过时了
				doc.add(new NumericField("attach",Field.Store.YES,true).setIntValue(attachs[i]));
				String et = emails[i].substring(emails[i].lastIndexOf("@")+1);
				writer.addDocument(doc);
			}
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (LockObtainFailedException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				if(writer!=null)writer.close();
			} catch (CorruptIndexException e) {
				e.printStackTrace();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

	public void search02() {
		IndexSearcher searcher = mgr.acquire();//获得一个searcher
		try {
			/**
			 * maybeReopen会自动检查是否需要重新打开
			 * 比如重复执行search02几次,中间一次删除一条数据
			 * 这个删除的数据需要对writer进行commit才行
			 * 那么使用maybeReopen就可以检测到硬盘中的索引是否改变
			 * 并在下次查询的时候把删除的这条给去掉
			 */
			mgr.maybeReopen();
			TermQuery query = new TermQuery(new Term("content","like"));
			TopDocs tds = searcher.search(query, 10);
			for(ScoreDoc sd:tds.scoreDocs) {
				Document doc = searcher.doc(sd.doc);
				System.out.println(doc.get("id")+"---->"+
						doc.get("name")+"["+doc.get("email")+"]-->"+doc.get("id")+","+
						doc.get("attach")+","+doc.get("date")+","+doc.getValues("email")[1]);
			}
			searcher.close();
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally{
			try {
				mgr.release(searcher);//释放一个searcher
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

}

二:使用NRTManager+ManagerSearch

package org.itat.index;

import java.io.File;
import java.io.IOException;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.NRTManager;
import org.apache.lucene.search.NRTManagerReopenThread;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.SearcherWarmer;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.util.Version;

/**
 * SearcherManager和NRTManager联合使用
 *
 * SearcherManager的maybeReopen会自动检查是否需要重新打开,比如重复执行search02几次,
 * 中间的一次删除一条数据这个删除的数据需要对writer进行commit才行,这样硬盘上的索引才会生效
 * 那么使用maybeReopen就可以检测到硬盘中的索引是否改变,并在下次查询的时候就进行生效
 *
 * 但是:
 * 光使用SearcherManager的话做不到实时搜索,为什么呢?
 * 因为使用SearcherManager需要进行writer.commit才会检测到,但是我们知道writer的commit是非常
 * 消耗性能的,我们不能经常性的commit,那需要怎么做呢?
 * 我们只能把添加修改删除的操作在内存中生效,然后使用内存中的索引信息并且在搜索时能起到效果,
 * 过一段时间累计到一定程序才进行writer.commit
 * NRTManage就是这样的功能,把更新的数据存储在内容中,但是lucene搜索的时候也可以搜索到,需要
 * writer进行commit才会把索引更新到硬盘中
 */
public class IndexUtil2 {
	private String[] ids = {"1","2","3","4","5","6"};
	private String[] emails = {"[email protected]","[email protected]","[email protected]","[email protected]","[email protected]","[email protected]"};
	private String[] contents = {
			"welcome to visited the space,I like book",
			"hello boy, I like pingpeng ball",
			"my name is cc I like game",
			"I like football",
			"I like football and I like basketball too",
			"I like movie and swim"
	};
	private int[] attachs = {2,3,1,4,5,5};
	private String[] names = {"zhangsan","lisi","john","jetty","mike","jake"};
	private Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_35);
	private SearcherManager mgr = null;//是线程安全的
	private NRTManager nrtMgr = null;//near real time  近实时搜索
	private Directory directory = null;
	private IndexWriter writer = null;

	public IndexUtil2() {
		try {
			directory = FSDirectory.open(new File("D:\\Workspaces\\realtime\\index"));
			writer = new IndexWriter(directory,new IndexWriterConfig(Version.LUCENE_35, analyzer));
			nrtMgr = new NRTManager(writer,
						new SearcherWarmer() {
							/**
							 * 索引一更新就要重新获取searcher,那获取searcher的时候就会调用这个方法
							 * 执行maybeReopen的时候会执行warm方法,在这里可以对资源等进行控制
							 */
							@Override
							public void warm(IndexSearcher search) throws IOException {
								System.out.println("has open");
							}
						}
			);
			//启动NRTManager的Reopen线程
			//NRTManagerReopenThread会每隔25秒去检测一下索引是否更新并判断是否需要重新打开writer
			NRTManagerReopenThread reopen = new NRTManagerReopenThread(nrtMgr, 5.0, 0.025);//0.025为25秒
			reopen.setDaemon(true);//设为后台线程
			reopen.setName("NrtManager Reopen Thread");
			reopen.start();

			mgr = nrtMgr.getSearcherManager(true);//true为允许所有的更新
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	/**
	 * 删除索引数据,默认不会完全删除,被放入索引回收站
	 */
	public void delete(String id) {

		try {
			//参数是一个选项,可以是一个Query,也可以是一个term,term是一个精确查找的值
			//此时删除的文档并不会被完全删除,而是存储在一个回收站中的,可以恢复
			//执行完这个操作,索引文件夹下就会多出一个名叫_0_1.del的文件,也就是删除的文件在这个文件中记录了
			nrtMgr.deleteDocuments(new Term("id",id));//使用使用nrtMgr来删除
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (LockObtainFailedException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	public void query() {
		try {
			IndexReader reader = IndexReader.open(directory);
			//通过reader可以有效的获取到文档的数量
			System.out.println("numDocs:"+reader.numDocs());//存储的文档数//不包括被删除的
			System.out.println("maxDocs:"+reader.maxDoc());//总存储量,包括在回收站中的索引
			System.out.println("deleteDocs:"+reader.numDeletedDocs());
			reader.close();
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
	/**
	 *	索引文件后缀为.fmn为保存的是域的名称等
	 * .fdt和.fdx保存的是Store.YES的信息,保存域里面存储的数据
	 * .frq表示这里的域哪些出现多少次,哪些单词出现多少次,
	 * .nrm存储一些评分信息
	 * .prx存储一些偏移量等
	 * .tii和.tis专门存储索引里面的所有内容信息
	 */
	public void index() {
		IndexWriter writer = null;
		try {
			//在2.9版本之后,lucene的就不是全部的索引格式都兼容的了,所以在使用的时候必须写明版本号
			writer = new IndexWriter(directory, new IndexWriterConfig(Version.LUCENE_35, analyzer));
			writer.deleteAll();//清空索引
			Document doc = null;
			for(int i=0;i<ids.length;i++) {
				doc = new Document();
				doc.add(new Field("id",ids[i],Field.Store.YES,Field.Index.NOT_ANALYZED_NO_NORMS));
				doc.add(new Field("email",emails[i],Field.Store.YES,Field.Index.NOT_ANALYZED));
				doc.add(new Field("email","test"+i+"@test.com",Field.Store.YES,Field.Index.NOT_ANALYZED));
				doc.add(new Field("content",contents[i],Field.Store.NO,Field.Index.ANALYZED));
				doc.add(new Field("name",names[i],Field.Store.YES,Field.Index.NOT_ANALYZED_NO_NORMS));
				//存储数字
				//NumberTools.stringToLong("");已经被标记为过时了
				doc.add(new NumericField("attach",Field.Store.YES,true).setIntValue(attachs[i]));
				String et = emails[i].substring(emails[i].lastIndexOf("@")+1);
				writer.addDocument(doc);
			}
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (LockObtainFailedException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				if(writer!=null)writer.close();
			} catch (CorruptIndexException e) {
				e.printStackTrace();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

	public void search02() {
		IndexSearcher searcher = mgr.acquire();//获得一个searcher
		try {
			/**
			 * maybeReopen会自动检查是否需要重新打开
			 * 比如重复执行search02几次,中间一次删除一条数据
			 * 这个删除的数据需要对writer进行commit才行
			 * 那么使用maybeReopen就可以检测到硬盘中的索引是否改变
			 * 并在下次查询的时候把删除的这条给去掉
			 */
			TermQuery query = new TermQuery(new Term("content","like"));
			TopDocs tds = searcher.search(query, 10);
			for(ScoreDoc sd:tds.scoreDocs) {
				Document doc = searcher.doc(sd.doc);
				System.out.println(doc.get("id")+"---->"+
						doc.get("name")+"["+doc.get("email")+"]-->"+doc.get("id")+","+
						doc.get("attach")+","+doc.get("date")+","+doc.getValues("email")[1]);
			}
			searcher.close();
		} catch (CorruptIndexException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally{
			try {
				mgr.release(searcher);//释放一个searcher
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

}
时间: 2024-08-26 14:35:55

Lucene实现SearchManager近实时搜索的相关文章

Lucene.net 实现近实时搜索(NRT)和增量索引

Lucene做站内搜索的时候经常会遇到实时搜索的应用场景,比如用户搜索的功能.实现实时搜索,最普通的做法是,添加新的document之后,调用 IndexWriter 的 Commit 方法把内存中的索引提交到硬盘:然后重新打开IndexReader,进行搜索.但是索引一般存储在硬盘上,而且当索引文件比较大的时候,Commit操作和重新打开IndexReader效率比较低. 于是就想,可否一份索引的IndexWriter始终打开,当需要添加或删除Document时,直接调用该IndexWrite

lucene3.5通过NRTManager和SearchManager实现近实时搜索

实时搜索(近实时搜索) 完全的实时搜索:只要数据库一变动,马上要更新索引,writer.commit来操作 近实时搜索:当用户修改了信息之后,先把索引保存到内存中,然后在一个统一的时间对内存中的所有的索引进行提交操作. reopen,NRTManager(near-real-time) lucene通过NRTManager这个类来实现近实时搜索,所谓近实时搜索即在索引发生改变时,通过线程跟踪,在相对很短的时间反映给给用户程序的调用. NRTManager通过管理IndexWriter对象,并将I

lucene4之后的近实时搜索实现

好久没干这块东西了,近几天需要做这个,所以重新学了一下,首先非常感谢孔浩老师,没孔浩老师的视频我也不会进入lucene的殿堂. 老师当时讲的实时搜索还是NRTManager,现在已经都变了,这个类已经不存在了,在4.0之后消失的,到我现在使用的5.2.1都是下面的方法: 首先罗列会使用的特殊类(常用的不再赘述): TrackingIndexWriter  追踪writer,在api中有介绍,只有通过这个类进行更新ControlledRealTimeReopenThread才能获得更新 Contr

剖析Elasticsearch集群系列之三:近实时搜索、深层分页问题和搜索相关性权衡之道

转载:http://www.infoq.com/cn/articles/anatomy-of-an-elasticsearch-cluster-part03 近实时搜索 虽然Elasticsearch中的变更不能立即可见,它还是提供了一个近实时的搜索引擎.如前一篇中所述,提交Lucene的变更到磁盘是一个代价昂贵的操作.为了避免在文档对查询依然有效的时候,提交变更到磁盘,Elasticsearch在内存缓冲和磁盘之间提供了一个文件系统缓存.内存缓存(默认情况下)每1秒刷新一次,在文件系统缓存中使

solr 近实时搜索

摘要: Solr的近实时搜索NRT(Near Real Time Searching)意味着文档可以在索引以后马上可以被查询到. Solr不会因为本次提交而阻塞更新操作,不会等待后台合并操作(merge)的完成而是直接检索索引并返回数据.参见原文 利用NRT,就可以设置soft commit,因为标准的commit操作代价高昂,soft commit可以做到近乎实时的查询效果而不丢失数据. Commits 与 Optimizing 一个commit操作可以使新的查询请求能够感知到索引的变化,一般

基于Lucene的近实时搜索引擎优化总结

一.搜索优化: 在工程领域,越是看起来“简单.确定”的问题,越是难以解决.近实时搜索引擎需要解决的问题只有一个:性能!它包含快速索引,快速搜索,以及索引到搜索的快速生效. 以下为百万条数据级(适用于千万级)快速滚动数据近实时搜索引擎实践经验总结:  1. 针对技术优化 1.1 数值搜索优化: 将数值的范围缩小,能用 int值 的不要用 long值,能用 float值 的不用要 double值:能用string 替换的,就不要用范围查询(特别是大范围查询),这些都基于Lucene搜索引擎对数值建索

Solr -- 实时搜索

在solr中,实时搜索有3种方案 ①soft commit,这其实是近实时搜索,不能完全实时. ②RealTimeGet,这是实时,但只支持根据文档ID的查询. ③和第一种类似,只是触发softcommit. 综上,其实是由实时(②)和近实时(①③)两种. solr4.0 之后使用NRT的方法和需要的配置 方案1 使用soft commit达到近实时搜索的效果. 为了使用soft commit ,需要配置solrconfig.xml.其中两个地方需要修改 <autoCommit> <ma

一步一步跟我学习lucene(19)---lucene增量更新和NRT(near-real-time)Query近实时查询

这两天加班,不能兼顾博客的更新,请大家见谅. 有时候我们创建完索引之后,数据源可能有更新的内容,而我们又想像数据库那样能直接体现在查询中,这里就是我们所说的增量索引.对于这样的需求我们怎么来实现呢?lucene内部是没有提供这种增量索引的实现的: 这里我们一般可能会想到,将之前的索引全部删除,然后进行索引的重建.对于这种做法,如果数据源的条数不是特别大的情况下倒还可以,如果数据源的条数特别大的话,势必会造成查询数据耗时,同时索引的构建也是比较耗时的,几相叠加,势必可能造成查询的时候数据缺失的情况

搜索引擎系列 ---lucene简介 创建索引和搜索初步

一.什么是Lucene? Lucene最初是由Doug Cutting开发的,2000年3月,发布第一个版本,是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎 :Lucene得名于Doug妻子的中名,同时这也她外祖母的姓;目前是Apache基金会的一个顶级项目,同时也是学习搜索引擎入门必知必会. Lucene 是一个 JAVA 搜索类库,它本身并不是一个完整的解决方案,需要额外的开发工作. 优点:成熟的解决方案,有很多的成功案例.apache 顶级项目,正在持续快速的进步.庞大而活跃的开