Lucene還可以這樣玩?SpringBoot集成Lucene實(shí)現(xiàn)自己的輕量級(jí)搜索引擎(附源碼)

前言

哈嘍,大家好,我是丸子。

搜索引擎想必大家都并不陌生,比如百度,谷歌都是常見的搜索引擎。

在我們實(shí)際的項(xiàng)目開發(fā)中,也經(jīng)常遇到類似的業(yè)務(wù)需求,比如公司要開發(fā)一個(gè)知識(shí)庫(kù)項(xiàng)目,知識(shí)庫(kù)里有上百萬(wàn)條文章,要求我們能夠輸入關(guān)鍵字,查詢出包含有關(guān)鍵字的文章內(nèi)容,并且對(duì)關(guān)鍵字進(jìn)行高亮處理,顯示查詢后的最佳摘要,這個(gè)時(shí)候傳統(tǒng)的數(shù)據(jù)庫(kù)LIKE查詢雖然能勉強(qiáng)滿足業(yè)務(wù)需求,但是查詢速度令人無(wú)法忍受,這個(gè)時(shí)候就需要借助搜索引擎來(lái)進(jìn)行處理。

在Java開發(fā)領(lǐng)域,Lucene可以算是開山鼻祖,現(xiàn)在常用的SolrElasticSearch底層都是基于Lucene,很多開發(fā)人員并沒(méi)有系統(tǒng)的學(xué)習(xí)過(guò)Lucene,都是直接上手SolrElasticSearch進(jìn)行開發(fā),但實(shí)際上掌握Lucene的常用api,理解其底層原理還是比較重要的,這有利于我們對(duì)全文檢索領(lǐng)域有更加深入的理解,同時(shí)我們也可以根據(jù)自己的業(yè)務(wù)需求定制個(gè)性化的搜索引擎,我所在的公司使用的就是基于Lucene自研的搜索引擎服務(wù),針對(duì)公司獨(dú)特的業(yè)務(wù)場(chǎng)景,使用起來(lái)特別方便。

本篇文章將詳細(xì)講解如何使用SpringBoot集成Lucene實(shí)現(xiàn)自己的輕量級(jí)搜索引擎,相關(guān)源碼資料可以查看文末獲取!

Lucene為什么查的快

Lucene之所以查的快,原因在于它內(nèi)部使用了倒排索引算法,在這里簡(jiǎn)單的介紹一下原理:
普通查詢是根據(jù)文章找關(guān)鍵字,而倒排索引是根據(jù)關(guān)鍵字找文章!

比如“我今天很開心,因?yàn)轳R上就要下班了”這句話,從中搜索“開心”,普通查詢要遍歷整句話,直到找到“開心”二字為止,效率低下。倒排索引則是對(duì)整句話使用分詞器進(jìn)行分詞處理,從而“開心”二字可以直接指向這句話,搜索的時(shí)候直接就可以根據(jù)“開心”搜到所屬的內(nèi)容,達(dá)到快速響應(yīng)的效果。

springBoot集成Lucene

下面我會(huì)以Demo的形式詳細(xì)講解springBoot如何集成Lucene實(shí)現(xiàn)增刪查改,以及顯示高亮和最佳摘要(demo全部資料和源碼在文末獲?。?/p>

一.建表

以Mysql為例,創(chuàng)建數(shù)據(jù)庫(kù)lucene_demo,建表article,作為數(shù)據(jù)源,之后對(duì)表內(nèi)容進(jìn)行增刪查改的時(shí)候同步到Lucene索引數(shù)據(jù),建表語(yǔ)句如下:

CREATE TABLE `article` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
  `title` varchar(200) DEFAULT NULL COMMENT '標(biāo)題',
  `content` longtext COMMENT '內(nèi)容',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

二.創(chuàng)建SpringBoot項(xiàng)目

在這里我直接拿自己的代碼生成器生成,配置好基礎(chǔ)內(nèi)容點(diǎn)擊生成,即可生成一個(gè)完整的前后臺(tái)項(xiàng)目框架,省去了搭建項(xiàng)目的繁瑣步驟,這樣我們可以在生成的代碼基礎(chǔ)上進(jìn)行開發(fā):

代碼生成器

生成的項(xiàng)目結(jié)構(gòu)和代碼如下:
生成的項(xiàng)目

三.引入Lucene相關(guān)依賴

pom.xml引入Lucene相關(guān)依賴:

<dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-core</artifactId>
            <version>8.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-analyzers-common</artifactId>
            <version>8.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-queryparser</artifactId>
            <version>8.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-highlighter</artifactId>
            <version>8.1.0</version>
        </dependency>

四.引入IK分詞器依賴

目前市面上有不少中文分詞器,但最受歡迎的還是IK分詞器,Lucene自帶的分詞器對(duì)中文只能單字拆分,顯然不符合我們的需求,但I(xiàn)K分詞器解決了這個(gè)問(wèn)題,他可以把一段話分成多組不同的中文單詞,幫助建立搜索索引。

公共maven倉(cāng)庫(kù)中沒(méi)有IK分詞器的依賴,需要我們install一下,文末資料中有IK分詞器的源碼,可以導(dǎo)入idea直接install到自己的maven倉(cāng)庫(kù),然后引入依賴到項(xiàng)目即可。


install

pom.xml引入Ik分詞器相關(guān)依賴(因?yàn)橹耙呀?jīng)引入了Lucene相關(guān)依賴,所以引入Ik的時(shí)候去除一下,防止依賴沖突):

 <dependency>
            <groupId>org.wltea.ik-analyzer</groupId>
            <artifactId>ik-analyzer</artifactId>
            <version>8.1.0</version>
            <exclusions>
                <exclusion>
                    <groupId>org.apache.lucene</groupId>
                    <artifactId>lucene-analyzers-common</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.apache.lucene</groupId>
                    <artifactId>lucene-queryparser</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.apache.lucene</groupId>
                    <artifactId>lucene-core</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-api</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

五.項(xiàng)目啟動(dòng)時(shí)加載IK分詞器

最好在我們啟動(dòng)項(xiàng)目的時(shí)候就把IK分詞器加載進(jìn)內(nèi)存當(dāng)中,這樣第一次查詢就不必再進(jìn)行加載,避免第一次查詢因?yàn)榧虞d分詞器造成卡頓,創(chuàng)建init包,建立BusinessInitializer類,如下:

初始化加載IK分詞器

代碼如下:

package lucenedemo.init;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.wltea.analyzer.cfg.DefaultConfig;
import org.wltea.analyzer.dic.Dictionary;

/**
 * 業(yè)務(wù)初始化器
 *
 * @author zrx
 */
@Component
public class BusinessInitializer implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        //加載ik分詞器配置 防止第一次查詢慢
        Dictionary.initial(DefaultConfig.getInstance());
    }
}

引入IK的配置文件IKAnalyzer.cfg.xml以及擴(kuò)展字典ext.dic和停止詞字典stopword.dic,可以添加和屏蔽某些詞語(yǔ),把配置文件放入resources下:

Ik配置文件

在這里我們添加兩個(gè)擴(kuò)展詞小螺旋丸小千鳥,查詢的時(shí)候可以用來(lái)做測(cè)試,如果測(cè)試的時(shí)候可以被完整標(biāo)記高亮,說(shuō)明詞語(yǔ)被成功識(shí)別,因?yàn)镮K自帶的字典里,沒(méi)有這兩個(gè)單詞,IK自帶的字典位于IK源碼的resources包下,感興趣的朋友可以通過(guò)源碼自行查看:
添加擴(kuò)展詞

添加完畢,我們啟動(dòng)項(xiàng)目,發(fā)現(xiàn)詞典被成功加載,如下:
詞典被加載

接下來(lái)我們進(jìn)行增刪查改的開發(fā)。

六.增刪查改業(yè)務(wù)開發(fā):

1、配置索引庫(kù)存放位置

首先我們需要配置索引的存放位置,可以把它理解為一個(gè)數(shù)據(jù)庫(kù),只不過(guò)這個(gè)數(shù)據(jù)庫(kù)存放的是一些索引文件,我們?cè)趛ml中指定位置,創(chuàng)建Config配置類,用@value注解獲取它的值,方便隨時(shí)在代碼中獲取,如下:

yml和Config

2、增刪查改的時(shí)候同步索引

數(shù)據(jù)庫(kù)的增刪查改方法代碼生成器已經(jīng)幫助我們生成完畢,只需要在原來(lái)的功能基礎(chǔ)上添加對(duì)于索引庫(kù)相關(guān)的代碼邏輯即可!

首先是添加和更新操作,添加更新放在一起,根據(jù)主鍵id判斷,如果索引中存在此id,則更新,否則添加,在service實(shí)現(xiàn)類中添加addOrUpIndex方法,同時(shí)每次添加和更新的時(shí)候都要調(diào)一下此方法,同步索引,代碼基本每一行都有完整注釋,如下:

/**
     * mapper文件里增加 useGeneratedKeys="true" keyProperty="id" keyColumn="id"屬性,否則自增主鍵映射不上
     *
     * @param entity
     */
    @Override
    public void add(ArticleEntity entity) {
        dao.add(entity);
        addOrUpIndex(entity);
    }

    @Override
    public void update(ArticleEntity entity) {
        dao.update(entity);
        addOrUpIndex(entity);
    }

    /**
     * 添加或更新索引
     * @param entity
     */
    private void addOrUpIndex(ArticleEntity entity) {
        IndexWriter indexWriter = null;
        IndexReader indexReader = null;
        Directory directory = null;
        Analyzer analyzer = null;
        try {
            //創(chuàng)建索引目錄文件
            File indexFile = new File(config.getIndexLibrary());
            File[] files = indexFile.listFiles();
            // 1. 創(chuàng)建分詞器,分析文檔,對(duì)文檔進(jìn)行分詞
            analyzer = new IKAnalyzer();
            // 2. 創(chuàng)建Directory對(duì)象,聲明索引庫(kù)的位置
            directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
            // 3. 創(chuàng)建IndexWriteConfig對(duì)象,寫入索引需要的配置
            IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
            // 4.創(chuàng)建IndexWriter寫入對(duì)象
            indexWriter = new IndexWriter(directory, writerConfig);
            // 5.寫入到索引庫(kù),通過(guò)IndexWriter添加文檔對(duì)象document
            Document doc = new Document();
            //查詢是否有該索引,沒(méi)有添加,有則更新
            TopDocs topDocs = null;
            //判斷索引目錄文件是否存在文件,如果沒(méi)有文件,則為首次添加,有文件,則查詢id是否已經(jīng)存在
            if (files != null && files.length != 0) {
                //創(chuàng)建查詢對(duì)象
                QueryParser queryParser = new QueryParser("id", analyzer);
                Query query = queryParser.parse(String.valueOf(entity.getId()));
                indexReader = DirectoryReader.open(directory);
                IndexSearcher indexSearcher = new IndexSearcher(indexReader);
                //查詢獲取命中條目
                topDocs = indexSearcher.search(query, 1);
            }
            //StringField 不分詞 直接建索引 存儲(chǔ)
            doc.add(new StringField("id", String.valueOf(entity.getId()), Field.Store.YES));
            //TextField 分詞 建索引 存儲(chǔ)
            doc.add(new TextField("title", entity.getTitle(), Field.Store.YES));
            //TextField 分詞 建索引 存儲(chǔ)
            doc.add(new TextField("content", entity.getContent(), Field.Store.YES));
            //如果沒(méi)有查詢結(jié)果,添加
            if (topDocs != null && topDocs.totalHits.value == 0) {
                indexWriter.addDocument(doc);
                //否則,更新
            } else {
                indexWriter.updateDocument(new Term("id", String.valueOf(entity.getId())), doc);
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("添加索引庫(kù)出錯(cuò):" + e.getMessage());
        } finally {
            if (indexWriter != null) {
                try {
                    indexWriter.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (indexReader != null) {
                try {
                    indexReader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (directory != null) {
                try {
                    directory.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (analyzer != null) {
                analyzer.close();
            }
        }
    }

代碼應(yīng)該很容易就可以看明白,這里我們把實(shí)體的titlecontent進(jìn)行分詞,并存儲(chǔ)為索引文件,所以接下來(lái)查詢的時(shí)候也要根據(jù)這兩個(gè)字段來(lái)進(jìn)行查詢,查詢的時(shí)候我們要對(duì)查詢結(jié)果進(jìn)行分頁(yè),Lucene的分頁(yè)方式比較特別,他沒(méi)有類似數(shù)據(jù)庫(kù)那種提供開始和結(jié)束下標(biāo)定位元素的方法,而是只能指定查詢的總條目數(shù),然后把所有的命中結(jié)果查詢出來(lái),比如一共有100條數(shù)據(jù),查詢第一頁(yè)返回10條,查詢第十頁(yè)則會(huì)返回100條,需要我們?cè)谶壿嬌蠈?duì)查詢結(jié)果進(jìn)行分頁(yè),取我們想要的數(shù)據(jù),也可以利用Luncene提供的SearchAfter方法進(jìn)行查詢,它可以根據(jù)指定的最后一個(gè)元素查詢接下來(lái)指定數(shù)目的元素,但這需要我們查詢出前n個(gè)元素然后取最后一個(gè)元素傳給SearchAfter方法,兩種方法效率上并沒(méi)有太大區(qū)別,畢竟Lucene本身就很快。但這也涉及到一個(gè)問(wèn)題,如果查詢的數(shù)據(jù)量過(guò)多,比如上千萬(wàn)條可能會(huì)導(dǎo)致內(nèi)存溢出,這就需要我們根據(jù)業(yè)務(wù)做一個(gè)取舍,用戶在查詢的時(shí)候通常只會(huì)看前幾頁(yè)的數(shù)據(jù),所以我們可以指定一下最大的查詢數(shù)量,比如10000條,無(wú)論實(shí)際符合條件的結(jié)果有多少,我們最多只查詢前10000條,這樣問(wèn)題便得到解決,其實(shí)很多搜索引擎也是這樣做的!

如果你說(shuō)我就要看全部的數(shù)據(jù),那就涉及到了數(shù)據(jù)的分布式存儲(chǔ),在分頁(yè)的時(shí)候就需要每臺(tái)服務(wù)器進(jìn)行查詢?nèi)缓髤R總查詢結(jié)果,這里的問(wèn)題就比較復(fù)雜了,在此處不做深究,以后可以專門聊一聊,其實(shí)業(yè)界已經(jīng)有了幾種比較成熟的解決方案,可以較好的解決分布式存儲(chǔ)的分頁(yè)問(wèn)題。

這里代碼中并沒(méi)有指定查詢的最大數(shù)量,畢竟是個(gè)demo,沒(méi)必要弄的這么復(fù)雜,代碼如下:

    @Override
    public PageData<ArticleEntity> fullTextSearch(String keyWord, Integer page, Integer limit) {
        List<ArticleEntity> searchList = new ArrayList<>(10);
        PageData<ArticleEntity> pageData = new PageData<>();
        File indexFile = new File(config.getIndexLibrary());
        File[] files = indexFile.listFiles();
        //沒(méi)有索引文件,不然沒(méi)有查詢結(jié)果
        if (files == null || files.length == 0) {
            pageData.setCount(0);
            pageData.setTotalPage(0);
            pageData.setCurrentPage(page);
            pageData.setResult(new ArrayList<>());
            return pageData;
        }
        IndexReader indexReader = null;
        Directory directory = null;
        try (Analyzer analyzer = new IKAnalyzer()) {
            directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
            //多項(xiàng)查詢條件
            QueryParser queryParser = new MultiFieldQueryParser(new String[]{"title", "content"}, analyzer);
            //單項(xiàng)
            //QueryParser queryParser = new QueryParser("title", analyzer);
            Query query = queryParser.parse(!StringUtils.isEmpty(keyWord) ? keyWord : "*:*");
            indexReader = DirectoryReader.open(directory);
            //索引查詢對(duì)象
            IndexSearcher indexSearcher = new IndexSearcher(indexReader);
            TopDocs topDocs = indexSearcher.search(query, 1);
            //獲取條數(shù)
            int total = (int) topDocs.totalHits.value;
            pageData.setCount(total);
            int realPage = total % limit == 0 ? total / limit : total / limit + 1;
            pageData.setTotalPage(realPage);
            //獲取結(jié)果集
            ScoreDoc lastSd = null;
            if (page > 1) {
                int num = limit * (page - 1);
                TopDocs tds = indexSearcher.search(query, num);
                lastSd = tds.scoreDocs[num - 1];
            }
            //通過(guò)最后一個(gè)元素去搜索下一頁(yè)的元素 如果lastSd為null,查詢第一頁(yè)
            TopDocs tds = indexSearcher.searchAfter(lastSd, query, limit);
            QueryScorer queryScorer = new QueryScorer(query);
            //最佳摘要
            SimpleSpanFragmenter fragmenter = new SimpleSpanFragmenter(queryScorer, 200);
            //高亮前后標(biāo)簽
            SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("<b><font color='red'>", "</font></b>");
            //高亮對(duì)象
            Highlighter highlighter = new Highlighter(formatter, queryScorer);
            //設(shè)置高亮最佳摘要
            highlighter.setTextFragmenter(fragmenter);
            //遍歷查詢結(jié)果 把標(biāo)題和內(nèi)容替換為帶高亮的最佳摘要
            for (ScoreDoc sd : tds.scoreDocs) {
                Document doc = indexSearcher.doc(sd.doc);
                ArticleEntity articleEntity = new ArticleEntity();
                Integer id = Integer.parseInt(doc.get("id"));
                //獲取標(biāo)題的最佳摘要
                String titleBestFragment = highlighter.getBestFragment(analyzer, "title", doc.get("title"));
                //獲取文章內(nèi)容的最佳摘要
                String contentBestFragment = highlighter.getBestFragment(analyzer, "content", doc.get("content"));
                articleEntity.setId(id);
                articleEntity.setTitle(titleBestFragment);
                articleEntity.setContent(contentBestFragment);
                searchList.add(articleEntity);
            }
            pageData.setCurrentPage(page);
            pageData.setResult(searchList);
            return pageData;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("全文檢索出錯(cuò):" + e.getMessage());
        } finally {
            if (indexReader != null) {
                try {
                    indexReader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (directory != null) {
                try {
                    directory.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

最后是刪除索引,根據(jù)唯一標(biāo)識(shí)id刪除即可,代碼如下:

    @Override
    public void delete(ArticleEntity entity) {
        dao.delete(entity);
        //同步刪除索引
        deleteIndex(entity);
    }

    private void deleteIndex(ArticleEntity entity) {
        //刪除全文檢索
        IndexWriter indexWriter = null;
        Directory directory = null;
        try (Analyzer analyzer = new IKAnalyzer()) {
            directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
            IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
            indexWriter = new IndexWriter(directory, writerConfig);
            //根據(jù)id字段進(jìn)行刪除
            indexWriter.deleteDocuments(new Term("id", String.valueOf(entity.getId())));
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("刪除索引庫(kù)出錯(cuò):" + e.getMessage());
        } finally {
            if (indexWriter != null) {
                try {
                    indexWriter.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (directory != null) {
                try {
                    directory.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

至此,Lucene的后臺(tái)增刪查改功能開發(fā)完畢!

3、利用swagger測(cè)試

接下來(lái)我們利用swagger對(duì)功能進(jìn)行測(cè)試,測(cè)試之前我們把controller層增刪查改方法的 @LoginRequired 注解去掉(@LoginRequired是代碼生成器最新版添加的注解,可以控制方法必須登錄才可以調(diào)用),這樣可以不必登錄,打開swagger,添加一條數(shù)據(jù),如下:


添加數(shù)據(jù)

如上,數(shù)據(jù)添加成功,數(shù)據(jù)庫(kù)數(shù)據(jù)添加成功,Lucene索引文件夾也生成了相關(guān)索引文件,如下:


數(shù)據(jù)庫(kù)
Lucene索引文件

接下里我們測(cè)一下全文檢索功能,如下:
查詢結(jié)果

刪除功能也可正常使用并同步刪除索引,此處就不截圖了。這樣一來(lái),后臺(tái)api測(cè)試完畢,符合預(yù)期效果,接下來(lái)進(jìn)入前臺(tái)實(shí)現(xiàn)階段。

4、前臺(tái)實(shí)現(xiàn)

前臺(tái)實(shí)現(xiàn)沒(méi)有什么好說(shuō)的,就是跟后端對(duì)接口進(jìn)行交互,前端真是我的硬傷,我根據(jù)代碼生成器生成的列表頁(yè)做了調(diào)整,最終實(shí)現(xiàn)效果如下:


前端效果

前臺(tái)代碼就不貼了,沒(méi)有太大意義,畢竟有了后臺(tái)的數(shù)據(jù)返回,前臺(tái)有n多種展示方式,大家根據(jù)自己的習(xí)慣去對(duì)接口就好了,完整的前后臺(tái)代碼以及sql文件等可于文末獲取。

結(jié)語(yǔ)

本篇文章我們利用Lucene自己實(shí)現(xiàn)了一個(gè)非常輕量的搜索引擎,其實(shí)我們可以利用反射把它做成一個(gè)通用的查詢框架,這樣無(wú)論實(shí)體的屬性名稱怎么變,都可以靈活應(yīng)對(duì)。

全文檢索在Java開發(fā)領(lǐng)域是一個(gè)重要的知識(shí)點(diǎn),需要我們深入理解和掌握,希望通過(guò)本篇文章可以讓你對(duì)Lucene有一個(gè)更加全面的認(rèn)識(shí),代碼生成器不出意外本月會(huì)更新一版,我們下次更新,再見啦!

附:關(guān)注公眾號(hào) 螺旋編程極客 獲取更多精彩內(nèi)容,我們一起進(jìn)步,一起成長(zhǎng),回復(fù) 1024 可獲取本篇文章的項(xiàng)目源碼等資料,期待您的關(guān)注!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容