I. 引言

全文检索

全文检索首先对要搜索的文档进行分词,然后形成索引,通过查询索引来查询文档。先创建索引,然后根据索引来进行搜索。比如查字典,字典的偏旁部首就类似于索引,字典的具体内容则类似于文档内容。

应用场景:

  • 搜索引擎
  • 站内搜索
  • 文件系统的搜索

Lucence

Lucene是Apache的一个全文检索引擎工具包,通过Lucene可以让程序员快速开发一个全文检索功能。Lucene不是搜索引擎,仅仅是一个工具包。它不能独立运行,不能单独对外提供服务(Solr、ElasticSearch)。

II. Lucence实现全文检索流程

全文检索流程

  1. 索引流程:采集数据——文档处理——存储到索引库中
  2. 搜索流程:输入查询条件——通过Lucene查询器查询索引——从索引库中取出结果——视图渲染

:Lucene本身不能进行视图渲染。


Lucene的索引与搜索

文档域:文档域存储的信息就是采集到的信息,通过Document对象来存储,具体说是通过Document对象中Field域来存储数据。比如:数据库中一条记录会存储一个一个Document对象,数据库中一列会存储成Document中一个Field域。Field域的name为字段名,value则为具体值。文档域中,Document对象之间是没有关系的,而且每个Document中的Field域也不一定一样。

索引域:主要是为了搜索使用的,索引域的内容是经过Lucene分词之后存储的。

倒排索引表:传统方法是先找到文件,如何在文件中找内容,在文件内容中匹配搜索关键字,这种方法是顺序扫描方法,数据量大就搜索慢。 倒排索引结构是根据内容(词语)找文档,包括索引和文档两部分。索引即词汇表,它是在索引中匹配搜索关键字,由于索引内容量有限并且采用固定优化算法搜索速度很快,先找到索引中的词汇,词汇又与文档关联,从而最终找到了文档。

III. 入门案例

环境准备

  • Jdk1.8(Lucene4.8版本以后,必须使用jdk1.7及以上)
  • Lucence7.4.0:http://archive.apache.org/dist/lucene/java/
  • MySQL5

案例需求

使用Lucene完成对数据库中商品信息的索引和搜索功能。


工程搭建

需要导入如下依赖jar包:

  • MySQL驱动包
  • Analysis包
  • Core包
  • QueryParse包

其中,Analysis包、Core包和QueryParse包分别在lucene-7.4.0\analysis\common、lucene-7.4.0\core和lucene-7.4.0\queryparser文件夹下,最终工程结构如下:


准备数据

这里的数据主要来源于数据库商品表,需要通过Jdbc查询数据库准备数据。

Product实体类:

public class Product {private Long id;private String title;private String sellPoint;private Long price;private int num;private String barcode;private String image;private Long cid;private int status;private Date created;private Date updated;/** getter and setter **/
}

ProductDao数据库查询:

public class ProductDao {public List<Product> queryProducts() {Connection connection = null;PreparedStatement preparedStatement = null;ResultSet resultSet = null;List<Product> products = new ArrayList<>();try {Class.forName("com.mysql.jdbc.Driver");connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/taotao", "root", "1234");String sql = "SELECT * FROM tb_item";preparedStatement = connection.prepareStatement(sql);resultSet = preparedStatement.executeQuery();// 结果集解析while (resultSet.next()) {Product product = new Product();product.setId(resultSet.getLong("id"));product.setTitle(resultSet.getString("title"));product.setSellPoint(resultSet.getString("sell_point"));product.setBarcode(resultSet.getString("barcode"));product.setImage(resultSet.getString("image"));product.setPrice(resultSet.getLong("price"));product.setCid(resultSet.getLong("cid"));product.setNum(resultSet.getInt("num"));product.setStatus(resultSet.getInt("status"));product.setCreated(resultSet.getDate("created"));product.setUpdated(resultSet.getDate("updated"));products.add(product);}} catch (Exception e) {e.printStackTrace();}return products;}
}

创建索引


IndexWriter是索引过程的核心组件,通过IndexWriter可以创建新索引、更新索引、删除索引操作。IndexWriter需要通过Directory对索引进行存储操作。

Directory描述了索引的存储位置,底层封装了I/O操作,负责对索引进行存储。它是一个抽象类,它的子类常用的包括FSDirectory(在文件系统存储索引)、RAMDirectory(在内存存储索引)。

@Test
public void createIndex() throws IOException {// 采集数据ProductDao productDao = new ProductDao();List<Product> products = productDao.queryProducts();// 将采集到的数据封装到Document对象中List<Document> docList = new ArrayList<>();for (Product product : products) {Document document = new Document();// store:如果是yes,则说明存储到文档域中Field id = new TextField("id", product.getId().toString(), Field.Store.YES);Field title = new TextField("title", product.getTitle(), Field.Store.YES);Field sellPoint = new TextField("sellPoint", product.getSellPoint(), Field.Store.YES);Field barcode = new TextField("barcode", product.getBarcode(), Field.Store.YES);Field image = new TextField("image", product.getImage(), Field.Store.YES);Field price = new TextField("price", product.getPrice().toString(), Field.Store.YES);Field cid = new TextField("cid", product.getCid().toString(), Field.Store.YES);Field num = new TextField("num", product.getNum()+"", Field.Store.YES);Field status = new TextField("status", product.getStatus()+"", Field.Store.YES);Field created = new TextField("created", product.getCreated().toString(), Field.Store.YES);Field updated = new TextField("updated", product.getUpdated().toString(), Field.Store.YES);// 将field域设置到Document对象中document.add(id);document.add(title);document.add(sellPoint);document.add(barcode);document.add(image);document.add(price);document.add(cid);document.add(num);document.add(status);document.add(created);document.add(updated);docList.add(document);}// 创建分词器,标准分词器Analyzer analyzer = new StandardAnalyzer();// 创建IndexWriterIndexWriterConfig config = new IndexWriterConfig(analyzer);// 指定索引库的地址Path indexFile = Paths.get("E:\\search-learn\\data");Directory directory = FSDirectory.open(indexFile);IndexWriter indexWriter = new IndexWriter(directory, config);// 通过IndexWriter对象将Document写入到索引库中for (Document document : docList) {indexWriter.addDocument(document);}// 关闭indexWriterindexWriter.close();
}

内容分词

上面的代码,使用了标准分词器:StandardAnalyzer。主要有分词过滤两个步骤。

  • 分词:将field域中的内容一个个的分词。
  • 过滤:将分好的词进行过滤,比如去掉标点符号、大写转小写、词的型还原(复数转单数、过去式转成现在式)、停用词过滤。

使用Luck工具

下载:luke-javafx-7.4.0-luke-release.zip



Luke搜索

同数据库的sql一样,Lucene全文检索也有固定的语法。最基本的有比如:AND, OR, NOT 等。

举个例子,用户想找一个title中包括手和机(手机)关键字的文档。查询语句为:

title:手 AND 机

Luke中的查询结果为:


API搜索


通过创建索引搜索对象,执行封装了查询语句的查询对象Query,得到结果集TopDocs。创建索引搜索对象IndexSearch时,需要索引读取对象IndexReader和索引目录流对象Directory。

@Test
public void searchIndex() throws Exception {// 创建query对象// 使用QueryParser搜索时,需要指定分词器,搜索时的分词器要和索引时的分词器一致// 第一个参数:默认搜索的域的名称QueryParser parser = new QueryParser("title", new StandardAnalyzer());// 通过queryparser来创建query对象// 参数:输入的lucene的查询语句(关键字一定要大写)Query query = parser.parse("手 AND 机");// 创建IndexSearcher// 指定索引库的地址Path indexFile = Paths.get("E:\\search-learn\\data");Directory directory = FSDirectory.open(indexFile);IndexReader reader = DirectoryReader.open(directory);IndexSearcher indexSearcher = new IndexSearcher(reader);// 通过searcher来搜索索引库// 第二个参数:指定需要显示的顶部记录的N条TopDocs topDocs = indexSearcher.search(query, 2);// 根据查询条件匹配出的记录ScoreDoc[] scoreDocs = topDocs.scoreDocs;for (ScoreDoc scoreDoc : scoreDocs) {// 获取文档的IDint docId = scoreDoc.doc;// 通过ID获取文档Document doc = indexSearcher.doc(docId);System.out.println(doc.get("id"));System.out.println(doc.get("title"));System.out.println(doc.get("sellPoint"));System.out.println(doc.get("barcode"));System.out.println(doc.get("image"));System.out.println(doc.get("price"));System.out.println(doc.get("cid"));System.out.println(doc.get("num"));System.out.println(doc.get("status"));System.out.println(doc.get("created"));System.out.println(doc.get("updated"));System.out.println("=================");}// 关闭资源reader.close();
}

查询结果:


IV. Field域

Field属性

点击TextField类,我们可以看到如下三行源码:


分别对应下面三个属性:

  • 是否分词(Tokenized)

    决定是否对该field存储的内容进行分词,分词的目的,就是为了索引。如果不分词,不代表不索引,而是将整个内容进行索引。比如商品id可以选择不分词整个索引。

  • 是否索引 (Indexed)

    将分好的词进行索引,索引的目的,就是为了搜索。 如果设置不索引,也就是不对该field域进行搜索。比如商品图片,不需要进行索引。甚至也不需要分词。

  • 是否存储 (Stored)

    将Field域中的内容存储到文档域中。存储的目的,就是为了搜索页面显示取值用的。设置否则不将内容存储到文档域中,搜索页面中没法获取该Field域的值。比如商品详情,商品详情往往需要分词检索,但是不需要取值供页面显示。一般通过商品id,点击进入商品详情展示页。

Field常用类型

Field类 数据类型 是否分词 是否索引 是否存储
StringField(FieldName, FieldValue,Store.YES)) 字符串 N Y Y/N
StoredField(FieldName, FieldValue) 重载方法,支持多种类型 N N Y
TextField(FieldName, FieldValue, Store.NO)或TextField(FieldName, reader) 字符串或流 Y Y Y/N
  • StringField:用来构建一个字符串Field,但是不会进行分词,会将整个串存储在索引中。比如:订单号,身份证号等。是否存储在文档中用Store.YES或Store.NO决定。
  • StoredField:用来构建不同类型Field,不分析,不索引,但要Field存储在文档中。
  • TextField:如果是一个Reader,Lucene猜测内容比较多,会采用Unstored的策略。

重构入门代码

// 将采集到的数据封装到Document对象中
List<Document> docList = new ArrayList<>();
for (Product product : products) {Document document = new Document();// store:如果是yes,则说明存储到文档域中// 商品id 不分词直接索引存储Field id = new StringField("id", product.getId().toString(), Field.Store.YES);// 商品名称 分词索引存储Field title = new TextField("title", product.getTitle(), Field.Store.YES);// 商品卖点 分词索引存储Field sellPoint = new TextField("sellPoint", product.getSellPoint(), Field.Store.YES);// 商品条形码 不分词直接索引存储Field barcode = new StringField("barcode", product.getBarcode(), Field.Store.YES);// 商品图片地址 不分词不索引存储Field image = new StoredField("image", product.getImage());// 商品价格 不分词不索引存储Field price = new StoredField("price", product.getPrice());// 商品类别id 不分词不索引存储Field cid = new StoredField("cid", product.getCid());// 商品数量 不分词不索引存储Field num = new StoredField("num", product.getNum());// 商品状态 不分词不索引存储Field status = new StoredField("status", product.getStatus());// 商品创建日期 不分词不索引存储Field created = new StoredField("created", product.getCreated().toString());// 商品更新日期 不分词不索引存储Field updated = new StoredField("updated", product.getUpdated().toString());// 将field域设置到Document对象中document.add(id);document.add(title);document.add(sellPoint);document.add(barcode);document.add(image);document.add(price);document.add(cid);document.add(num);document.add(status);document.add(created);document.add(updated);docList.add(document);
}

Luke查看分词结果:


从存储中取出结果:


V. 索引维护

一旦商品信息发生变化,对应索引库相对应也需要进行改变。

添加索引

IndexWriter.addDocument(Document document);

新增数据库一条商品记录:


继续利用入门案列创建索引即可,Luke查看结果可以看出新增索引成功:


修改索引

IndexWriter.updateDocument(Term term,Document document);

@Test
public void updateIndex() throws Exception {// 创建分词器,标准分词器Analyzer analyzer = new StandardAnalyzer();// 创建IndexWriterIndexWriterConfig config = new IndexWriterConfig(analyzer);Path indexFile = Paths.get("E:\\search-learn\\data");Directory directory = FSDirectory.open(indexFile);IndexWriter indexWriter = new IndexWriter(directory, config);// 第一个参数:指定查询条件// 第二个参数:修改之后的对象// 修改时如果根据查询条件可以查出结果,则将其删掉,并进行覆盖新的doc;否则直接新增一个docDocument doc = new Document();doc.add(new TextField("name","pingpong", Field.Store.YES));indexWriter.updateDocument(new Term("name", "xiaoming"), doc);indexWriter.close();
}

原本没有name的Field,所以会直接新增“pingpong”:


将代码的pingpong和xiaoming互换,再次更新,此时即会更新name的域索引:


删除索引

  • 删除全部:IndexWriter.deleteAll();

  • 条件删除

    Term是索引域中最小的单位。根据条件删除时,建议根据唯一键来进行删除。在solr中就是根据ID来进行删除和修改操作的。

    @Test
    public void deleteByCondition() throws Exception {// 创建分词器,标准分词器Analyzer analyzer = new StandardAnalyzer();// 创建IndexWriterIndexWriterConfig config = new IndexWriterConfig(analyzer);Path indexFile = Paths.get("E:\\search-learn\\data");Directory directory = FSDirectory.open(indexFile);IndexWriter indexWriter = new IndexWriter(directory, config);// TermsindexWriter.deleteDocuments(new Term("barcode", "323233"));indexWriter.close();
    }

VI. 搜索

查询对象创建方式

  • 通过Query子类来创建查询对象
  • 通过QueryParser来创建查询对象(常用)

通过Query子类创建

Query子类常用的有:TermQuery、TermRangeQuery、BooleanQuery等。这种方式不能输入lucene的查询语法,不需要指定分词器。

  • 精确的词项查询 TermQuery

    @Test
    public void termQuery() throws IOException {Query query = new TermQuery(new Term("id", "998692"));doSearch(query);
    }private void doSearch(Query query) throws IOException {// 创建IndexSearcher// 指定索引库的地址Path indexFile = Paths.get("E:\\search-learn\\data");Directory directory = FSDirectory.open(indexFile);IndexReader reader = DirectoryReader.open(directory);IndexSearcher indexSearcher = new IndexSearcher(reader);// 通过searcher来搜索索引库// 第二个参数:指定需要显示的顶部记录的N条TopDocs topDocs = indexSearcher.search(query, 2);ScoreDoc[] scoreDocs = topDocs.scoreDocs;for (ScoreDoc scoreDoc : scoreDocs) {// 获取文档的IDint docId = scoreDoc.doc;// 通过ID获取文档Document doc = indexSearcher.doc(docId);System.out.println(doc.get("id"));System.out.println(doc.get("title"));System.out.println(doc.get("sellPoint"));System.out.println(doc.get("barcode"));System.out.println(doc.get("image"));System.out.println(doc.get("price"));System.out.println(doc.get("cid"));System.out.println(doc.get("num"));System.out.println(doc.get("status"));System.out.println(doc.get("created"));System.out.println(doc.get("updated"));System.out.println("=================");}// 关闭资源reader.close();
    }
  • 范围查询TermRangeQuery

    @Test
    public void termRangeQuery() throws IOException {Query query = new TermRangeQuery("id", new BytesRef("123455"), new BytesRef("123457"), true, true);doSearch(query);
    }
  • 组合查询BooleanQuery

    @Test
    public void booleanQuery() throws IOException {Query query1 = new TermQuery(new Term("barcode", "323233"));Query query2 = new TermQuery(new Term("sellPoint", "手"));BooleanQuery.Builder builder = new BooleanQuery.Builder();builder.add(query1, BooleanClause.Occur.MUST);builder.add(query2, BooleanClause.Occur.SHOULD);BooleanQuery query = builder.build();doSearch(query);
    }

    组合关系代表的意思如下:

    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表示“或”的概念。

通过QueryParser类创建

QueryParser、MultiFieldQueryParser等,可以输入lucene的查询语法、可以指定分词器。

  • QueryParser

    通过QueryParser来创建query对象,可以指定分词器,搜索时的分词器和创建该索引的分词器一定要一致。还可以输入查询语句。具体参考入门案例

  • 多域查询MultiFieldQueryParser

    @Test
    public void multiFieldQuery() throws Exception {// 默认搜索多个域名String[] fields = {"title", "sellPoint"};MultiFieldQueryParser parser = new MultiFieldQueryParser(fields, new StandardAnalyzer());Query query = parser.parse("手"); // 相当于title:手 OR sellPoint:手doSearch(query);
    }

TopDocs

Lucene搜索结果可通过TopDocs遍历,TopDocs类提供了少量的属性,如下:

属性 说明
long totalHits 匹配搜索条件的总记录数
ScoreDoc[] scoreDocs 顶部匹配记录
float maxScore 最高匹配度

VII. 相关度排序

相关度排序就是查询关键字与查询结果的匹配相关度。匹配越高的越靠前。Lucene是通过打分来进行相关度排序的。

打分分两步:

  1. 根据词计算词的权重
  2. 根据词的权重进行打分

词的权重:词指的就是Term。也就是说一个Term对一个文档的重要性,就叫词的权重。影响词的权重的方式有两种:

  • Tf (词在同一个文档中出现的频率)——Tf越高,说明词的权重越高
  • Df(词在多个文档中出现的频率)——Df越高,说明词的权重越低

以上是自然打分的规则,Lucene可以通过设置Boost值来进行手动调整。设置加权值可以在创建索引时设置,也可以在查询时设置。

创建索引时设置

新版索引时设置权值被废除,最新API如何使用还没有研究清楚 :(

查询时设置

  • BoostQuery

    @Test
    public void booleanQuery() throws IOException {Query query1 = new TermQuery(new Term("title", "电"));Query query2 = new TermQuery(new Term("sellPoint", "手"));// 利用BoostQuery包装QueryBoostQuery query3 = new BoostQuery(query2, 100f);BooleanQuery.Builder builder = new BooleanQuery.Builder();builder.add(query3, BooleanClause.Occur.MUST);builder.add(query2, BooleanClause.Occur.MUST);BooleanQuery query = builder.build();doSearch(query);
    }
  • MultiFieldQueryParser

    @Test
    public void multiFieldQuery() throws Exception {String[] fields = {"title", "sellPoint"};Map<String, Float> boosts = new HashMap<>();boosts.put("title", 100f);// 利用MultiFieldQueryParser构造函数传参MultiFieldQueryParser parser = new MultiFieldQueryParser(fields, new StandardAnalyzer(), boosts);Query query = parser.parse("通"); doSearch(query);
    }

VIII. 中文分词

Lucence自带分词器,对于中文分词支持不是很好。所以需要第三方中文分词器。


IK-analyzer

最新版在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月后没有在更新。

下载:支持Lucence7.x版本

将jar包加入工程,修改创建索引的分词器为中文分词器:

// 创建分词器,中文分词器
Analyzer analyzer = new IKAnalyzer();

创建索引结果:


自定义词库

添加配置文件在工程的classpath下:

  • ext.dic 自定义扩展词
  • stopword.dic 自定义停用词
  • IKAnalyzer.cfg.xml 配置文件

JavaEE进阶——全文检索之Lucene框架相关推荐

  1. 双向最大匹配算法思想详解,分词器及全文检索工具及Lucene框架简介

    一.中文分词理论描述 前言 这篇将使用Java实现基于规则的中文分词算法,一个中文词典将实现准确率高达85%的分词结果.使用经典算法:正向最大匹配和反向最大匹配算法,然后双剑合璧,双向最大匹配. 根据 ...

  2. dept在Java里面_EmpDeptManager 在JavaEE环境下搭建三大框架体系实现员工的增删改查系统 Develop 261万源代码下载- www.pudn.com...

    文件名称: EmpDeptManager下载  收藏√  [ 5  4  3  2  1 ] 开发工具: Java 文件大小: 39 KB 上传时间: 2016-07-08 下载次数: 0 提 供 者 ...

  3. 基于Lucene框架的“虎扑篮球”网站搜索引擎(java版)

    1  引言 本次作业完成了基于Lucene的"虎扑篮球"网站搜索引擎,对其主要三个板块---"最新新闻"(主要NBA新闻),"虎扑步行街"( ...

  4. 全文检索工具Lucene入门教程

    目录 1.什么是Lucene 1.1什么是全文检索 1.2 全文检索的应用场景 1.3. 如何实现全文检索 2.Lucene实现全文检索的流程 2.1. 创建索引和搜索流程图 2.2. 创建索引 2. ...

  5. lucene框架的学习

    目录 一.lucene是什么 二.lucene解决什么问题 三.lucene技术概览 3.1.lucene的架构图 3.2.lucene的相关模块 3.3.lucene的术语,常用对象 四.lucen ...

  6. aop拦截mybatis执行sql_Java进阶架构之开源框架面试题系列:Spring+SpringMVC+MyBatis

    开源框架 Spring5 Framework体系结构 spring5概述 Spring5环境搭建 Spring MVC AOP源码解析 IOC源码解析 Mybatis spring 什么是Spring ...

  7. Python 爬虫进阶一之爬虫框架概述

    综述 爬虫入门之后,我们有两条路可以走. 一个是继续深入学习,以及关于设计模式的一些知识,强化 Python 相关知识,自己动手造轮子,继续为自己的爬虫增加分布式,多线程等功能扩展.另一条路便是学习一 ...

  8. 全文检索工具包Lucene

    什么是全文检索 数据的分类 结构化数据:指的是格式固定.长度固定.数据类型固定的数据,例如数据库中的数据. 非结构化数据:指的是格式不固定.长度不固定.数据类型不固定的数据,例如 word 文档.pd ...

  9. 【手把手教你全文检索】Lucene索引的【增、删、改、查】

    2019独角兽企业重金招聘Python工程师标准>>> 前言 搞检索的,应该多少都会了解Lucene一些,它开源而且简单上手,官方API足够编写些小DEMO.并且根据倒排索引,实现快 ...

最新文章

  1. Spring单例的线程安全性
  2. 718. Maximum Length of Repeated Subarray 最长重复子数组
  3. Golang教程:常量
  4. Java EE 6测试第I部分– EJB 3.1可嵌入API
  5. 3 配置ftp文件服务器,03-FTP和TFTP配置
  6. 暑假集训-个人赛第六场
  7. 机器学习基础(六十)—— 凸优化
  8. cisco端口排错步骤
  9. Criteo数据集探索
  10. 一文读懂各种分布式机器学习框架的区别与联系
  11. 改变图片强调可修改r,如s.val[i]*scale*r
  12. linux rm 文件找回_Linux下用rm删除的文件的恢复方法
  13. 数据库相关类型(日期、复合、bit、布尔)
  14. Vue3项目报错[vue/no-template]
  15. 理解ES6中暂时性死区TDZ
  16. 爱聚云店宝,荣获 “中国新零售联盟联合发起人”单位
  17. ES 创建索引设置(setting)基础
  18. 验证码识别PaddleOCR 快速开始
  19. 一个大二博主的一年来写博的年终总结与未来展望——致2019努力的自己,也致2020更好的自己
  20. 冈萨雷斯《数字图像处理(第三版)》中文版纠错

热门文章

  1. CAD视口与模型的线型比例保持一致的命令
  2. 【已解决】程序异常终止:Process finished with exit code -1073741819 (0xC0000005)
  3. 用十种编程需语言说新年快乐_整理新年手机和PC的10种方法
  4. react cron表达式生成组件qnn-react-cron
  5. 信号完整性研究系列--什么是信号完整性
  6. mfc中如何使用全局变量进行数据共享
  7. 封装bootstrap-treegrid组件
  8. SketchMaster滤镜中文版
  9. 小伙利用Python绘制999种玫瑰花表白女神,会编程男孩子真好
  10. 使用 strace、tcpdump、nlmon、wireshark 探索 ethtool netlink 框架的原理