最近因为负责一个小功能,所以想尽力做好它。于是对会经常看看用户的查询,看看这些查询的结果是否满足需要,于是需要对这些查询词进行提取。本来还想用Python来写的,后来想想shell才是做这事的最佳方法,于是先从grep开始。

solr的日志中,query都是跟在‘q=’后面,且参数间用&隔开,于是执行如下命令,
grep -o ‘q=.*\&’ solr.log
得到如下结果
q=磐安&macro.skip=0&qt=macro&wt=json&
q=磐安+财政&macro.skip=0&qt=macro&wt=json&
q=保定+财政&macro.skip=0&qt=macro&wt=json&
q=磐安+财政&macro.skip=0&qt=macro&wt=json&
q=财政+长春&macro.skip=0&qt=macro&wt=json&
q=财政+长沙&macro.skip=0&qt=macro&wt=json&
q=存款收入&macro.skip=0&qt=macro&wt=json&
q=存款收入&qt=macro&wt=json&macro.groupOffset=0&macro.groupNames=利率走势&
q=存款收入&qt=macro&wt=json&macro.groupOffset=0&macro.groupNames=行业经济&
q=存款收入&qt=macro&wt=json&macro.groupOffset=0&macro.groupNames=区域宏观&
q=存款收入&qt=macro&wt=json&macro.groupOffset=0&macro.groupNames=中国宏观&

之后就是截取query部分,这时awk就派上用场了。先用&分割,得到第一段,之后用=分割,得到第二段
grep -o ‘q=.*\&’ solr.log | grep -v ‘module2:’ | grep -v ‘solrconfig.xml’ | awk -F ‘&’ ‘{print $1}’ | awk -F ‘=’ ‘{print $2}’
结果如下:
磐安
磐安+财政
保定+财政
磐安+财政
财政+长春
财政+长沙
存款收入
存款收入
存款收入
存款收入
存款收入

之后想统计每个查询词的次数,此时先用sort排序,之后用uniq -c来统计,
grep -o ‘q=.*\&’ solr.log | grep -v ‘module2:’ | grep -v ‘solrconfig.xml’ | awk -F ‘&’ ‘{print $1}’ | awk -F ‘=’ ‘{print $2}’ |sort | uniq -c
结果如下:
1 保定+财政
5 存款收入
1 磐安
2 磐安+财政
1 财政+长春
1 财政+长沙

而我希望按查询次数从高到低排列,于是再用sort -rn
grep -o ‘q=.*\&’ solr.log | grep -v ‘module2:’ | grep -v ‘solrconfig.xml’ | awk -F ‘&’ ‘{print $1}’ | awk -F ‘=’ ‘{print $2}’ |sort | uniq -c | sort -rn
结果如下:
5 存款收入
2 磐安+财政
1 财政+长沙
1 财政+长春
1 磐安
1 保定+财政

一行代码搞定。一句话,管道实在是太方便了,linux也是如此。

联系作者

最近一个项目需要用脚本生成汉字拼音时来排序,组里同事说以前有同事写过一个,于是拿过来用,看了一下代码,发现有些地方还是可以优化的,

如以下代码:

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
self.polyphone = {}
for line in open(polyphone_path):
k, context, pron, other = line.split(' ', 3)
item = collections.defaultdict(dict)
key = "%X" % ord(unicode(k, 'utf8'))
item[key]['context'] = unicode(context, 'utf8')

item[key]['pron'] = pron
if self.polyphone.has_key(key):
self.polyphone[key].append(item)
else:
self.polyphone[key] = []
self.polyphone[key].append(item)
```
这一段代码里需要判断字典里有没有包含key,如果没有,则要先声明value为空的list,之后再添加值,这种情况下collections中的defaultdict就派上用场了。
``` python
self.polyphone = defaultdict(list)
for line in open(polyphone_path):
k, context, pron, other = line.split(' ', 3)
item = defaultdict(dict)
key = "%X" % ord(unicode(k, 'utf8'))
item[key]['context'] = unicode(context, 'utf8')
item[key]['pron'] = pron
self.polyphone[key].append(item)
```
defaultdict可以给定一个默认值,这样省去了判断key是否已经在字典里存在。

还见到如下代码:
``` python
polyphone = False
for item in self.polyphone[key]:
if chars.find(item[key]['context']) != -1:
result.append(item[key]['pron'].strip()[:-1].lower())
polyphone = True
break

if not polyphone:
result.append(self.dict[key].split(",")[0].strip()[:-1].lower())

这段代码里,if not polyphonse判断里的句子只有在上面的for没有被break时才执行,也就是for循环执行时才执行,这种情况在编程中经常遇到,而python提供了for else循环语句,于是可以修改成:

1
2
3
4
5
6
for item in self.polyphone[key]:
if chars.find(item[key]['context']) != -1:
result.append(item[key]['pron'].strip()[:-1].lower())
break
else :
result.append(self.dict[key].split(",")[0].strip()[:-1].lower())

是不是瞬间简洁很多?所以说,语言特性还是有必要学习的,虽然算法和数据结构依然是核心,可是代码易维护,易懂也是非常重要的

联系作者

如要查看8080端口被进程占用,以前都是用 lsof命令的,
lsof -i:8080

现在lsof命令不能用了,于是改成netstat
netstat -nltp | grep 8080

以前执行这个命令时没有加上p参数,后来仔细看netstat的帮助,知道p参数是显示进程id和名字用的

联系作者

以前专门考虑过这个问题,只是没有记录笔记,和组里的同事说起这个问题,于是又考虑了一次,这次还是记下来为妙。

13球问题说的是有12个标准球和1个不合格的球,这个球可能偏重或者偏轻了,给你一个天平,问至少称多少次可以找到这个不合格的球。

考虑这个问题之前,可以先考虑高中时代,数学老师问过的8球问题。8球问题说的是一共有8个球,其中有一个球偏重了,给你一个天平,问至少称几次可以找到这个球。

一个很显然的办法是,两边各4个,之后拿重的一方再对分称,之后再拿重的一方对分称,一共三次就可以称出来。但这不是最优的,记得当时大部分同学都是这么考虑的,只有一个许杨冰同学不是这样,她说称两次即可知道。方法是,左边放三个,右边放三个,如果是左边重,则球一定在这3个中,从这个三个中取两个,天平两边各放一个,如果两边一样重,则偏重的球是剩下的那一个,如果两边不一样重,则偏重的一方就是那个球。当时就觉得许同学非同一般,后来高考时她考了全班第一。

大三的时候,学习了信息论,发现这个问题可以用信息论的观点来解释。考虑上面的8球问题,当只有3个球时,只称一次即可知道是哪一个球偏重了,也就是说,一次称球,可以知道3种情况,那么2此称球就可以知道9种情况。而8球问题,只有8种情况,所以只需称两次即可找到那个球。考虑上面的13球问题,这里一共有26种情况,1号球偏重,1号球偏轻,2号球偏重,2号球偏轻。。。,因为两次称球可以知道9种情况,那么三次称球可以知道27种情况,而13球一共只有26中情况,所以13球问题只需称3次就可以找到那个球,剩下的问题就是如何称了。

考虑之后,给出了一种解法。为了方便,我们给球编号,1,2,3,4,,,13。
1.首先把1,2,3,4放在左边天平,把5,6,7,8放在右边天平,
2.如果天平一样重,则那个球在剩下的5个球中,其它8个为标准球。之后在这5个球中取3个球,放在左边,取3个标准球放在天平右边,分三种情况
A 如果一样重,则不合格球在剩下的两个球中,对于剩下的两个球,取其中一个出来称,如果偏重或者偏轻,则找到了那个不合格球,如果一样重,则不合格球是剩下的一个(注意,这里我们不能知道它是偏重或者偏轻)。
B 如果左边轻,之后再称一次就可以知道是哪个球偏轻。
C 如果左边重,之后再称一次就可以知道是哪个球偏轻。
3.如果不一样重,则那个球在这8个球中。假设是左边重了,则剩下可能的8种情况,1,2,3,4号偏重,5,6,7,8号偏轻。之后的取法可以这样,左边放三个标准球再加上8号球,右边放5,6,7和4号球。依然分三种情况
A 如果一样重,则1 2 3号球偏重,称一次即可知道结果
B 如果左边重,则5 6 7 号球偏轻,称一次即可知道结果
C 如果左边轻,则8号球偏轻或者4号球偏重,称一次即可知道结果。

现在再来考虑,2时,剩下5个球的情况,5个球的时候,一共有10种情况,9号偏重,9号偏轻,,,13号偏重,所以称两次是无法知道具体是哪一个球偏重或者偏轻,这也是为什么在2的A情况中,如果一样重,则不合格球是剩下的一个,但我们无法知道它是偏重或者偏轻。所幸题目只要求我们找到那个球就可以了,没有要求知道它是偏重或者偏轻。

那么如果一定要找到那个球,且知道它是偏重或者偏轻呢?这样的话就不是这种方法能解决的了,需要精心设计的方法,继续考虑,等知道了再分享。

联系作者

看来这一段小代码还是有点用的,还是开源出来吧,免得再造轮子。注意,修改时基于Solr1.4,其它版本进行相应修改即可。

主要修改了两个类IndexWriter, SegmentMerger.添加辅助类ByteUtil,TypeUtil,Constant。

修改类IndexWriter:
在方法private int mergeMiddle(MergePolicy.OneMerge merge)里,
将SegmentReader初始化 SegmentReader reader = merge.readers[i] = readerPool.get(info, merge.mergeDocStores,MERGE_READ_BUFFER_SIZE, -1);
修改成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String temp = System
.getProperty(Constant.DOCUMENT_MERGE_OPTION);
boolean documentMerge = temp != null && temp.equals("true") ? true
: false;
if (documentMerge) {
merge.readers[i] = readerPool.get(info, merge.mergeDocStores,
MERGE_READ_BUFFER_SIZE,
IndexReader.DEFAULT_TERMS_INDEX_DIVISOR);
} else {
merge.readers[i] = readerPool.get(info, merge.mergeDocStores,
MERGE_READ_BUFFER_SIZE,
-1);
}
SegmentReader reader = merge.readers[i];

这是因为如果需要读FieldCache,则需要加载内存,否则会报错。而readerPool.get这个函数内有这样一个判断

1
2
3
4
5
6
7
8
9
if (termsIndexDivisor != -1 && !sr.termsIndexLoaded()) {
// If this reader was originally opened because we
// needed to merge it, we didn't load the terms
// index. But now, if the caller wants the terms
// index (eg because it's doing deletes, or an NRT
// reader is being opened) we ask the reader to
// load its terms index.
sr.loadTermsIndex(termsIndexDivisor);
}

设置第四个参数为IndexReader.DEFAULT_TERMS_INDEX_DIVISOR,SegmentReader就会增加索引

修改类SegmentMerger:
在mergeFields函数里,将copyFieldsWithDeletions和copyFieldsNoDeletions增加一个参数 boolean documentMerge
参数的值由如下语句得到

1
2
String temp = System.getProperty(Constant.DOCUMENT_MERGE_OPTION);
boolean documentMerge = temp != null && temp.equals("true") ? true : false;

函数copyFieldsWithDeletions和copyFieldsNoDeletions是对应的,这里只拿copyFieldsNoDeletions举例。
在copyFieldsNoDeletions里,读取FieldCache的主要工作在以下这个判断语句里完成.

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
if (documentMerge) {
//Update Dengshilong 2014-09-25
//here is where documentMerge and read FieldCache actually do
//read fields and types from start parameters
//for every field ,read value from FieldCache ,
//for numerical field use the correspond byte transform method to build a Field
String fieldNamesStr = System
.getProperty(Constant.DOCUMENT_MERGE_FIELDS);
String typesStr = System
.getProperty(Constant.DOCUMENT_MERGE_TYPES);
String[] fieldNames = fieldNamesStr.split(",");
String[] types = typesStr.split(",");
for (; docCount < maxDoc; docCount++) {
// NOTE: it's very important to first assign to doc then
// pass it to
// termVectorsWriter.addAllDocVectors; see LUCENE-1282
Document doc = reader.document(docCount,
fieldSelectorMerge);
Map typeMap = TypeUtil.TYPE_MAP;
for (int i = 0; i < fieldNames.length; i++) {
String fieldName = fieldNames[i];
String type = types[i];
Fieldable field = (Fieldable) doc
.getFieldable(fieldName);
if (field == null) {
Types t = (Types) TypeUtil.TYPE_MAP.get(type);
switch(t) {
case INTEGER:
int[] vi = FieldCache.DEFAULT.getInts(reader, fieldName);
Field fi = new Field(fieldName, ByteUtil.toArr(vi[docCount]), Store.YES);
doc.add(fi);
break;
case LONG:
long[] vl = FieldCache.DEFAULT.getLongs(reader, fieldName);
Field fl = new Field(fieldName, ByteUtil.toArr(vl[docCount]), Store.YES);
doc.add(fl);
break;
case FLOAT:
float[] vf = FieldCache.DEFAULT.getFloats(reader, fieldName);
Field ff = new Field(fieldName, ByteUtil.toArr(vf[docCount]), Store.YES);
doc.add(ff);
break;
case DOUBLE:
double[] vd = FieldCache.DEFAULT.getDoubles(reader, fieldName);
Field fd = new Field(fieldName, ByteUtil.toArr(vd[docCount]), Store.YES);
doc.add(fd);
break;
}
} else {
continue;
}
}

fieldsWriter.addDocument(doc);
checkAbort.work(300);
}
}

增加类ByteUtil用于int,double等数值型转化为byte[]数组;

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
package org.apache.lucene.util;
//The transform method is copy from TrieField.java
public class ByteUtil {
public static int toInt(byte[] arr) {
return (arr[0] << 24) | ((arr[1] & 0xff) << 16)
| ((arr[2] & 0xff) << 8) | (arr[3] & 0xff);
}

public static long toLong(byte[] arr) {
int high = (arr[0] << 24) | ((arr[1] & 0xff) << 16)
| ((arr[2] & 0xff) << 8) | (arr[3] & 0xff);
int low = (arr[4] << 24) | ((arr[5] & 0xff) << 16)
| ((arr[6] & 0xff) << 8) | (arr[7] & 0xff);
return (((long) high) << 32) | (low & 0x0ffffffffL);
}
public static float toFloat(byte[] arr) {
return Float.intBitsToFloat(toInt(arr));
}
public static double toDouble(byte[] arr) {
return Double.longBitsToDouble(toLong(arr));
}

public static byte[] toArr(int val) {
byte[] arr = new byte[4];
arr[0] = (byte) (val >>> 24);
arr[1] = (byte) (val >>> 16);
arr[2] = (byte) (val >>> 8);
arr[3] = (byte) (val);
return arr;
}

public static byte[] toArr(long val) {
byte[] arr = new byte[8];
arr[0] = (byte) (val >>> 56);
arr[1] = (byte) (val >>> 48);
arr[2] = (byte) (val >>> 40);
arr[3] = (byte) (val >>> 32);
arr[4] = (byte) (val >>> 24);
arr[5] = (byte) (val >>> 16);
arr[6] = (byte) (val >>> 8);
arr[7] = (byte) (val);
return arr;
}

public static byte[] toArr(float val) {
return toArr(Float.floatToRawIntBits(val));
}

public static byte[] toArr(double val) {
return toArr(Double.doubleToRawLongBits(val));
}
}

增加类TypesUtil,定义了INTEGER等类型常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.apache.lucene.util;
import java.util.HashMap;
import java.util.Map;
//the types is copy form TrieField.java
public class TypeUtil {
public enum Types {
INTEGER,
LONG,
FLOAT,
DOUBLE,
}
public final static Map TYPE_MAP = new HashMap() { {
put("int", Types.INTEGER);
put("tint", Types.INTEGER);
put("long", Types.LONG);
put("tlong", Types.LONG);
put("float", Types.FLOAT);
put("tfloat", Types.FLOAT);
put("double", Types.DOUBLE);
put("tdouble", Types.DOUBLE);
}};
}

增加类Constant,定义了三个常量

1
2
3
4
5
6
7
package org.apache.lucene.util;
public class Constant {
//add for documentMerge
public final static String DOCUMENT_MERGE_OPTION = "search.index.documentMerge";
public final static String DOCUMENT_MERGE_FIELDS = "search.index.documentMerge.fields";
public final static String DOCUMENT_MERGE_TYPES = "search.index.documentMerge.types";
}

使用:
在solr启动脚本中,添加如下参数
search.index.documentMerge 为true时表示开启强制文档合并,其它值时表示不开启
search.index.documentMerge.fields 需要读取的字段,字段之间用逗号隔开
search.index.documentMerge.types 读取字段的类型,类型间用逗号隔开,这里的类型要与上面的字段一一对应起来
举个例子:

要对PublishTime,ContentLength进行读取,而它们的字段类型分别为tint,int于是添加如下参数
-Dsearch.index.documentMerge=true -Dsearch.index.documentMerge.fields=PublishTim,ContentLength
-Dsearch.index.documentMerge.types=tint,int

联系作者

随着对Solr的进一步深入,自然就想了解Lucene的索引文件格式。之前写的段合并小工具不知怎么不起作用了(后来发现是没有更新代码),于是把觉先的《Lucene源码剖析》又翻出来看,顺便看了一下 Lucene索引格式。Solr使用的是1.4的,查看文件格式,与Lucene2.9的文件格式相差不大,依然有参考价值。

到索引目录下查看,一共有如下几种文件格式。对照http://lucene.apache.org/core/2_9_4/fileformats.html,知道每一种格式的大概用途。
segments.gen, segments_N Segments File 主要保存索引段信息
.fnm Fields 域的元数据信息文件,保存域信息
.fdx Field Index 域数据索引文件,保存指向域数据文件的指针,方便快速访问域数据文件
.fdt Field Data 域数据文件,保存每个文档的字段,域的真正值就是在这里保存
.tis Term Infos 词典文件,记录索引词的信息
.tii Term Info Index 词典索引文件,记录到tis文件的指向,主要是为了加快访问词典文件
.frq Frequencies 文档号与词频文件,记录索引词在文档中的词频
.prx Positions 词位置信息文件,记录索引词的位置信息
.nrm Norms 标准化因子文件,记录文档和域的权重
.tvx Term Vector Index 词向量索引文件,保存到词向量文档文件和词向量域文件的指针
.tvd Term Vector Documents 词向量文档文件,记录文档第一个域与其它域的偏移
.tvf Term Vector Fields 词向量域文件,记录域级别的词向量
.del Deleted Document 记录哪个文档被删除

还有.cfs文件,也即是Compound File,当将所有索引文件合成一个文件时才会出现,主要是减少文件句柄。
write.lock,用来互斥的写索引文件。
而.tvx,tvd,tvf只有在启用词向量时才会出现。

联系作者

初识Solr

1.安装Solr,
方法一,下载源码,编译,安装,这个单独介绍
方法二,下载二进制文件,解压,即可。

2.启动Solr
进入example目录,允许 java -jar start.jar,默认监听8983端口,访问http://localhost:8983/solr看看是否启动。
若端口被占用,修改启动端口即可,java -Djetty.port=8080 -jar start.jar 。

3.查询
Solr后台,查询表单的参数意义示例
字段 值 意义
q iPod 查询词
fq manu:Belkin 过滤,只显示manu中有Belkin的结果
sort price asc 排序,价格从低到高排列
start 0 分页参数,相当于mysql中的offset,即从第几条结果开始显示
rows 10 分页参数,想到与mysql中的limit,即总共显示几条结果
fl name,price,features,score 需要显示的字段
df text 默认搜索字段,对于没有制定搜索字段的查询,默认查询text字段
wt xml 返回结果显示格式,还有json,csv等多种格式供选择

4.相关性排序
可以对查询词进行加权,改变排序结果,如查询词“iPod power”变成”iPod power^2”,则power的权重是iPod的两倍

5.分页
使用start和rows参数,每页显示条数尽量小,因为需要都去返回字段的值,条数越多,速度越慢

6.排序
对返回结果使用如 price asc等进行排序

7.提供的搜索组件
dismax 如何翻译,待查
edismax 如何翻译,待查
hl 高亮
facet 平面搜索
spatial 地理位置搜索
spellchecking 拼写检查

联系作者

Solr in Action是本好书,决定复习一遍。

为什么需要搜索引擎,或者说搜索引擎有什么特别的地方,需要在应用中用到它?

搜索引擎有四个主要特征:

1.文本为中心。

当用户需要在文本中查找所需要的信息时,基本上就需要用到搜索引擎了。

2.读多写少
搜索引擎的结果为了读做了很多优化,相应的,写数据就会变得慢一些。当应用读多写少,用搜索引擎是比较合适的,而如果写多读少,则应考虑其它方案。

3.面向文档
搜索引擎的一条记录成为一个文档,这个文档是一个整体,不需要依赖其它信息。

4.灵活的模式
意思是说,引擎中的记录不要求结构都一样,每条记录所具有的字段可以不同

搜索的基本应用:
1.关键词查询
2.相关性排序
相关性排序是搜索引擎区别与其它查询的重要特征,相关性排序也是一个非常重要的研究方向。

Solr是什么?
简单来说,Solr就是Lucene的一个外壳。底层,Solr使用Lucene来索引和查询数据,外层,Solr提供灵活的配置文件,避免像Lucene那样编写代码来定义字段类型。此外,Solr还提供一些功能,如高亮,缓存,分布式等。

为什么选择Solr?
因为Solr在稳定性,可扩展性,容错性三个方面都做的非常出色。

联系作者

最近需要将Solr从1.4升级到4.8,于是需要将索引数据进行升级,而1.4无法直接升级到4.8,需要经过如下转化。从1.4升级到3.6,3.6升级到4.0,4.0升级到4.8。有几个引擎的数据升级很顺利,可是也有那么几个引擎的数据升级过程中出现了错误。

错误都出现在4.0升级到4.8时。调用栈如下:
Caused by: java.lang.IllegalArgumentException: maxValue must be non-negative (got: -1)
at org.apache.lucene.util.packed.PackedInts.bitsRequired(PackedInts.java:1180)
at org.apache.lucene.codecs.lucene41.ForUtil.bitsRequired(ForUtil.java:243)
at org.apache.lucene.codecs.lucene41.ForUtil.writeBlock(ForUtil.java:164)
at org.apache.lucene.codecs.lucene41.Lucene41PostingsWriter.addPosition(Lucene41PostingsWriter.java:368)
at org.apache.lucene.codecs.PostingsConsumer.merge(PostingsConsumer.java:123)
at org.apache.lucene.codecs.TermsConsumer.merge(TermsConsumer.java:164)
at org.apache.lucene.codecs.FieldsConsumer.merge(FieldsConsumer.java:72)
at org.apache.lucene.index.SegmentMerger.mergeTerms(SegmentMerger.java:389)
at org.apache.lucene.index.SegmentMerger.merge(SegmentMerger.java:112)
at org.apache.lucene.index.IndexWriter.mergeMiddle(IndexWriter.java:4132)
at org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:3728)
at org.apache.lucene.index.ConcurrentMergeScheduler.doMerge(ConcurrentMergeScheduler.java:405)
at org.apache.lucene.index.ConcurrentMergeScheduler$MergeThread.run(ConcurrentMergeScheduler.java:482)

看代码后,在PostingsConsumer 120行附近,final int position = postingsEnum.nextPosition();,这个position是负的,所以报错。看这附近的代码,知道是对索引词的在文档中的位置信息进行压缩。可是词在文档中的位置不应该是负的,于是报错。问题是,为什么这里会出现负的位置,只能解释是数据问题。一个解决的办法是跳过为负的位置,如此升级确实成功了,只是不知道有没有什么副作用。

联系作者

在Solr的索引记录里看到,很多HostName是逆序的,如news.qq.com记录成moc.qq.swen, www.qq.com记录成moc.qq.www,moc.qq,finance.qq.com记录成moc.qq.ecnanif。后来才知道,这是为了实现像google那样的site功能.

site功能就是要查找索引中某一域名下的记录。一个实现办法就是实现上面的逆序存储。如此,要找出qq.com下的所有记录只需要用moc.qq.*去比较HostName即可。

联系作者