(转)Lucene
lucene
Lucene.Net?系列一本文介紹了什么是Lucene,Lucene能做什么.
如何從一個文件夾下的所有txt文件中查找特定的詞?
本文將圍繞該個實例介紹了lucene.net的索引的建立以及如何針對索引進行搜索.最后還將給出源代碼供大家學習.
源代碼下載
What’s Lucene
Lucene是一個信息檢索的函數庫(Library),利用它你可以為你的應用加上索引和搜索的功能.
Lucene的使用者不需要深入了解有關全文檢索的知識,僅僅學會使用庫中的一個類,你就為你的應用實現全文檢索的功能.
不過千萬別以為Lucene是一個象google那樣的搜索引擎,Lucene甚至不是一個應用程序,它僅僅是一個工具,一個Library.你也可以把它理解為一個將索引,搜索功能封裝的很好的一套簡單易用的API.利用這套API你可以做很多有關搜索的事情,而且很方便.
What Can Lucene Do
Lucene可以對任何的數據做索引和搜索. Lucene不管數據源是什么格式,只要它能被轉化為文字的形式,就可以被Lucene所分析利用.也就是說不管是MS word, Html ,pdf還是其他什么形式的文件只要你可以從中抽取出文字形式的內容就可以被Lucene所用.你就可以用Lucene對它們進行索引以及搜索.
How To Use Lucene --- A Simple Example
示例介紹:
為作為輸入參數的文件夾下的所有txt類型的文件做索引,做好的索引文件放入index文件夾.
然后在索引的基礎上對文件進行全文搜索.
1.???????建立索引
IndexWriter writer = new IndexWriter("index", new StandardAnalyzer(), true);
IndexDocs(writer, new System.IO.FileInfo(args[0]));???????????????
writer.Optimize();
writer.Close();
IndexWriter是對索引進行寫操作的一個類,利用它可以創建一個索引對象然后往其中添加文件.需要注意它并不是唯一可以修改索引的類.在索引建好后利用其他類還可以對其進行修改.
構造函數第一個參數是建立的索引所要放的文件夾的名字.第二個參數是一個分析對象,主要用于從文本中抽取那些需要建立索引的內容,把不需要參與建索引的文本內容去掉.比如去掉一些a the之類的常用詞,還有決定是否大小寫敏感.不同的選項通過指定不同的分析對象控制.第三個參數用于確定是否覆蓋原有索引的.
第二步就是利用這個writer往索引中添加文件.具體后面再說.
第三步進行優化.
第四步關閉writer.
?
下面具體看看第二步:
???public static void IndexDirectory(IndexWriter writer, FileInfo file)
???????? {
????????????? if (Directory.Exists(file.FullName))
????????????? {
?????????????????? String[] files = Directory.GetFileSystemEntries(file.FullName);
?????????????????? // an IO error could occur
?????????????????? if (files != null)
?????????????????? {
?????????????????????? for (int i = 0; i < files.Length; i++)
?????????????????????? {
??????????????????????????? IndexDirectory(writer, new FileInfo(files[i]));? //這里是一個遞歸
?????????????????????? }
?????????????????? }
????????????? }
????????????? else if (file.Extension == ".txt")
????????????? {
?????????????????? IndexFile(file, writer);
????????????? }
???????? }
?
???????? private static void IndexFile(FileInfo file, IndexWriter writer)
???????? {
????????????? Console.Out.WriteLine("adding " + file);
????????????? try
????????????? {
?????????????????? Document doc = new Document();???????????????????
?????????????????? doc.Add(Field.Keyword("filename", file.FullName));
?????????????????? doc.Add(Field.Text("contents", new StreamReader(file.FullName)));
?????????????????? writer.AddDocument(doc);
????????????? }
??????????????
????????????? catch (FileNotFoundException fnfe)
????????????? {
???????????????????
????????????? }
???? }
主要就是兩個函數一個用于處理文件夾(不是為文件夾建立索引),一個用于真正為文件建立索引.
因此主要集中看一下IndexFile這個方法.首先建立Document對象,然后為Document對象添加一些屬性Field.你可以把Document對象看成是虛擬文件,將來將從此獲取信息.而Field則看成是描述此虛擬文件的元數據(metadata).
其中Field包括四個類型:
| Keywork | 該類型的數據將不被分析,而會被索引并保存保存在索引中. |
| UnIndexed | 該類型的數據不會被分析也不會被索引,但是會保存在索引. |
| UnStored | 和UnIndexed剛好相反,被分析被索引,但是不被保存. |
| Text | 和UnStrored類似.如果值的類型為string還會被保存.如果值的類型Reader就不會被保存和UnStored一樣. |
?
最后將每一個Document添加到索引當中.
需要注意的是索引不僅可以建立在文件系統上,也可以建立在內存中.
例如
IndexWriter writer = new IndexWriter("index", new StandardAnalyzer(), true);
在第一個參數不是指定文件夾的名字而是使用Directory對象,并使用它的子類RAMDirectory,就可以將索引建立在內存當中.
?
2.???????對索引進行搜索
IndexSearcher indexSearcher= new IndexSearcher(indexDir);
Query query = QueryParser.Parse(queryString, "contents",new StandardAnalyzer());
Hits hits = indexSearcher.Search(query);
?第一步利用IndexSearcher打開索引文件用于后面搜索,其中的參數是索引文件的路徑.
第二步使用QueryParser將可讀性較好的查詢語句(比如查詢的詞lucene ,以及一些高級方式lucene AND .net)轉化為Lucene內部使用的查詢對象.
第三步執行搜索.并將結果返回到hits集合.需要注意的是Lucene并不是一次將所有的結果放入hits中而是采取一次放一部分的方式.出于空間考慮.
| 作者 idior ?? |
| 2005-03-16 22:36 |
| ? |
Lucene.net?系列二?--- index?上一詳細介紹了有關Lucene.net索引添加刪除更新的詳細內容.并給出了所有的TestCase供學習參考.
Lucene建立Index的過程:
1.???????抽取文本.
?? 比如將PDF以及Word中的內容以純文本的形式提取出來.Lucene所支持的類型主要為String,為了方便同時也支持Date 以及Reader.其實如果使用這兩個類型lucene會自動進行類型轉換.
2.???????文本分析.
?? Lucene將針對所給的文本進行一些最基本的分析,并從中去除一些不必要的信息,比如一些常用字a ,an, the 等等,如果搜索的時候不在乎字母的大小寫, 又可以去掉一些不必要的信息.總而言之你可以把這個過程想象成一個文本的過濾器,所有的文本內容通過分析, 將過濾掉一些內容,剩下最有用的信息.
3.???????寫入index.
和google等常用的索引技術一樣lucene在寫index的時候都是采用的倒排索引技術(inverted index.) 簡而言之,就是通過某種方法(類似hash表?)將常見的”一篇文檔中含有哪些詞”這個問題轉成”哪篇文檔中有這些詞”. 而各個搜索引擎的索引機制的不同主要在于如何為這張倒排表添加更準確的描述.比如google有名的PageRank因素.Lucene當然也有自己的技術,希望在以后的文章中能為大家加以介紹.
在上一篇文章中,使用了最基本的建立索引的方法.在這里將對某些問題加以詳細的討論.
1. 添加Document至索引
上次添加的每份文檔的信息是一樣的,都是文檔的filename和contents.
doc.Add(Field.Keyword("filename", file.FullName));
doc.Add(Field.Text("contents", new StreamReader(file.FullName)));
在Lucene中對每個文檔的描述是可以不同的,比如,兩份文檔都是描述一個人,其中一個添加的是name, age 另一個添加的是id, sex ,這種不規則的文檔描述在Lucene中是允許的.
還有一點Lucene支持對Field進行Append , 如下:
string baseWord = "fast";
string synonyms[] = String {"quick", "rapid", "speedy"};
Document doc = new Document();
doc.Add(Field.Text("word", baseWord));
for (int i = 0; i < synonyms.length; i++)?
??? doc.Add(Field.Text("word", synonyms[i]));
這點純粹是為了方便用戶的使用.在內部Lucene自動做了轉化,效果和將它們拼接好再存是一樣.
2.?刪除索引中的文檔
??? 這一點Lucene所采取的方式比較怪,它使用IndexReader來對要刪除的項進行標記,然后在Reader Close的時候一起刪除.
這里簡要介紹幾個方法.
[TestFixture]
public class DocumentDeleteTest : BaseIndexingTestCase?? // BaseIndexingTestCase中的SetUp方法?????????? ??????????????????????????????????? //建立了索引其中加入了兩個Document
{
??? [Test]
??? public void testDeleteBeforeIndexMerge()
??? {
????
??????? IndexReader reader = IndexReader.Open(dir);? //當前索引中有兩個Document
??????? Assert.AreEqual(2, reader.MaxDoc());?? //文檔從0開始計數,MaxDoc表示下一個文檔的序號
??????? Assert.AreEqual(2, reader.NumDocs());? //NumDocs表示當前索引中文檔的個數??
??????? reader.Delete(1);?????????????????? //對標號為1的文檔標記為待刪除,邏輯刪除
??????? Assert.IsTrue(reader.IsDeleted(1));???????? //檢測某個序號的文檔是否被標記刪除
??????? Assert.IsTrue(reader.HasDeletions());?????? //檢測索引中是否有Document被標記刪除?
??????? Assert.AreEqual(2, reader.MaxDoc());??????? //當前下一個文檔序號仍然為2
??????? Assert.AreEqual(1, reader.NumDocs());?????? //當前索引中文檔數變成1
??????? reader.Close();???????????????????????????? //此時真正從物理上刪除之前被標記的文檔
??????? reader = IndexReader.Open(dir);
??????? Assert.AreEqual(2, reader.MaxDoc());?????????
??????? Assert.AreEqual(1, reader.NumDocs());
??????? reader.Close();
??? }
??? [Test]
??? public void DeleteAfterIndexMerge()????//在索引重排之后
??? {
??????? IndexReader reader = IndexReader.Open(dir);
??????? Assert.AreEqual(2, reader.MaxDoc());
??????? Assert.AreEqual(2, reader.NumDocs());
??????? reader.Delete(1);
??????? reader.Close();
??????? IndexWriter writer = new IndexWriter(dir, GetAnalyzer(), false);
??????? writer.Optimize();?????????????????//索引重排
??????? writer.Close();
??????? reader = IndexReader.Open(dir);
??????? Assert.IsFalse(reader.IsDeleted(1));
??????? Assert.IsFalse(reader.HasDeletions());
??????? Assert.AreEqual(1, reader.MaxDoc());?????? //索引重排后,下一個文檔序號變為1
??????? Assert.AreEqual(1, reader.NumDocs());
??????? reader.Close();
??? }
}
當然你也可以不通過文檔序號進行刪除工作.采用下面的方法,可以從索引中刪除包含特定的內容文檔.
IndexReader reader = IndexReader.Open(dir);
reader.Delete(new Term("city", "Amsterdam"));
reader.Close();
你還可以通過reader.UndeleteAll()這個方法取消前面所做的標記,即在read.Close()調用之前取消所有的刪除工作
3.?更新索引中的文檔
?? 這個功能Lucene沒有支持, 只有通過刪除后在添加來實現. 看看代碼,很好理解的.
[TestFixture]
public class DocumentUpdateTest : BaseIndexingTestCase
{
??? [Test]
??? public void Update()
??? {
??????? Assert.AreEqual(1, GetHitCount("city", "Amsterdam"));
??????? IndexReader reader = IndexReader.Open(dir);
??????? reader.Delete(new Term("city", "Amsterdam"));
??????? reader.Close();
??????? Assert.AreEqual(0, GetHitCount("city", "Amsterdam"));
??????? IndexWriter writer = new IndexWriter(dir, GetAnalyzer(),false);
??????? Document doc = new Document();
??????? doc.Add(Field.Keyword("id", "1"));
?
??????? doc.Add(Field.UnIndexed("country", "Netherlands"));
??????? doc.Add(Field.UnStored("contents","Amsterdam has lots of bridges"));
??????? doc.Add(Field.Text("city", "Haag"));
??????? writer.AddDocument(doc);
??????? writer.Optimize();
??????? writer.Close();
??????? Assert.AreEqual(1, GetHitCount("city", "Haag"));
??? }
?
??? protected override Analyzer GetAnalyzer()
??? {
??????? return new WhitespaceAnalyzer();? //注意此處如果用SimpleAnalyzer搜索會失敗,因為建立索引的時候使用的SimpleAnalyse它會將所有字母變成小寫.
??? }
??? private int GetHitCount(String fieldName, String searchString)
??? {
??????? IndexSearcher searcher = new IndexSearcher(dir);
??????? Term t = new Term(fieldName, searchString);
??????? Query query = new TermQuery(t);
??????? Hits hits = searcher.Search(query);
??????? int hitCount = hits.Length();
??????? searcher.Close();
??????? return hitCount;
??? }
}
??? 需要注意的是以上所有有關索引的操作,為了避免頻繁的打開和關閉Writer和Reader.又由于添加和刪除是不同的連接(Writer, Reader)做的.所以應該盡可能的將添加文檔的操作放在一起批量執行,然后將刪除文檔的操作也放在一起批量執行.避免添加刪除交替進行.
Lucene.net?系列三?--- index?中本文將進一步討論有關Lucene.net建立索引的問題:
主要包含以下主題:
1.索引的權重
2.利用IndexWriter 屬性對建立索引進行高級管理
3.利用RAMDirectory充分發揮內存的優勢
4.利用RAMDirectory并行建立索引
5.控制索引內容的長度
6.Optimize 優化的是什么?
源代碼下載
本文將進一步討論有關Lucene.net建立索引的問題:
索引的權重
根據文檔的重要性的不同,顯然對于某些文檔你希望提高權重以便將來搜索的時候,更符合你想要的結果. 下面的代碼演示了如何提高符合某些條件的文檔的權重.
比如對公司內很多的郵件做了索引,你當然希望主要查看和公司有關的郵件,而不是員工的個人郵件.這點根據郵件的地址就可以做出判斷比如包含@alphatom.com的就是公司郵件,而@gmail.com等等就是私人郵件.如何提高相應郵件的權重? 代碼如下:
???? public static? String COMPANY_DOMAIN = "alphatom.com";
???? Document doc = new Document();
???? String senderEmail = GetSenderEmail();
???? String senderName = getSenderName();
???? String subject = GetSubject();
???? String body = GetBody();
???? doc.Add(Field.Keyword("senderEmail”, senderEmail));
???? doc.Add(Field.Text("senderName", senderName));
???? doc.Add(Field.Text("subject", subject));
???? doc.Add(Field.UnStored("body", body));
???? if (GetSenderDomain().EndsWith(COMPANY_DOMAIN))?
????//如果是公司郵件,提高權重,默認權重是1.0
???? ????? doc.SetBoost(1.5);???????????????????????
???? else?????????????????????????//如果是私人郵件,降低權重.
???? ????? doc.SetBoost(0.1);
???? writer.AddDocument(doc);
不僅如此你還可以對Field也設置權重.比如你對郵件的主題更感興趣.就可以提高它的權重.???
????Field senderNameField = Field.Text("senderName", senderName);
???? Field subjectField = Field.Text("subject", subject);
???? subjectField.SetBoost(1.2);
lucene搜索的時候會對符合條件的文檔按匹配的程度打分,這點就和google的PageRank有點類似, 而SetBoost中的Boost就是其中的一個因素,當然還有其他的因素.這要放到搜索里再說.
利用IndexWriter?變量對建立索引進行高級管理
在建立索引的時候對性能影響最大的地方就是在將索引寫入文件的時候, 所以在具體應用的時候就需要對此加以控制.
在建立索引的時候對性能影響最大的地方就是在將索引寫入文件的時候所以在具體應用的時候就需要對此加以控制
| IndexWriter屬性?? | 默認值 | 描述 |
| MergeFactory | 10 | 控制segment合并的頻率和大小 |
| MaxMergeDocs | Int32.MaxValue | 限制每個segment中包含的文檔數 |
| MinMergeDocs | 10 | 當內存中的文檔達到多少的時候再寫入segment |
?
Lucene默認情況是每加入10份文檔就從內存往index文件寫入并生成一個segement,然后每10個segment就合并成一個segment.通過MergeFactory這個變量就可以對此進行控制.
MaxMergeDocs用于控制一個segment文件中最多包含的Document數.比如限制為100的話,即使當前有10個segment也不會合并,因為合并后的segmnet將包含1000個文檔,超過了限制.
MinMergeDocs用于確定一個當內存中文檔達到多少的時候才寫入文件,該項對segment的數量和大小不會有什么影響,它僅僅影響內存的使用,進一步影響寫索引的效率.
為了生動的體現這些變量對性能的影響,用一個小程序對此做了說明.
這里有點不可思議.Lucene in Action書上的結果比我用dotLucene做的結果快了近千倍.這里給出書中用Lucene的數據,希望大家比較一下看看是不是我的問題.
Lucene in Action書中的數據:
% java lia.indexing.IndexTuningDemo 100000 10 9999999 10
Merge factor: 10
Max merge docs: 9999999
Min merge docs: 10
Time: 74136 ms
% java lia.indexing.IndexTuningDemo 100000 100 9999999 10
Merge factor: 100
Max merge docs: 9999999
Min merge docs: 10
Time: 68307 ms
我的數據: 336684128 ms
可以看出MinMergeDocs(主要用于控制內存)和MergeFactory(控制合并的次數和合并后的大小) 對建立索引有顯著的影響.但是并不是MergeFactory越大越好,因為如果一個segment的文檔數很多的話,在搜索的時候必然也會影響效率,所以這里MergeFactory的取值是一個需要平衡的問題.而MinMergeDocs主要受限于內存.
利用RAMDirectory充分發揮內存的優勢
從上面來看充分利用內存的空間,減少讀寫文件(寫入index)的次數是優化建立索引的重要方法.其實在Lucene中提供了更強大的方法來利用內存建立索引.使用RAMDirectory來替代FSDirectory. 這時所有的索引都將建立在內存當中,這種方法對于數據量小的搜索業務很有幫助,同時可以使用它來進行一些小的測試,避免在測試時頻繁建立刪除索引文件.
在實際應用中RAMDirectory和FSDirectory協作可以更好的利用內存來優化建立索引的時間.
具體方法如下:
1.建立一個使用FSDirectory的IndexWriter
2 .建立一個使用RAMDirectory的IndexWriter
3 把Document添加到RAMDirectory中
4 當達到某種條件將RAMDirectory 中的Document寫入FSDirectory.
5 重復第三步
示意代碼:
???? ?private FSDirectory fsDir = FSDirectory.GetDirectory("index",true);?
? ??? ?private RAMDirectory ramDir = new RAMDirectory();
?????? private IndexWriter fsWriter = IndexWriter(fsDir,new SimpleAnalyzer(), true);
?????? private IndexWriter ramWriter = new IndexWriter(ramDir,new SimpleAnalyzer(), true);
?????? while (there are documents to index)?
????? {
???????? ramWriter.addDocument(doc);
???????? if (condition for flushing memory to disk has been met)?
???????? {
?????????? fsWriter.AddIndexes(Directory[]{ramDir}) ;
?????????? ramWriter.Close();????????? //why not support flush?
?????????? ramWriter =new IndexWriter(ramDir,new SimpleAnalyzer(),true);
???????? }
???? }
這里的條件完全由用戶控制,而不是FSDirectory采用對Document計數的方式控制何時寫入文件.相比之下有更大的自由性,更能提升性能.
利用RAMDirectory并行建立索引
RAMDirectory還提供了使用多線程來建立索引的可能性.下面這副圖很好的說明了這一點.
?
?
甚至你可以在一個高速的網絡里使用多臺計算機來同時建立索引.就像下面這種圖所示.
?
?
雖然有關并行同步的問題需要你自己進行處理,不過通過這種方式可以大大提高對大量數據建立索引的能力.
?
控制索引內容的長度.
在我的一篇速遞介紹過Google Desktop Search只能搜索到文本中第5000個字的.也就是google在建立索引的時候只考慮前5000個字,在Lucene中同樣也有這個配置功能.
Lucene對一份文本建立索引時默認的索引長度是10,000. 你可以通過IndexWriter 的MaxFieldLength屬性對此加以修改.還是用一個例子說明問題.?
???? [Test]
???? public void FieldSize()???????
???? // AddDocuments 和 GetHitCount都是自定義的方法,詳見源代碼
???? {
???????? AddDocuments(dir, 10);???????
???????? //第一個參數是目錄,第二個配置是索引的長度
???????? Assert.AreEqual(1, GetHitCount("contents", "bridges"))
???????? //原文檔的contents為”Amsterdam has lots of bridges”
???????? //當索引長度為10個字時能找到bridge
???????? AddDocuments(dir, 1);
???????? Assert.AreEqual(0, GetHitCount("contents", "bridges"));
???????? //當索引長度限制為1個字時就無法發現第5個字bridges
???? }
???對索引內容限長往往是處于效率和空間大小的考慮.能夠對此進行配置是建立索引必備的一個功能.
Optimize?優化的是什么?
在以前的例子里,你可能已經多次見過writer.Optimize()這段代碼.Optimize到底做了什么?
讓你吃驚的是這里的優化對于建立索引不僅沒有起到加速的作用,反而是延長了建立索引的時間.為什么?
因為這里的優化不是為建立索引做的,而是為搜索做的.之前我們提到Lucene默認每遇到10個Segment就合并一次,盡管如此在索引完成后仍然會留下幾個segmnets,比如6,7.
而Optimize的過程就是要減少剩下的Segment的數量,盡量讓它們處于一個文件中.
它的過程很簡單,就是新建一個空的Segmnet,然后把原來的幾個segmnet全合并到這一個segmnet中,在此過程中,你的硬盤空間會變大,因為同時存在兩份一樣大小的索引.不過在優化完成后,Lucene會自動將原來的多份Segments刪除,只保留最后生成的一份包含原來所有索引的segment.
盡量減少segments的個數主要是為了增加查詢的效率.假設你有一個Server,同時有很多的Client建立了各自不同的索引,如果此時搜索,那么必然要同時打開很多的索引文件,這樣顯然會受到很大的限制,對性能產生影響.
當然也不是隨時做Optimize就好,如前所述做優化時要花費更多的時間和空間,而且在做優化的時候是不能進行查詢的.所以索引建立的后期,并且索引的內容不會再發生太多的變化的時候做優化是一個比較好的時段.
?
Lucene.net?系列四?--- index?下本文將介紹有關索引并發控制的問題,以結束對Lucene.net建立索引問題的討論.
1. 允許任意多的讀操作并發.即可以有任意多的用戶在同一時間對同一份索引做查詢工作.
2.?允許任意多的讀操作在索引被正在被修改的時候進行.即哪怕索引正在被優化,添加刪除文檔,這時也是允許用戶對索引進行查詢工作. (it’s so cool.)
3.?同一時間只允許一個對索引修改的操作.即同一時間只允許IndexWriter或IndexReader打開同一份索引.不能允許兩個同時打開一份索引.
Lucene提供了幾種對索引進行讀寫的操作.添加文檔到索引,從索引中刪除文檔,優化索引,合并Segments.這些都是對索引進行寫操作的方法. 查詢的時候就會讀取索引的內容.
有關索引并發的問題是一個比較重要的問題,而且是Lucene的初學者容易忽略的問題,當索引被破壞,或者程序突然出現異常的時候初學者往往不知道是自己的誤操作造成的.
下面讓我們看看Lucene是如何處理索引文件的并發控制的.
首先記住一下三點準則:
1. 允許任意多的讀操作并發.即可以有任意多的用戶在同一時間對同一份索引做查詢工作.
2.?允許任意多的讀操作在索引被正在被修改的時候進行.即哪怕索引正在被優化,添加刪除文檔,這時也是允許用戶對索引進行查詢工作. (it’s so cool.)
3.?同一時間只允許一個對索引修改的操作.即同一時間只允許IndexWriter或IndexReader打開同一份索引.不能允許兩個同時打開一份索引.
第一個準則很容易理解,第二個準則說明Lucene對并發的操作支持還是不錯的.第三個準則也很正常,不過需要注意的是第三個準則只是表明IndexWriter和IndexReader不能并存,而沒有反對在多線程中利用同一個IndexWriter對索引進行修改.這個功能可是經常用到的,所以不要以為它是不允許的.不過這個時候的并發就需要你自己加以控制,以免出現沖突.
(注: 在前面的系列中已說過IndexReader不是對Index進行讀操作,而是從索引中刪除docuemnt時使用的對象)
有關這三個原則在實際使用Lucene API時候的體現,讓我們先看看下面這張表:
表中列出了有關索引的主要讀寫操作.其中空白處表示X軸的操作和Y軸的操作允許并發.
而X處表明X軸的操作和Y軸的操作不允許同時進行.
比如Add document到索引的時候不允許同時從索引中刪除document.
其實以上這張表就是前面三個準則的體現.Add Optimize Merge操作都是由IndexWriter來做的.而Delete則是通過IndexReader完成.所以表中空白處正是第一條和第二條準則的體現,而X(沖突)處正是第三個原則的具體表現.
?
為了在不了解并發控制的情況下對Lucene API的亂用. Lucene提供了基于文件的鎖機制以確保索引文件不會被破壞.
當你對index 進行修改的時候, 比如添加刪除文檔的時候就會產生 ***write.lock文件,而當你從segment進行讀取信息或者合并segments的時候就會產生***commit.lock文件.在默認情況下,這些文件是放在系統臨時文件夾下的. 簡而言之, write.lock文件存在的時間比較長,也就是對index進行修改的鎖時間比較長,而commit.lock存在的時間往往很短.具體情況見下表.
?
如果索引存在于server, 很多clients想訪問的時候,自然希望能看到其他用戶的鎖文件,這時把鎖文件放到系統臨時文件夾就不好了.此時可以通過配置文件來改變鎖文件存放的位置.
比如在一個asp.net的應用下,你就可以象下面這樣利用web.config文件來實現你的目的.
<configuration>
??? <appSettings>
??????? <add key="Lucene.Net.lockdir" value="c:yourdir" />
??? </appSettings>
</configuration>
不僅如此,在某些情況下比如你的索引文件存放在一個CD-ROM中,這時根本就無法對索引進行修改,也就不存在所謂的并發沖突,這種情況下你甚至可以講鎖文件的機制取消掉.同樣通過配置文件.
<configuration>
??? <appSettings>
??????? <add key="disableLuceneLocks" value="true" />
??? </appSettings>
</configuration>
不過請注意不要亂用此功能,不然你的索引文件將不再受到安全的保護.
下面用一個例子說明鎖機制的體現.
using System;
using System.IO;
using Lucene.Net.Analysis;
using Lucene.Net.Index;
using Lucene.Net.Store;
using NUnit.Framework;
using Directory = Lucene.Net.Store.Directory;
[TestFixture]
public class LockTest
{
?private Directory dir;
?[SetUp]
?public void Init()
?{
? String indexDir = "index";
? dir = FSDirectory.GetDirectory(indexDir, true);
?}
?[Test]
?[ExpectedException(typeof(IOException))]
?public void WriteLock()
?{
? IndexWriter writer1 = null;
? IndexWriter writer2 = null;
? try
? {
?? writer1 = new IndexWriter(dir, new SimpleAnalyzer(), true);
?? writer2 = new IndexWriter(dir, new SimpleAnalyzer(), true);
???
? }
? catch (IOException e)
? {
?? Console.Out.WriteLine(e.StackTrace);
? }
? finally
? {
?? writer1.Close();
?? Assert.IsNull(writer2);
? }
?}
?[Test]
?public void CommitLock()
?{
? IndexReader reader1 = null;
? IndexReader reader2 = null;
? try
? {
?? IndexWriter writer = new IndexWriter(dir, new SimpleAnalyzer(),
??????????????????????????????????????? true);
?? writer.Close();
?? reader1 = IndexReader.Open(dir);
?? reader2 = IndexReader.Open(dir);
? }
? finally
? {
?? reader1.Close();
?? reader2.Close();
? }
?}
}
不過很令人失望的是在Lucene(Java)中應該收到的異常在dotLucene(1.4.3)我卻沒有捕獲到.隨后我在dotLucene的論壇上問了一下,至今尚未有解答.這也是開源項目的無奈了吧.
?
?
Lucene.net?系列五?--- search?上在前面的系列我們一直在介紹有關索引建立的問題,現在是該利用這些索引來進行搜索的時候了,Lucene良好的架構使得我們只需要很少的幾行代碼就可以為我們的應用加上搜索的功能,首先讓我們來認識一下搜索時最常用的幾個類.
查詢特定的某個概念
當我們搜索完成的時候會返回一個按Sorce排序的結果集Hits. 這里的Score就是接近度的意思,象Google那樣每個頁面都會有一個分值,搜索結果按分值排列. 如同你使用Google一樣,你不可能查看所有的結果, 你可能只查看第一個結果所以Hits返回的不是所有的匹配文檔本身, 而僅僅是實際文檔的引用. 通過這個引用你可以獲得實際的文檔.原因很好理解, 如果直接返回匹配文檔,數據量太大,而很多的結果你甚至不會去看, 想想你會去看Google 搜索結果10頁以后的內容嗎?
下面用一個例子來簡要介紹一下Search
先建立索引
namespace dotLucene.inAction.BasicSearch
{
???? [TestFixture]
???? public class BaseIndexingTestCase
???? {
???????? protected String[] keywords = {"1930110994", "1930110995"};
???????? protected String[] unindexed = {"Java Development with Ant", "JUnit in Action"};
???????? protected String[] unstored = {
????????????? "we have ant and junit",
????????????? "junit use a mock,ant is also",
???????? };
???????? protected String[] text1 = {
???????? ???? "ant junit",
????????????? "junit mock"
???????? };
???????? protected String[] text2 = {
????????????? "200206",
????????????? "200309"
???????? };
???????? protected String[] text3 = {
????????????? "/Computers/Ant", "/Computers/JUnit"
???????? };
???????? protected Directory dir;
???????? [SetUp]
???????? protected void Init()
???????? {
????????????? string indexDir = "index";
????????????? dir = FSDirectory.GetDirectory(indexDir, true);
????????????? AddDocuments(dir);
???????? }
???????? protected void AddDocuments(Directory dir)
???????? {
????????????? IndexWriter writer=new IndexWriter(dir, GetAnalyzer(), true);
????????????? for (int i = 0; i < keywords.Length; i++)
????????????? {
?????????????????? Document doc = new Document();
?????????????????? doc.Add(Field.Keyword("isbn", keywords[i]));
?????????????????? doc.Add(Field.UnIndexed("title", unindexed[i]));
?????????????????? doc.Add(Field.UnStored("contents", unstored[i]));
?????????????????? doc.Add(Field.Text("subject", text1[i]));
?????????????????? doc.Add(Field.Text("pubmonth", text2[i]));
?????????????????? doc.Add(Field.Text("category", text3[i]));
?????????????????? writer.AddDocument(doc);
????????????? }
?????????
????????????? writer.Optimize();
????????????? writer.Close();
???????? }
???????? protected virtual Analyzer GetAnalyzer()
???????? {
????????????? PerFieldAnalyzerWrapper analyzer = new PerFieldAnalyzerWrapper(
?????????????????? new SimpleAnalyzer());
????????????? analyzer.AddAnalyzer("pubmonth", new WhitespaceAnalyzer());
????????????? analyzer.AddAnalyzer("category", new WhitespaceAnalyzer());
????????????? return analyzer;
???????? }
???? }
}
這里用到了一些有關Analyzer的知識,將放在以后的系列中介紹.
查詢特定的某個概念
然后利用利用TermQery來搜索一個Term(你可以把它理解為一個Word)
?????????[Test]
???????? public void Term()
???????? {
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Term t = new Term("subject", "ant");
????????????? Query query = new TermQuery(t);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length(), "JDwA");
????????????? t = new Term("subject", "junit");
????????????? hits = searcher.Search(new TermQuery(t));
????????????? Assert.AreEqual(2, hits.Length());
????????????? searcher.Close();
???????? }
利用QueryParse簡化查詢語句
顯然對于各種各樣的查詢(與或關系,等等各種復雜的查詢,在下面將介紹),你不希望一一對應的為它們寫出相應的XXXQuery. Lucene已經為你考慮到了這點, 通過使用QueryParse這個類, 你只需要寫出我們常見的搜索語句, Lucene會在內部自動做一個轉換.
這個過程有點類似于數據庫搜索, 我們已經習慣于使用SQL查詢語句,其實在數據庫的內部是要做一個轉換的, 因為數據庫不認得SQL語句,它只認得查詢語法樹.
讓我們來看一個例子.
???????? [Test]
???????? public void TestQueryParser()
???????? {
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Query query = QueryParser.Parse("+JUNIT +ANT -MOCK",
????????????? ??????????????????????????????? "contents",
????????????? ??????????????????????????????? new SimpleAnalyzer());
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length());
????????????? Document d = hits.Doc(0);
????????????? Assert.AreEqual("Java Development with Ant", d.Get("title"));
????????????? query = QueryParser.Parse("mock OR junit",
????????????? ????????????????????????? "contents",
????????????? ????????????????????????? new SimpleAnalyzer());
????????????? hits = searcher.Search(query);
????????????? Assert.AreEqual(2, hits.Length(), "JDwA and JIA");
???????? }
由以上的代碼可以看出我們不需要為每種特定查詢而去設定XXXQuery 通過QueryParse類的靜態方法Parse就可以很方便的將可讀性好的查詢口語轉換成Lucene內部所使用的各種復雜的查詢語句. 有一點需要注意:在Parse方法中我們使用了SimpleAnalyzer, 這時候會將查詢語句做一些變換,比如這里將JUNIT 等等大寫字母變成了小寫字母,所以才能搜索到(因為我們在建立索引的時候使用的是小寫),如果你將StanderAnalyzer變成WhitespaceAnalyzer就會搜索不到.具體原理以后再說.
+A +B表示A和B要同時存在,-C表示C不存在,A OR B表示A或B二者有一個存在就可以..具體的查詢規則如下:
?
其中title等等的field表示你在建立索引時所采用的屬性名.
?
?
Lucene.net系列六?-- search?下本文主要結合測試案例介紹了Lucene下的各種查詢語句以及它們的簡化方法.
通過本文你將了解Lucene的基本查詢語句,并可以學習所有的測試代碼已加強了解.
源代碼下載
具體的查詢語句
在了解了SQL后, 你是否想了解一下查詢語法樹?在這里簡要介紹一些能被Lucene直接使用的查詢語句.
1.???????? TermQuery
查詢某個特定的詞,在文章開始的例子中已有介紹.常用于查詢關鍵字.
???????????? [Test]
???????? public void Keyword()
???????? {
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Term t = new Term("isbn", "1930110995");
????????????? Query query = new TermQuery(t);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length(), "JUnit in Action");
???????? }
注意Lucene中的關鍵字,是需要用戶去保證唯一性的.
?TermQuery和QueryParse
只要在QueryParse的Parse方法中只有一個word,就會自動轉換成TermQuery.
2.???????? RangeQuery
用于查詢范圍,通常用于時間,還是來看例子:
namespace dotLucene.inAction.BasicSearch
{
???? public class RangeQueryTest : LiaTestCase
???? {
???????? private Term begin, end;
???????? [SetUp]
???????? protected override void Init()
???????? {
????????????? begin = new Term("pubmonth", "200004");
????????????? end = new Term("pubmonth", "200206");
????????????? base.Init();
???????? }
???????? [Test]
???????? public void Inclusive()
???????? {
????????????? RangeQuery query = new RangeQuery(begin, end, true);
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length());
???????? }
???????? [Test]
???????? public void Exclusive()
???????? {
????????????? RangeQuery query = new RangeQuery(begin, end, false);
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(0, hits.Length());
???????? }
???? }
}
RangeQuery的第三個參數用于表示是否包含該起止日期.
RangeQuery和QueryParse
????????????? [Test]
???????? public void TestQueryParser()
???????? {
????????????? Query query = QueryParser.Parse("pubmonth:[200004 TO 200206]", "subject", new SimpleAnalyzer());
????????????? Assert.IsTrue(query is RangeQuery);
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? query = QueryParser.Parse("{200004 TO 200206}", "pubmonth", new SimpleAnalyzer());
????????????? hits = searcher.Search(query);
????????????? Assert.AreEqual(0, hits.Length(), "JDwA in 200206");
???????? }
Lucene用[] 和{}分別表示包含和不包含.
3.???PrefixQuery
用于搜索是否包含某個特定前綴,常用于Catalog的檢索.
???????????[Test]
???????? public? void? TestPrefixQuery()
???????? {
????????????? PrefixQuery query = new PrefixQuery(new Term("category", "/Computers"));
???????????? ?IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(2, hits.Length());
??????????????
????????????? query = new PrefixQuery(new Term("category", "/Computers/JUnit"));
????????????? hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length(), "JUnit in Action");
???????? }
PrefixQuery和QueryParse
??????????? ??[Test]
???????? public void TestQueryParser()
???????? {
????????????? QueryParser qp = new QueryParser("category", new SimpleAnalyzer());
????????????? qp.SetLowercaseWildcardTerms(false);
????????????? Query query =qp.Parse("/Computers*");
????????????? Console.Out.WriteLine("query = {0}", query.ToString());
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(2, hits.Length());
????????????? query =qp.Parse("/Computers/JUnit*");
????????????? hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length(), "JUnit in Action");
???????? }
這里需要注意的是我們使用了QueryParser對象,而不是QueryParser類. 原因在于使用對象可以對QueryParser的一些默認屬性進行修改.比如在上面的例子中我們的category是大寫的,而QueryParser默認會把所有的含*的查詢字符串變成小寫/computer*. 這樣我們就會查不到原文中的/Computers* ,所以我們需要通過設置QueryParser的默認屬性來改變這一默認選項.即qp.SetLowercaseWildcardTerms(false)所做的工作.
4.????BooleanQuery
用于測試滿足多個條件.
下面兩個例子用于分別測試了滿足與條件和或條件的情況.
???????? [Test]
???????? public void And()
???????? {
????????????? TermQuery searchingBooks =
?????????????????? new TermQuery(new Term("subject", "junit"));
????????????? RangeQuery currentBooks =
?????????????????? new RangeQuery(new Term("pubmonth", "200301"),
?????????????????? ?????????????? new Term("pubmonth", "200312"),
?????????????????? ?????????????? true);
????????????? BooleanQuery currentSearchingBooks = new BooleanQuery();
????????????? currentSearchingBooks.Add(searchingBooks, true, false);
????????????? currentSearchingBooks.Add(currentBooks, true, false);
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(currentSearchingBooks);
????????????? AssertHitsIncludeTitle(hits, "JUnit in Action");
???????? }
???????? [Test]
???????? public void Or()
???????? {
????????????? TermQuery methodologyBooks = new TermQuery(
?????????????????? new Term("category",
?????????????????? ???????? "/Computers/JUnit"));
????????????? TermQuery easternPhilosophyBooks = new TermQuery(
?????????????????? new Term("category",
?????????????????? ???????? "/Computers/Ant"));
????????????? BooleanQuery enlightenmentBooks = new BooleanQuery();
????????????? enlightenmentBooks.Add(methodologyBooks, false, false);
????????????? enlightenmentBooks.Add(easternPhilosophyBooks, false, false);
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(enlightenmentBooks);
????????????? Console.Out.WriteLine("or = " + enlightenmentBooks);
????????????? AssertHitsIncludeTitle(hits, "Java Development with Ant");
????????????? AssertHitsIncludeTitle(hits, "JUnit in Action");
???????? }
什么時候是與什么時候又是或? 關鍵在于BooleanQuery對象的Add方法的參數.
參數一是待添加的查詢條件.
參數二Required表示這個條件必須滿足嗎? True表示必須滿足, False表示可以不滿足該條件.
參數三Prohibited表示這個條件必須拒絕嗎? True表示這么滿足這個條件的結果要排除, False表示可以滿足該條件.
這樣會有三種組合情況,如下表所示:
?
BooleanQuery和QueryParse
???????? [Test]
???????? public void TestQueryParser()
???????? {
????????????? Query query = QueryParser.Parse("pubmonth:[200301 TO 200312] AND junit", "subject", new SimpleAnalyzer());
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length());
????????????? query = QueryParser.Parse("/Computers/JUnit OR /Computers/Ant", "category", new WhitespaceAnalyzer());
????????????? hits = searcher.Search(query);
????????????? Assert.AreEqual(2, hits.Length());
???????? }
注意AND和OR的大小 如果想要A與非B 就用 A AND –B 表示, +A –B也可以.
默認的情況下QueryParser會把空格認為是或關系,就象google一樣.但是你可以通過QueryParser對象修改這一屬性.
[Test]
???????? public void TestQueryParserDefaultAND()
???????? {
????????????? QueryParser qp = new QueryParser("subject", new SimpleAnalyzer());
??????????? ??qp.SetOperator(QueryParser.DEFAULT_OPERATOR_AND );
????????????? Query query = qp.Parse("pubmonth:[200301 TO 200312] junit");
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(1, hits.Length());
???????? }
5.???????? PhraseQuery
查詢短語,這里面主要有一個slop的概念, 也就是各個詞之間的位移偏差, 這個值會影響到結果的評分.如果slop為0,當然最匹配.看看下面的例子就比較容易明白了,有關slop的計算用戶就不需要理解了,不過slop太大的時候對查詢效率是有影響的,所以在實際使用中要把該值設小一點.?PhraseQuery對于短語的順序是不管的,這點在查詢時除了提高命中率外,也會對性能產生很大的影響, 利用SpanNearQuery可以對短語的順序進行控制,提高性能.
????? ??[SetUp]
???? protected void Init()
???? {
???????? // set up sample document
???????? RAMDirectory directory = new RAMDirectory();
???????? IndexWriter writer = new IndexWriter(directory,
???????? ???????????????????????????????????? new WhitespaceAnalyzer(), true);
???????? Document doc = new Document();
???????? doc.Add(Field.Text("field",
???????? ?????????????????? "the quick brown fox jumped over the lazy dog"));
???????? writer.AddDocument(doc);
???????? writer.Close();
???????? searcher = new IndexSearcher(directory);
???? }
??????private bool matched(String[] phrase, int slop)
???? {
???????? PhraseQuery query = new PhraseQuery();
???????? query.SetSlop(slop);
???????? for (int i = 0; i < phrase.Length; i++)
???????? {
????????????? query.Add(new Term("field", phrase[i]));
???????? }
???????? Hits hits = searcher.Search(query);
???????? return hits.Length() > 0;
???? }
???? [Test]
???? public void SlopComparison()
???? {
???????? String[] phrase = new String[]{"quick", "fox"};
???????? Assert.IsFalse(matched(phrase, 0), "exact phrase not found");
???????? Assert.IsTrue(matched(phrase, 1), "close enough");
???? }
?????[Test]
???? public void Reverse()
???? {
???????? String[] phrase = new String[] {"fox", "quick"};
???????? Assert.IsFalse(matched(phrase, 2), "exact phrase not found");
???????? Assert.IsTrue(matched(phrase, 3), "close enough");
???? }
???? [Test]
???? public void Multiple()-
???? {
???????? Assert.IsFalse(matched(new String[] {"quick", "jumped", "lazy"}, 3), "not close enough");
???????? Assert.IsTrue(matched(new String[] {"quick", "jumped", "lazy"}, 4), "just enough");
???????? Assert.IsFalse(matched(new String[] {"lazy", "jumped", "quick"}, 7), "almost but not quite");
???????? Assert.IsTrue(matched(new String[] {"lazy", "jumped", "quick"}, 8), "bingo");
???? }
PhraseQuery和QueryParse
利用QueryParse進行短語查詢的時候要先設定slop的值,有兩種方式如下所示
[Test]
???? public void TestQueryParser()
???? {
???????? Query q1 = QueryParser.Parse(""quick fox"",
????????????? "field", new SimpleAnalyzer());
???????? Hits hits1 = searcher.Search(q1);
???????? Assert.AreEqual(hits1.Length(), 0);
???????? Query q2 = QueryParser.Parse(""quick fox"~1",????????? //第一種方式
???????? ??????????????????????????? "field", new SimpleAnalyzer());
???????? Hits hits2 = searcher.Search(q2);
???????? Assert.AreEqual(hits2.Length(), 1);
???????? QueryParser qp = new QueryParser("field", new SimpleAnalyzer());
???????? qp.SetPhraseSlop(1);??????????????????????????????????? //第二種方式
???????? Query q3=qp.Parse(""quick fox"");
???????? Assert.AreEqual(""quick fox"~1", q3.ToString("field"),"sloppy, implicitly");
???????? Hits hits3 = searcher.Search(q2);
???????? Assert.AreEqual(hits3.Length(), 1);
???? }
6.???????? WildcardQuery
通配符搜索,需要注意的是child, mildew的分值是一樣的.?
???????? [Test]
???????? public void Wildcard()
???????? {
????????????? IndexSingleFieldDocs(new Field[]
?????????????????? {
?????????????????????? Field.Text("contents", "wild"),
?????????????????????? Field.Text("contents", "child"),
?????????????????????? Field.Text("contents", "mild"),
?????????????????????? Field.Text("contents", "mildew")
?????????????????? });
????????????? IndexSearcher searcher = new IndexSearcher(directory);
????????????? Query query = new WildcardQuery(
?????????????????? new Term("contents", "?ild*"));
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(3, hits.Length(), "child no match");
????????????? Assert.AreEqual(hits.Score(0), hits.Score(1), 0.0, "score the same");
????????????? Assert.AreEqual(hits.Score(1), hits.Score(2), 0.0, "score the same");
???????? }
WildcardQuery和QueryParse
需要注意的是出于性能的考慮使用QueryParse的時候,不允許在開頭就使用就使用通配符.
同樣處于性能考慮會將只在末尾含有*的查詢詞轉換為PrefixQuery.
???????? [Test, ExpectedException(typeof (ParseException))]
???????? public void TestQueryParserException()
???????? {
????????????? Query query = QueryParser.Parse("?ild*", "contents", new WhitespaceAnalyzer());
???????? }
???????? [Test]
???????? public void TestQueryParserTailAsterrisk()
???????? {
????????????? Query query = QueryParser.Parse("mild*", "contents", new WhitespaceAnalyzer());
????????????? Assert.IsTrue(query is PrefixQuery);
????????????? Assert.IsFalse(query is WildcardQuery);
???????? }
???????? [Test]
???????? public void TestQueryParser()
???????? {
????????????? Query query = QueryParser.Parse("mi?d*", "contents", new WhitespaceAnalyzer());
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual(2, hits.Length());
???????? }
7.???????? FuzzyQuery
模糊查詢, 需要注意的是兩個匹配項的分值是不同的,這點和WildcardQuery是不同的
???????? [Test]
???????? public void Fuzzy()
???????? {
????????????? Query query = new FuzzyQuery(new Term("contents", "wuzza"));
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual( 2, hits.Length(),"both close enough");
????????????? Assert.IsTrue(hits.Score(0) != hits.Score(1),"wuzzy closer than fuzzy");
????????????? Assert.AreEqual("wuzzy", hits.Doc(0).Get("contents"),"wuzza bear");
???????? }
FuzzyQuery和QueryParse
注意和PhraseQuery中表示slop的區別,前者~后要跟數字.
???????? [Test]
???????? public void TestQueryParser()
???????? {
????????????? Query query =QueryParser.Parse("wuzza~","contents",new SimpleAnalyzer());
????????????? Hits hits = searcher.Search(query);
????????????? Assert.AreEqual( 2, hits.Length(),"both close enough");
???????? }
轉載于:https://www.cnblogs.com/huideng/p/3979307.html
總結
- 上一篇: DB2存储过程语法规则
- 下一篇: 翻转一个单列表