ElasticSearch 2 (17) - 深入搜索系列之部分匹配


 

摘要

到目前為止,我們介紹的所有查詢都是基於完整術語的,為了匹配,最小的單元為單個術語,我們只能查找反向索引中存在的術語。

但是,如果我們想匹配部分術語而不是全部改怎么辦?部分匹配(Partial matching) 允許用戶指定查找術語的一部分,然后找出所有包含這部分片段的詞。

與我們想象的不一樣,需要對術語進行部分匹配的需求在全文搜索引擎的世界並不常見,但是如果讀者有SQL方面的背景,可能會在某個時候使用下面的SQL語句對全文進行搜索:

WHERE text LIKE "%quick%" AND text LIKE "%brown%" AND text LIKE "%fox%" #1
  • #1 *fox* 會與 “fox” 和 “foxes” 匹配

當然,在ElasticSearch中,我們有分析過程,反向索引讓我們不需要使用這種蠻力。為了解決同時匹配“fox”和“foxes”的應用場景,我們只需要簡單的將它們的詞干作為索引形式,不需要做部分匹配。

有人說,在某些情況下部分匹配會比較有用,這些應用場景如下:

  • 匹配郵編,產品序列號,或其他未分析(not_analyzed)值,它們可以是以某個特定前綴開始,可以是與某種模式匹配,也可以是與某個正則式相匹配。
  • 輸入即查詢——在用戶輸入搜索術語的時候就向用戶呈現最可能的結果。
  • 匹配如德語或荷蘭語這樣的語言,他們有很長的組合詞,如:Weltgesundheitsorganisation (World Health Organization) 世界衛生組織。

我們的介紹會始於一個not_analyzed准確值字段的前綴匹配。

版本

elasticsearch版本: elasticsearch-2.x

內容

郵編與結構化數據(Postcodes and Structured Data)

我們會使用美國的郵編(UK postcodes)來說明如何用部分匹配查詢結構化數據。這種格式的郵編有着良好的結構定義。例如,郵編 W1V 3DG 可以分解為:

  • W1V:這是郵編的外部,它定義了郵件的區域和行政區:

    • W 代表區域(1或2個字母)
    • 1V 代表行政區(1或2個數字,可能跟着一個字符)
  • 3DG:內部定義了街道或建築:

    • 3 代表街區區塊(1個數字)
    • DG 代表單元(2個字母)

我們假設將郵編作為 not_analyzed 的准確值字段索引,我們可以為其創建索引:

PUT /my_index { "mappings": { "address": { "properties": { "postcode": { "type": "string", "index": "not_analyzed" } } } } } 

然后索引一些郵編:

PUT /my_index/address/1 { "postcode": "W1V 3DG" } PUT /my_index/address/2 { "postcode": "W2F 8HW" } PUT /my_index/address/3 { "postcode": "W1F 7HW" } PUT /my_index/address/4 { "postcode": "WC1N 1LZ" } PUT /my_index/address/5 { "postcode": "SW5 0BE" }

現在這些數據可供查詢了。

前綴查詢(prefix Query)

為了找到所有以 W1 開始的郵編,我們可以使用簡單的前綴查詢:

GET /my_index/address/_search { "query": { "prefix": { "postcode": "W1" } } }

前綴查詢是一個術語級別的低層次的查詢,在搜索之前它不會分析查詢字符串,它認為傳入的前綴就是想要查找的前綴。

默認狀態下,前綴查詢不做相關度分數計算,它只是將所有匹配的文檔返回,然后賦予所有相關分數值為1。它的行為更像是一個過濾器而不是查詢。兩者實際的區別就是過濾器是可以被緩存的,而前綴查詢不行。

之前我們提到過:“我們只能找到反向索引中存在的術語”,但是我們並沒有對這些郵編的索引做特殊處理,每個郵編還是以它們准確值的方式存在於每個文檔的索引中,那么前綴查詢是如何工作的呢?

回想反向索引包含了一個有序的唯一的術語列表(這個例子中是郵編),對於每個術語,反向索引都會將包含術語的文檔ID列入 關聯列表(postings list)。與我們例子對於的反向索引是:

Term:          Doc IDs:
------------------------- "SW5 0BE" | 5 "W1F 7HW" | 3 "W1V 3DG" | 1 "W2F 8HW" | 2 "WC1N 1LZ" | 4 -------------------------

為了支持前綴匹配,查詢會做以下事情:

  1. 掃描術語列表並查找到第一個以 W1 開始的術語。
  2. 搜集關聯的ID
  3. 移動到下一個術語
  4. 如果這個術語也是以 W1 開頭,查詢跳回到第二步再重復執行,直到下一個術語不以 W1 為止。

這對於小的例子當然可以正常工作,但是如果我們的反向索引中有數以百萬的郵編都是以 W1 開頭時,前綴查詢則需要訪問每個術語然后計算結果。

前綴越短需要訪問的術語越多,如果我們要以 W 作為前綴而不是 W1,那么就可能需要做千萬次的匹配。

前綴查詢或過濾對於一些特定的匹配非常有效,但是使用時還是需要當心,當字段的術語集合很小時,我們可以放心使用,但是它的伸縮性並不好,會對我們的集群帶來很多壓力。可以使用較長的前綴來減小這種壓力,這樣可以大大減少需要訪問的術語數量。

后面我們會介紹一個索引時的解決方案讓前綴匹配更高效,不過在此之前,我們需要先看看兩個相關的查詢:模糊(wildcard) 和 正則(regexp)查詢。

模糊查詢與正則式查詢(wildcard and regexp Queries)

與前綴查詢的特性類似,模糊(wildcard)查詢也是一種低層次基於術語的查詢,與前綴查詢不同的是它可以讓我們給出匹配的正則式。它使用標准的 shell 模糊查詢:? 匹配任意字符,* 匹配0個或多個字符。

這個查詢會匹配包含 W1F 7HW 和 W2F 8HW 的文檔:

GET /my_index/address/_search { "query": { "wildcard": { "postcode": "W?F*HW" #1 } } }
  • #1 ? 匹配 1 和 2,* 與空格以及 7 和 8 匹配。

如果現在我們只想匹配 W 區域的所有郵編,前綴匹配也會匹配以 WC 為開始的所有郵編,與模糊匹配碰到的問題類似,如果我們只想匹配以 W 開始並跟着一個數字的所有郵編,正則式(regexp)查詢讓我們能寫出這樣更復雜的模式:

GET /my_index/address/_search { "query": { "regexp": { "postcode": "W[0-9].+" #1 } } }
  • #1 這個正則表達式要求術語必須以 W 開頭,緊跟0至9之間的任何一個數字,然后跟着1或多個其他字符。

模糊和正則查詢與前綴查詢的工作方式一樣,他們也需要掃描反向索引中的術語列表才能找到所有的匹配術語,然后依次獲得每個術語相關的文檔ID,它與前綴查詢唯一的不同是:它能支持更為復雜的匹配模式。

這也意味着我們需要注意與前綴查詢中相同的性能問題,執行這些查詢可能會消耗非常多的資源,所以我們需要避免使用左模糊這樣的模式匹配(如,*foo 或 .foo* 這樣的正則式)

依靠數據在索引時的准備可以幫助我們提高前綴匹配的效率,但是只能在查詢時處理模糊匹配和正則表達式查詢,雖然這些查詢有他們的應用場景,但仍需謹慎使用。

注意

prefixwildcard 和 regrep 查詢是基於術語操作的,如果我們用它們來查詢分析過的字段(analyzed field),他們會檢查字段里面的每個術語,而不是將字段作為整體進行處理。

例如,我們說 title 包含 “Quick brown fox”會生成術語:quickbrown 和 fox

下面這個查詢會匹配:

{ "regexp": { "title": "br.*" }}

但是下面這兩個查詢都不會匹配:

{ "regexp": { "title": "Qu.*" }} #1 { "regexp": { "title": "quick br*" }} #2
  • #1 在術語表中存在的是 quick 而不是 Quick
  • #2 quick 和 brown 在術語表中是分開的。

查詢時輸入即搜索(Query-Time Search-as-You-Type)

先把郵編的事情放一邊,讓我們先看看前綴查詢是如果在全文查詢中起作用的。用戶已經漸漸習慣在他們輸入查詢內容的時候,搜索的結果就能展現在他們面前,這就是所謂的即時搜索(instant search)或 輸入即搜索(search-as-you-type),不僅用戶能在更短的時間內得到搜素結果,我們也能引導用戶搜索我們索引中存在的結果。

比如,如果用戶輸入 johnnie walker pl,我們希望在用戶結束搜索輸入前就能得到結果:Johnnie Walker Black Label 和Johnnie Walker Blue Label

就像貓的花色遠不只一種,我們希望找到一個最簡單的實現方式。我們並不需要對數據做任何准備,我們在查詢時,就能對任意的全文字段實現 search-as-you-type 的查詢。

短語匹配(Phrase Matching)中,我們引入了 match_phrase 查詢,它匹配相對順序一致的所有文檔,查詢時的輸入即搜索(search-as-you-type),我們可以使用 match_phrase 的一種特殊形式,即 match_phrase_prefix 查詢:

{
    "match_phrase_prefix" : { "brand" : "johnnie walker bl" } } 

這種查詢的行為與 match_phrase 查詢一致,不同的是它將查詢字符串的最后一個詞作為前綴使用,換句話說,可以把前面的例子看成:

  • johnnie
  • 跟着 walker
  • 跟着 一個以 bl 開始的詞

如果我們通過 validate-query 查詢,explaination的結果為:

"johnnie walker bl*"

與 match_phrase 一樣,它也可以接受 slop 參數讓相對詞序位置不那么嚴格:

{
    "match_phrase_prefix" : { "brand" : { "query": "walker johnnie bl", #1 "slop": 10 } } }
  • #1 盡管詞語的順序不正確,查詢仍然能夠匹配,因為我們為它設置了較高的slop值使匹配時的詞序有更大的靈活性。

但是只有最后一個詞才能作為前綴。

在之前的前綴查詢(prefix Query)中,我們提示過前綴使用的風險,即前綴查詢對於資源的消耗嚴重,這種方式(match_phrase_prefix)同樣如此。一個以 a 開頭的前綴可能會匹配成千上萬的術語,這不僅會消耗很多系統資源,而且結果對用戶也用處不大。

我們可以通過設置 max_expansions 參數來限制前綴擴展的影響,一個合理的值是可能是50:

{
    "match_phrase_prefix" : { "brand" : { "query": "johnnie walker bl", "max_expansions": 50 } } }

參數max_expansions控制着可以與前綴匹配的術語的數量,它會先查找第一個與前綴bl匹配的術語,然后依次查找搜集與之匹配的術語,直到沒有更多可以匹配的術語或者當數量超過max_expansions時結束。

不要忘記,當用戶每多輸入一個字符的時候,這個查詢又會執行一遍,所以這個查詢需要非常快速,如果第一個結果集不是用戶想要的,他們會繼續輸入直到能搜索出他們滿意的結果為止。

索引時優化(Index-Time Optimizations)

到目前為止,所有談到的解決方案都是在查詢時(query time)實現的。這樣做並不需要特殊的映射抑或特殊的索引模式,只是簡單的使用已經索引好的數據。

查詢時的靈活性通常會以犧牲搜索性能為代價,有些時候將這些消耗從查詢過程中轉移出去是有其意義的。在實時的網站應用中,100毫秒可能是一個難以忍受的巨大延遲。

我們可以通過在索引時處理數據來提高搜索的靈活性以及系統性能。為此我們仍然需要付出應有的代價:增加的索引空間與變慢的索引能力,但這與每次查詢都需要付出代價不同,在索引時付出的代價是一次性的。

用戶會感謝我們。

Ngram部分匹配(Ngrams for Partial Matching)

我們之前提到:“只能在反向索引中找到存在的術語”,盡管prefixwildcard、以及 regexp 查詢告訴我們這種說法並不完全正確,但單術語查找的確要比在術語列表中盲目挨個查找的效率要高得多。在搜索之前提前准備好供部分匹配的數據會提高搜索的性能。

在索引時准備數據意味着要選擇合適的分析鏈,這里我們使用的工具叫 n-gram。可以把 n-gram 看成一個在詞語上移動的窗子,n 代表這個“窗子”的長度,如果我們說要 n-gram 一個詞 —— quick,它的結果依賴於我們對於這個長度 n 的選擇:

  • 長度 1 (unigram): [ q, u, i, c, k ]
  • 長度 2 (bigram): [ qu, ui, ic, ck ]
  • 長度 3 (trigram): [ qui, uic, ick ]
  • 長度 4 (four-gram): [ quic, uick ]
  • 長度 5 (five-gram): [ quick ]

朴素的 n-gram 對於詞語內部的匹配非常有用,即我們在稍后 Ngram匹配復合詞(Ngrams for Compound Words)介紹的那樣。但是,對於輸入即搜索(search-as-you-type)這種行為,我們會使用一種特殊的n-gram叫做 邊界n-grams(edge n-grams)。所謂的邊界 n-gram 是說它會固定詞語開始的一邊,以單詞 quick 為例,它的邊界n-gram的結果是:

  • q
  • qu
  • qui
  • quic
  • quick

我們會注意到這與用戶在搜索時輸入 “quick” 的次序是一致的,換句話說,這種方式正好滿足即時搜索(instant search)。

索引時輸入即搜索(Index-Time Search-as-You-Type)

設置索引時輸入即搜索的第一步是需要定義好我們的分析鏈,我們在 配置分析器(Configuring Analyzers) 中提到過,但是這里我們會對此再次說明。

准備索引(Preparing the Index)

第一步需要配置一個自定義的token過濾器 edge_ngram,我們將其稱為 autocompleted_filter :

{
    "filter": { "autocomplete_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 } } }

這個配置的意思是:對於所有這個token過濾器收到的術語,它都會為之生成一個n-gram,這個n-gram固定的最小開始的位置為1,最大長度為20。

然后我們會在一個自定義分析器 autocomplete 中使用到上面的這個過濾器:

{
    "analyzer": { "autocomplete": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "autocomplete_filter" #1 ] } } }
  • #1 這里使用的是我們自定義的 邊界Ngram token過濾器。

這個分析器使用標准的標記器將字符串標記為獨立的術語,並且將他們都變成小寫形式,然后為每個術語生成一個邊界Ngram。

創建索引、實例化token過濾器和分析器的完整例子如下:

PUT /my_index { "settings": { "number_of_shards": 1, #1 "analysis": { "filter": { "autocomplete_filter": { #2 "type": "edge_ngram", "min_gram": 1, "max_gram": 20 } }, "analyzer": { "autocomplete": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "autocomplete_filter" ] } } } } }
  • #1 參考 被破壞的相關性(Relevance Is Broken)
  • 首先自定義我們的token過濾器
  • 然后在分析器中使用它

我們可以拿analyze API測試這個新的分析器:

GET /my_index/_analyze?analyzer=autocomplete quick brown

返回正確的術語如下:

  • q
  • qu
  • qui
  • quic
  • quick
  • b
  • br
  • bro
  • brow
  • brown

我們可以用 update-mapping API 將這個分析器應用到具體字段:

PUT /my_index/_mapping/my_type { "my_type": { "properties": { "name": { "type": "string", "analyzer": "autocomplete" } } } }

現在我們創建一些測試文檔:

POST /my_index/my_type/_bulk { "index": { "_id": 1 }} { "name": "Brown foxes" } { "index": { "_id": 2 }} { "name": "Yellow furballs" }

查詢字段(Querying the Field)

如果我們使用簡單 match 查詢測試查詢“brown fo”:

GET /my_index/my_type/_search { "query": { "match": { "name": "brown fo" } } }

我們可以看到兩個文檔同時都能匹配,盡管 Yellow furballs 這個文檔並不包含 brown 和 fo

{

  "hits": [ { "_id": "1", "_score": 1.5753809, "_source": { "name": "Brown foxes" } }, { "_id": "2", "_score": 0.012520773, "_source": { "name": "Yellow furballs" } } ] } 

validate-query API可以為我們提供一些線索:

GET /my_index/my_type/_validate/query?explain { "query": { "match": { "name": "brown fo" } } }

explaination 告訴我們這個查詢會查找邊界Ngram里的每個詞:

name:b name:br name:bro name:brow name:brown name:f name:fo

name:f 條件可以滿足第二個文檔,因為 furballs是以 ffufur形式索引的。回過頭看這並不令人吃驚,相同的autocomplete 分析器同時被應用於索引時和搜索時,這在大多數情況下是正確的,只有在少數場景下才需要改變這種行為。

我們需要保證索引表中包含邊界Ngram的每個詞,但是我們只想匹配用戶輸入的完整詞組(brown 和 fo),可以通過在索引時使用 autocomplete 分析器,並在搜索時使用 standard 標准分析器來實現這種想法,只需要改變查詢中搜索分析器的analyzer 參數即可:

GET /my_index/my_type/_search { "query": { "match": { "name": { "query": "brown fo", "analyzer": "standard" #1 } } } }
  • #1 覆蓋了 name 字段 analyzer 的設置

換種方式,我們可以在映射中,為 name 字段分別指定 index_analyzer 和 search_analyzer。因為只想改變search_analyzer,這里只需更新現有的映射而不用對數據重新創建索引:

PUT /my_index/my_type/_mapping { "my_type": { "properties": { "name": { "type": "string", "index_analyzer": "autocomplete", #1 "search_analyzer": "standard" #2 } } } }
  • #1 在索引時,使用 autocompleted 分析器生成邊界Ngram的每個術語。
  • #2 在搜索時,使用 standard 分析器搜索用戶輸入的術語。

如果我們再次用 validate-query API查看請求,現在的 explaination是:

name:brown name:fo

這樣結果中就只返回“Brown foxes”這個文檔。

因為大多數工作是在索引時完成的,所有的查詢只需要查找 brown 和 fo 這兩個術語,這比使用 match_phrase_prefix 查找所有以 fo 開始的術語的方式要高效許多。

完成建議者(Completion Suggester)

使用邊界Ngram進行輸入即搜索的查詢設置簡單、靈活且快速,但是,有些時候它並不足夠快,特別是當我們試圖立刻獲得反饋時,延遲的問題就會凸顯,很多時候不搜索才是最快的搜索方式。

ElasticSearch里的 完成建議者(Completion Suggester) 采用了與上面完全不同的方式,我們需要為搜索條件生成一個所有可能完成的單詞列表,然后將它們置入一個 有限狀態機(finite state transducer)內,這是一個經過優化的圖結構。為了搜索建議,ElasticSearch從圖的開始處順着匹配路徑一個字符一個字符進行匹配,一旦它處於用戶輸入的末尾,ElasticSearch就會查找所有可能的結束當前路徑,然后生成一個建議列表。

這個數據結構存在於內存里使得對前綴的查詢非常快速,比任何一種基於術語的查詢都要快很多,這對名字或品牌的自動完成非常適用,因為這些詞通常是以普通順序組織的:用“Johnny Rotten”而不是“Rotten Johnny”。

當詞序不是那么容易被預見時,邊界Ngram比完成建議者(Completion Suggester)更合適。如果說不可能所有的貓都是一個花色,那這只貓的花色也是相當特殊的。

邊界n-grams與郵編(Edge n-grams and Postcodes)

邊界n-gram的方式可以被用來查詢結構化的數據,比如之前例子中的:郵編(postcode)。當然 postcode 字段需要是分析過的(analyzed)而不是未分析過的(not_analyzed),即使未分析過(not_analyzed),我們還是可以用關鍵詞(keyword)標記器來處理它。

keyword 標記器是一個非操作型標記器,這個標記器不做任何事情,它接收的任何字符串都會被原樣發出,因此它可以用來處理 not_analyzed 的字段值,但這也需要其他的一些分析轉換,如:小寫化。

下面這個例子使用 keyword 標記器將郵編轉換成標記流(token stream),這樣我們就能使用邊界n-gram標記過濾器:

{
    "analysis": { "filter": { "postcode_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 8 } }, "analyzer": { "postcode_index": { #1 "tokenizer": "keyword", "filter": [ "postcode_filter" ] }, "postcode_search": { #2 "tokenizer": "keyword" } } } }
  • #1 postcode_index 分析器使用 postcode_filter 將郵編轉換成邊界n-gram形式。
  • #2 postcode_search 分析器可以將搜索術語看成未索引的(not_indexed)。

Ngram匹配復合詞(Ngrams for Compound Words)

最后,我們來看看n-gram是如何應用於搜索復合詞的語言中的。德語聞名於它可以將許多小詞組合成一個非常巨大的復合詞以獲得它准確而又復雜的意義。例如:

  • Aussprachewörterbuch

    發音字典(Pronunciation dictionary)

  • Militärgeschichte

    戰爭史(Military history)

  • Weißkopfseeadler

    禿鷹(White-headed sea eagle, or bald eagle)

  • Weltgesundheitsorganisation

    世界衛生組織(World Health Organization)

  • Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz

    法案考慮代理監管牛和牛肉的標記的職責(The law concerning the delegation of duties for the supervision of cattle marking and the labeling of beef)

有些人希望在搜索“Wörterbuch”(字典)的時候,能在結果中看到“Aussprachewörtebuch”(發音字典)。同樣,搜索“Adler”(鷹)的時候,能將“Weißkopfseeadler”(禿鷹)包含在結果集中。

處理這種語言的一種方式可以是將復合詞拆分成各自部分,然后用 組合詞標記過濾器(compound word token filter),但這種方式的結果質量依賴於我們組合詞字典的好壞。

另一種就是將所有的詞用n-gram的方式進行處理,然后搜索任何匹配的片段——能匹配的片段越多,文檔的相關度越大。

因為n-gram是一個詞上的移動窗,一個具有所有長度的n-gram能涵蓋所有的詞。我們希望選擇有足夠長度讓詞有意義,但是也不能太長而生成過多的唯一術語,一個長度為3的trigram可能是一個不錯的開始:

PUT /my_index { "settings": { "analysis": { "filter": { "trigrams_filter": { "type": "ngram", "min_gram": 3, "max_gram": 3 } }, "analyzer": { "trigrams": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "trigrams_filter" ] } } } }, "mappings": { "my_type": { "properties": { "text": { "type": "string", "analyzer": "trigrams" #1 } } } } }
  • #1 text 字段用 trigrams 分析器索引它的內容,這里n-gram的長度是3。

測試trigram分析器:

GET /my_index/_analyze?analyzer=trigrams Weißkopfseeadler

返回的術語:

wei, eiß, ißk, ßko, kop, opf, pfs, fse, see, eea,ead, adl, dle, ler

創建復合詞的測試文檔:

POST /my_index/my_type/_bulk { "index": { "_id": 1 }} { "text": "Aussprachewörterbuch" } { "index": { "_id": 2 }} { "text": "Militärgeschichte" } { "index": { "_id": 3 }} { "text": "Weißkopfseeadler" } { "index": { "_id": 4 }} { "text": "Weltgesundheitsorganisation" } { "index": { "_id": 5 }} { "text": "Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz" }

“Adler”(鷹)的搜索轉化為查詢三個術語 adldleler

GET /my_index/my_type/_search { "query": { "match": { "text": "Adler" } } }

正好可以與“Weißkopfsee-adler”相匹配:

{
  "hits": [ { "_id": "3", "_score": 3.3191128, "_source": { "text": "Weißkopfseeadler" } } ] }

類似查詢“Gesundheit”(健康)可以與“Welt-gesundheit-sorganisation”匹配,同時也能與“Militär-ges-chichte”和“Rindfleischetikettierungsüberwachungsaufgabenübertragungs-ges-etz”匹配,因為它們同時具有trigram生成的術語 ges

使用合適的 minimum_should_match 可以將這些奇怪的結果排除,只有當trigram最少匹配數滿足要求時,一個文檔才能被認為是匹配的:

GET /my_index/my_type/_search { "query": { "match": { "text": { "query": "Gesundheit", "minimum_should_match": "80%" } } } }

這有點像全文搜索中的獵槍法,可能會導致反向索引很大,盡管如此,在索引具有很多復合詞的語言,或詞之間沒有空格的語言(如:泰語)時,它仍不失為一種通用的有效方法。

這種技術可以用來提升召回(Recall)——搜索結果中相關文檔的數目。它通常會與其他技術(如:瓦片詞shingles)一起使用以提高精度和每個文檔的相關度分數。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM