对Lucene的一点小总结
摘要:我在一本书里看到过这样一句话:Stanford 大学培养人才的目标是 ”T形人才“:精通某个领域,但对各个领域都有所涉猎。字母 “T“ 的一横代表宽度,一竖代表深度。具有T型的知识结构是成为企业家的基础。我不禁问自己:我的深度在哪里?宽度在哪里?
1、Lucene是什么?
1.1、什么是Lucene?
Lucene是apache软件基金会4 jakarta项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。Lucene是一套用于全文检索和搜寻的开源程式库,由Apache软件基金会支持和提供。Lucene提供了一个简单却强大的应用程式接口,能够做全文索引和搜寻。在Java开发环境里Lucene是一个成熟的免费开源工具。就其本身而言,Lucene是当前以及最近几年最受欢迎的免费Java信息检索程序库。人们经常提到信息检索程序库,虽然与搜索引擎有关,但不应该将信息检索程序库与搜索引擎相混淆。
注意:Lucene和搜索引擎是不同的,Lucene是一套用java或其它语言写的全文检索的工具包。
1.2、全文检索定义
通俗易懂的来说就是:创建索引、搜索索引的过程就叫全文检索。
全文检索首先将要查询的目标文档中的词提取出来,组成索引,通过查询索引达到搜索目标文档的目的。
2、Lucene实现全文检索的流程
全文检索的流程分为两大部分:创建索引流程、搜索索引流程。
创建索引流程:收集数据、构建文档对象、分析文档、创建索引
搜索索引流程:输入关键词、对关键词分析、创建查询、执行搜索、搜索器从索引库取出数据、渲染搜索结果
现在基本上随便一个网站都有搜索功能,做得好的用引擎,做的不好的用sql。
Java常见的搜索引擎框架:
3、Lucene Hello World
3.1、环境准备
JDK版本:1.8.0_151(64)
IDE环境:IDEA
数据库:mysql 5.x
Lucene:7.1.0
3.2、创建一个Maven项目
3.2.1、创建索引
在pom文件加入以下几个jar包
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<!--核心包--> <!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-core --> <dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-core</artifactId> <version>7.1.0</version> </dependency> <!--一般分词器,适用于英文分词--> <!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-analyzers-common --> <dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-analyzers-common</artifactId> <version>7.1.0</version> </dependency> <!--对分词索引查询解析--> <!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-queryparser --> <dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-queryparser</artifactId> <version>7.1.0</version> </dependency> <!--检索关键字高亮显示--> <!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-highlighter --> <dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-highlighter</artifactId> <version>7.1.0</version> </dependency> |
IndexWriter indexWriter = new IndexWriter(indexDir, config);
Directory indexDir =new SimpleFSDirectory(Paths.get("C:/lucene"));
IndexWriterConfig config = new IndexWriterConfig(analyzer);
IndexWriter indexWriter = new IndexWriter(indexDir, config);
Directory indexDir = new SimpleFSDirectory(Paths.get("C:/lucene"));
StandardAnalyzer analyzer = new StandardAnalyzer();
IndexWriterConfig config = new IndexWriterConfig(analyzer);
IndexWriter indexWriter = new IndexWriter(indexDir, config);
Directory indexDir = new SimpleFSDirectory(Paths.get("C:/lucene"));
StandardAnalyzer analyzer = new StandardAnalyzer();
IndexWriterConfig config = new IndexWriterConfig(analyzer);
IndexWriter indexWriter = new IndexWriter(indexDir, config);
Document document = new Document();
Field id = new TextField("id","1",Field.Store.YES);
Field name = new TextField("name","xjh",Field.Store.YES);
document.add(id);
document.add(name);
Directory indexDir = new SimpleFSDirectory(Paths.get("C:/lucene"));
StandardAnalyzer analyzer = new StandardAnalyzer();
IndexWriterConfig config = new IndexWriterConfig(analyzer);
IndexWriter indexWriter = new IndexWriter(indexDir, config);
Document document = new Document();
Field id = new TextField("id","1",Field.Store.YES);
Field name = new TextField("name","xjh",Field.Store.YES);
document.add(id);
document.add(name);
indexWriter.addDocument(document);
indexWriter.close();
这就是创建索引过程
如果熟练了,直接就一步到位
IndexWriter indexWriter = new IndexWriter(new SimpleFSDirectory(Paths.get("C:/lucene")), new IndexWriterConfig(new StandardAnalyzer()));
其实,这就像是一本书,Field是书里面的每一个章节的内容,而Document可以看成是这本书的目录,IndexWriter就是写书了,分词器Analyzer就是采用什么样的语言写这本书了。而你的Directory就是你写好的这本书存放的位置。
最后说说里面的技术点:
Directory主要有两个地方存储,一个存放于内存中,一个存放于磁盘中,至于哪种好,看需求,一般情况下存磁盘,数据量太大的话内存还不得爆了啊~
FSDirectory:这是一个抽象类,我们要用它的子类
我们用的是SimpleFSDirectory,据说其他两个子类是推荐使用的,大家可以去尝试一下NIOFSDirectory、MMapDirectory
RAMDirectory:这是存放于内存的Directory
有一个值得注意的地方就是:Windows和Linux中目录的分隔符不一样!最好是遇到分隔符直接用File.separator替换。
分词器Analyzer:本次我们是用的是简单的标准分词器StandardAnalyzer(),什么是分词器呢?分词器就相当于用什么语言去写这本书,当然,你用什么语言写这本书,最好就用什么语言去读这本书。标准分词器对中文的分词如下:每个字算一个词,也就是说当我们写入“我是一个中国人”,我们可以通过搜索“我”“是”“一”“个”“中”“国”“人”都可以找到它,这个我们后面细说,先大概了解。
Document:就相当于目录。
3.2.2、搜索索引
IndexSearcher searcher = new IndexSearcher(indexReader);
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
Directory indexDir = FSDirectory.open(Paths.get("C:/lucene"));
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
Directory indexDir = FSDirectory.open(Paths.get("C:/lucene"));
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
TopDocs topDocs = searcher.search(query, 10);
Directory indexDir = FSDirectory.open(Paths.get("C:/lucene"));
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
QueryParser queryParser = new QueryParser("name", analyzer);
Query query = queryParser.parse("name:xjh");
TopDocs topDocs = searcher.search(query, 10);
Directory indexDir = FSDirectory.open(Paths.get("C:/lucene"));
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
Analyzer analyzer = new StandardAnalyzer();
QueryParser queryParser = new QueryParser("name", analyzer);
Query query = queryParser.parse("name:xjh");
TopDocs topDocs = searcher.search(query, 10);
Directory indexDir = FSDirectory.open(Paths.get("C:/lucene"));
DirectoryReader indexReader = DirectoryReader.open(indexDir);
IndexSearcher searcher = new IndexSearcher(indexReader);
Analyzer analyzer = new StandardAnalyzer();
QueryParser queryParser = new QueryParser("name", analyzer);
Query query = queryParser.parse("name:xjh");
TopDocs topDocs = searcher.search(query, 10);
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
System.out.println("查询出文档个数为:" + topDocs.totalHits);
for (ScoreDoc scoreDoc : scoreDocs) {
// 文档对象ID
int docId = scoreDoc.doc;
Document doc = searcher.doc(docId);
System.out.println("===============================");
System.out.println("文档id:" + docId);
System.out.println("图书id:" + doc.get("id"));
System.out.println("图书name:" + doc.get("name"));
}
indexReader.close();
4、Field域名
4.1、Field属性
Field是文档中的域,包括Field名和Field值两部分,一个文档可以包括多个Field,Document只是Field的一个承载体,Field值即为要索引的内容,也是要搜索的内容。
是否分词(tokenized)
是:作分词处理,即将Field值进行分词,分词的目的是为了索引。
比如:商品名称、商品简介等,这些内容用户要输入关键字搜索,由于搜索的内容格式大、内容多需要分词后将语汇单元索引。
否:不作分词处理
比如:商品id、订单号、身份证号等
是否索引(indexed)
是:进行索引。将Field分词后的词或整个Field值进行索引,索引的目的是为了搜索。
比如:商品名称、商品简介分析后进行索引,订单号、身份证号不用分析但也要索引,这些将来都要作为查询条件。
否:不索引。该域的内容无法搜索到
比如:商品id、文件路径、图片路径等,不用作为查询条件的不用索引。
是否存储(stored)
是:将Field值存储在文档中,存储在文档中的Field才可以从Document中获取。
比如:商品名称、订单号,凡是将来要从Document中获取的Field都要存储。
否:不存储Field值,不存储的Field无法通过Document获取
比如:商品简介,内容较大不用存储。如果要向用户展示商品简介可以从系统的关系数据库中获取商品简介。
4.2、Field常用类型
举几个例子:
图书id:
是否分词:不用分词,因为不会根据商品id来搜索商品
是否索引:不索引,因为不需要根据图书ID进行搜索
是否存储:要存储,因为查询结果页面需要使用id这个值。
图书名称:
是否分词:要分词,因为要将图书的名称内容分词索引,根据关键搜索图书名称抽取的词。
是否索引:要索引。
是否存储:要存储。
图书价格:
是否分词:要分词,lucene对数字型的值只要有搜索需求的都要分词和索引,因为lucene对数字型的内容要特殊分词处理,本例子可能要根据价格范围搜索,需要分词和索引。
是否索引:要索引
是否存储:要存储
图书图片地址:
是否分词:不分词
是否索引:不索引
是否存储:要存储
图书描述:
是否分词:要分词
是否索引:要索引
是否存储:因为图书描述内容量大,不在查询结果页面直接显示,不存储。
不存储是来不在lucene的索引文件中记录,节省lucene的索引文件空间,如果要在详情页面显示描述,思路:从lucene中取出图书的id,根据图书的id查询关系数据库得到描述信息。
5、索引的维护
5.1、更新索引
更新索引:如果该索引不存在,则是创建索引,如果存在,则是先删除索引再创建索引,所以此处参照创建索引即可。
writer.updateDocument(new Term("filename", "apache"), document);
5.2、删除索引
5.2.1、删除指定索引
根据Term项删除索引,满足条件的将全部删除。
writer.deleteDocuments(new Term("filename", "apache"));
5.2.2、删除全部索引(一般开发中不会用到)
writer.deleteAll();
将索引目录的索引信息全部删除,直接彻底删除,无法恢复。慎用!!!
建议参照关系数据库基于主键删除方式,所以在创建索引时需要创建一个主键Field,删除时根据此主键Field删除。
索引删除后将放在Lucene的回收站中,Lucene3.X版本可以恢复删除的文档,3.X之后无法恢复。
6、搜索
6.1、创建查询的两种方法
对要搜索的信息创建Query查询对象,Lucene会根据Query查询对象生成最终的查询语法。类似关系数据库Sql语法一样,Lucene也有自己的查询语法,比如:“name:lucene”表示查询Field的name为“lucene”的文档信息。
可通过两种方法创建查询对象:
1)使用Lucene提供Query子类
Query是一个抽象类,lucene提供了很多查询对象,比如TermQuery项精确查询,NumericRangeQuery数字范围查询等。
Query query = new TermQuery(new Term("name","lucene"));
2)使用QueryParse解析查询表达式
QueryParser会将用户输入的查询表达式解析成Query对象实例。
QueryParser queryParser =new QueryParser("name",new IKAnalyzer());
Query query =queryParser.parse("name:lucene");
6.2、通过Query子类搜索
6.2.1、TermQuery
TermQuery项查询,TermQuery不使用分析器,搜索关键词作为整体来匹配Field域中的词进行查询,比如订单号、分类ID号等。
Query query = new TermQuery(new Term("filename", "apache"));
6.2.2 NumbericRangeQuery
NumericRangeQuery,指定数字范围查询.
// 创建查询
// 第一个参数:域名
// 第二个参数:最小值
// 第三个参数:最大值
// 第四个参数:是否包含最小值
// 第五个参数:是否包含最大值
Query query = NumericRangeQuery.newLongRange("size", 1l, 100l, true,true);
6.2.3 BooleanQuery
BooleanQuery,布尔查询,实现组合条件查询。
BooleanQuery query = new BooleanQuery();
Query query1 = new TermQuery(new Term("id", "3"));
Query query2 = NumericRangeQuery.newFloatRange("price", 10f, 200f, true, true);
//MUST:查询条件必须满足,相当于AND
//SHOULD:查询条件可选,相当于OR
//MUST_NOT:查询条件不能满足,相当于NOT非
query.add(query1, Occur.MUST);
query.add(query2, Occur.SHOULD);
组合关系代表的意思如下:
1、MUST和MUST表示“与”的关系,即“并集”。
2、MUST和MUST_NOT前者包含后者不包含。
3、MUST_NOT和MUST_NOT没意义
4、SHOULD与MUST表示MUST,SHOULD失去意义;
5、SHOUlD与MUST_NOT相当于MUST与MUST_NOT。
6、SHOULD与SHOULD表示“或”的概念。
6.3 通过QueryParser搜索
通过QueryParser也可以创建Query,QueryParser提供一个Parse方法,此方法可以直接根据查询语法来查询。Query对象执行的查询语法可通过System.out.println(query);查询。
6.3.1 QueryParser
// 创建QueryParser
// 第一个参数:默认域名
// 第二个参数:分词器
QueryParser queryParser = new QueryParser("name", new IKAnalyzer());
// 指定查询语法 ,如果不指定域,就搜索默认的域
Query query = queryParser.parse("lucene");
查询语法
1、基础的查询语法,关键词查询:
域名+“:”+搜索的关键字
例如:content:java
2、范围查询
域名+“:”+[最小值 TO 最大值]
例如:size:[1 TO 1000]
注意:QueryParser不支持对数字范围的搜索,它支持字符串范围。数字范围搜索建议使用NumericRangeQuery。
3、组合条件查询
Occur.MUST 查询条件必须满足,相当于and +(加号) Occur.SHOULD 查询条件可选,相当于or 空(不用符号) Occur.MUST_NOT 查询条件不能满足,相当于not非 -(减号) 1)+条件1 +条件2:两个条件之间是并且的关系and
例如:+filename:apache +content:apache
2)+条件1 条件2:必须满足第一个条件,忽略第二个条件
例如:+filename:apache content:apache
3)条件1 条件2:两个条件满足其一即可。
例如:filename:apache content:apache
4)-条件1 条件2:必须不满足条件1,要满足条件2
例如:-filename:apache content:apache
第二种写法:
条件1 AND 条件2
条件1 OR 条件2
条件1 NOT 条件2
6.3.2、MultiFieldQueryParser
通过MuliFieldQueryParse对多个域查询。
// 可以指定默认搜索的域是多个
String[] fields = { "name", "description" };
// 创建一个MulitFiledQueryParser对象
QueryParser parser = new MultiFieldQueryParser(fields, new IKAnalyzer());
// 指定查询语法 ,如果不指定域,就搜索默认的域
Query query = parser.parse("lucene");
6.4、TopDocs
方法或属性 | 说明 |
totalHits | 匹配搜索条件的总记录数 |
scoreDocs | 顶部匹配记录 |
注意:
Search方法需要指定匹配记录数量n:indexSearcher.search(query, n)
TopDocs.totalHits:是匹配索引库中所有记录的数量
TopDocs.scoreDocs:匹配相关度高的前边记录数组,scoreDocs的长度小于等于search方法指定的参数n
7、相关度排序
7.1、什么是相关度排序
相关度排序是查询结果按照与查询关键字的相关性进行排序,越相关的越靠前。比如搜索“Lucene”关键字,与该关键字最相关的文章应该排在前边。
7.2、相关度打分
Lucene对查询关键字和索引文档的相关度进行打分,得分高的就排在前边。如何打分呢?Lucene是在用户进行检索时实时根据搜索的关键字计算出来的,分两步:
1)计算出词(Term)的权重
2)根据词的权重值,计算文档相关度得分。
什么是词的权重?
通过索引部分的学习,明确索引的最小单位是一个Term(索引词典中的一个词),搜索也是要从Term中搜索,再根据Term找到文档,Term对文档的重要性称为权重。
影响Term权重有两个因素:
Term Frequency (tf):指此Term在此文档中出现了多少次。tf越大说明越重要。
Document Frequency (df):指有多少文档包含次Term。df 越大说明越不重要。
7.3 设置boost值影响相关度排序
boost是一个加权值(默认加权值为1.0f),它可以影响权重的计算。
在索引时对某个文档中的field设置加权值高,在搜索时匹配到这个文档就可能排在前边。
在搜索时对某个域进行加权,在进行组合域查询时,匹配到加权值高的域最后计算的相关度得分就高。
设置boost是给域(field)或者Document设置的。
7.3.1 在创建索引时设置
如果希望某些文档更重要,当此文档中包含所要查询的词则应该得分较高,这样相关度排序可以排在前边,可以在创建索引时设定文档中某些域(Field)的boost值来实现,如果不进行设定,则Field Boost默认为1.0f。一旦设定,除非删除此文档,否则无法改变。
Field description = new TextField("description", "测试设置BOOST值 lucene", Store.YES);
// 设置boost
description.setBoost(10.0f);
7.3.2 在查询索引时设置
在MultiFieldQueryParser创建时设置boost值
// 创建7.3.2 MultiFieldQueryParser
// 默认搜索的多个域的域名
String[] fields = { "name", "description" };
Analyzer analyzer = new StandardAnalyzer();
Map<String, Float> boosts = new HashMap<String, Float>();
boosts.put("name", 200f);
MultiFieldQueryParser parser = new MultiFieldQueryParser(fields, analyzer, boosts);
// Query query = parser.parse("name:lucene OR description:lucene");
Query query = parser.parse("java");
8、中文分词器
8.1、lucene自带分词器:
StandardAnalyzer:是简单分词器,对中文只能一个字一个字的分词,效果很差
CJKAnalyzer:二分法分词器,效果很差
8.2、第三方中文分词器:
paoding:庖丁分词器,庖丁解牛最新版在 https://code.google.com/p/paoding/ 中最多支持Lucene 3.0,且最新提交的代码在 2008-06-03,在svn中最新也是2010年提交,已经过时,不予考虑。
mmseg4j:最新版已从 https://code.google.com/p/mmseg4j/ 移至 https://github.com/chenlb/mmseg4j-solr,支持Lucene 4.10,且在github中最新提交代码是2014年6月,从09年~14年一共有:18个版本,也就是一年几乎有3个大小版本,有较大的活跃度,用了mmseg算法。
IK-analyzer:IK分词器,最新版在https://code.google.com/p/ik-analyzer/上,支持Lucene 4.10从2006年12月推出1.0版开始, IKAnalyzer已经推出了4个大版本。最初,它是以开源项目Luence为应用主体的,结合词典分词和文法分析算法的中文分词组件。从3.0版本开 始,IK发展为面向Java的公用分词组件,独立于Lucene项目,同时提供了对Lucene的默认优化实现。在2012版本中,IK实现了简单的分词 歧义排除算法,标志着IK分词器从单纯的词典分词向模拟语义分词衍化。 但是也就是2012年12月后没有在更新。
ansj_seg:最新版本在 https://github.com/NLPchina/ansj_seg tags仅有1.1版本,从2012年到2014年更新了大小6次,但是作者本人在2014年10月10日说明:“可能我以后没有精力来维护ansj_seg了”,现在由”nlp_china”管理。2014年11月有更新。并未说明是否支持Lucene,是一个由CRF(条件随机场)算法所做的分词算法。
imdict-chinese-analyzer:最新版在 https://code.google.com/p/imdict-chinese-analyzer/ ,最新更新也在2009年5月,下载源码,不支持Lucene 4.10 。是利用HMM(隐马尔科夫链)算法。
Jcseg:最新版本在git.oschina.net/lionsoul/jcseg,支持Lucene 4.10,作者有较高的活跃度。利用mmseg算法。
8.3、使用中文分词器IKAnalyzer
使用IK分词器需要修改修改IKAnalyzer和IKTokenizer(高版本的lucene,示例版本:7.1.0)
MyIKTokenizer.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
public class MyIKTokenizer extends Tokenizer { // IK分词器实现 private IKSegmenter _IKImplement; // 词元文本属性 private final CharTermAttribute termAtt; // 词元位移属性 private final OffsetAttribute offsetAtt; // 词元分类属性(该属性分类参考org.wltea.analyzer.core.Lexeme中的分类常量) private final TypeAttribute typeAtt; // 记录最后一个词元的结束位置 private int endPosition; public MyIKTokenizer(Reader in) { this(in, false); } public MyIKTokenizer(Reader in, boolean useSmart) { offsetAtt = addAttribute(OffsetAttribute.class); termAtt = addAttribute(CharTermAttribute.class); typeAtt = addAttribute(TypeAttribute.class); _IKImplement = new IKSegmenter(input, useSmart); } @Override public boolean incrementToken() throws IOException { // 清除所有的词元属性 clearAttributes(); Lexeme nextLexeme = _IKImplement.next(); if (nextLexeme != null) { // 将Lexeme转成Attributes // 设置词元文本 termAtt.append(nextLexeme.getLexemeText()); // 设置词元长度 termAtt.setLength(nextLexeme.getLength()); // 设置词元位移 offsetAtt.setOffset(nextLexeme.getBeginPosition(), nextLexeme.getEndPosition()); // 记录分词的最后位置 endPosition = nextLexeme.getEndPosition(); // 记录词元分类 typeAtt.setType(nextLexeme.getLexemeTypeString()); // 返会true告知还有下个词元 return true; } // 返会false告知词元输出完毕 return false; } public void reset() throws IOException { super.reset(); _IKImplement.reset(input); } @Override public final void end() { // set final offset int finalOffset = correctOffset(this.endPosition); offsetAtt.setOffset(finalOffset, finalOffset); } } |
MyIkAnalyzer.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class MyIkAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String arg0) { Reader reader=null; try{ reader=new StringReader(arg0); MyIKTokenizer it = new MyIKTokenizer(reader); return new Analyzer.TokenStreamComponents(it); }finally { IOUtils.closeWhileHandlingException(reader); } } } |
使用:
Analyzer myIkAnalyzer=new MyIkAnalyzer();
8.3.1IK分词器拓展词、停用词
IK分词器可以配置停用词和扩展词,IK jar包下有三个文件,分别是IKAnalyzer.cfg.xml、stopword.dic、ext.dic
复制三个文件到resources下面即可
注意:不要用记事本保存扩展词文件和停用词文件,那样的话,格式中是UTF-8但是含有bom!
什么是UTF-8带bom、UTF-8不带bom?
比如用notepad++打开,在上面的导航栏有UTF-8格式编码(默认)、UTF-8无bom格式编码
直接选择UTF-8无bom格式编码即可,至于原因,估计是IK只能识别UTF-8无格式bom的编码文件
下一篇【对Solr的一点小总结】
吾虽浪迹天涯,却未迷失本心。
耐火砖价格
看不懂,不过还是要支持博主
27wy.cn
@耐火砖价格 谢谢!
匿名
可以的