Lucene.Net初识(2)

 

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中的关键字,是需要用户去保证唯一性的.

 TermQueryQueryParse

只要在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的第三个参数用于表示是否包含该起止日期.

RangeQueryQueryParse

      [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");
         }

PrefixQueryQueryParse

[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表示可以满足该条件.

这样会有三种组合情况,如下表所示:

BooleanQueryQueryParse

         [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");
     }

PhraseQueryQueryParse

利用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");
         }

WildcardQueryQueryParse
需要注意的是出于性能的考虑使用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");
         }

 
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值