記一次向Elasticsearch開源社區貢獻程式碼的經歷

  • 2019 年 12 月 23 日
  • 筆記

背景

在針對線上ES集群進行運維值班的過程中,有用戶回饋使用自建的最新的7.4.2版本的ES集群,索引的normalizer配置無法使用了,怎麼配置都無法生效,而之前的6.8版本還是可以正常使用的。根據用戶提供的索引配置進行了復現,發現確實如此。通過搜索發現github上有人已經針對這個問題提了issue: #48650, 並且已經有社區成員把這個issue標記為了bug, 但是沒有進一步的討論了,所以我就深入研究了源碼,最終找到了bug產生的原因,在github上提交了PR:#48866,最終被merge到了master分支,在7.6版本會進行發布。

何為normalizer

normaizer 實際上是和analyzer類似,都是對字元串類型的數據進行分析和處理的工具,它們之間的區別是:

1. normalizer只對keyword類型的欄位有效  2. normalizer處理後的結果只有一個token  3. normalizer只有char_filter和filter,沒有tokenizer,也即不會對字元串進行分詞處理

如下是一個簡單的normalizer定義,並且把欄位foo配置了normalizer:

PUT index  {    "settings": {      "analysis": {        "char_filter": {          "quote": {            "type": "mapping",            "mappings": [              "« => "",              "» => ""            ]          }        },        "normalizer": {          "my_normalizer": {            "type": "custom",            "char_filter": ["quote"],            "filter": ["lowercase", "asciifolding"]          }        }      }    },    "mappings": {      "properties": {        "foo": {          "type": "keyword",          "normalizer": "my_normalizer"        }      }    }  }

情景復現

首先定義了一個名為my_normalizer的normalizer, 處理邏輯是把該字元串中的大寫字母轉換為小寫:

{    "settings": {      "analysis": {        "normalizer": {          "my_normalizer": {            "filter": [              "lowercase"            ],            "type": "custom"          }        }      }    }}

通過使用_analyze api測試my_normalizer:

GET {index}/_analyze  {    "text": "Wi-fi",    "normalizer": "my_normalizer"  }

期望最終生成的token只有一個,為:"wi-fi", 但是實際上生成了如下的結果:

{    "tokens" : [      {        "token" : "wi",        "start_offset" : 0,        "end_offset" : 2,        "type" : "<ALPHANUM>",        "position" : 0      },      {        "token" : "fi",        "start_offset" : 3,        "end_offset" : 5,        "type" : "<ALPHANUM>",        "position" : 1      }    ]  }

也就是生成了兩個token: wi和fi,這就和前面介紹的normalizer的作用不一致了:normalizer只會生成一個token,不會對原始字元串進行分詞處理。

為什麼會出現這個bug

通過在6.8版本的ES上進行測試,發現並沒有復現,通過對比_analyze api的在6.8和7.4版本的底層實現邏輯,最終發現7.0版本之後,_analyze api內部的程式碼邏輯進行了重構,執行該api的入口方法TransportAnalyzeAction.anaylze()方法的邏輯有些問題:

public static AnalyzeAction.Response analyze(AnalyzeAction.Request request, AnalysisRegistry analysisRegistry,                                            IndexService indexService, int maxTokenCount) throws IOException {            IndexSettings settings = indexService == null ? null : indexService.getIndexSettings();            // First, we check to see if the request requires a custom analyzer.  If so, then we          // need to build it and then close it after use.          try (Analyzer analyzer = buildCustomAnalyzer(request, analysisRegistry, settings)) {              if (analyzer != null) {                  return analyze(request, analyzer, maxTokenCount);              }          }            // Otherwise we use a built-in analyzer, which should not be closed          return analyze(request, getAnalyzer(request, analysisRegistry, indexService), maxTokenCount);      }

analyze方法的主要邏輯為:先判斷請求參數request對象中是否包含自定義的tokenizer, token filter以及char filter, 如果有的話就構建出analyzer或者normalizer, 然後使用構建出的analyzer或者normalizer對字元串進行處理;如果請求參數request對象沒有自定義的tokenizer, token filter以及char filter方法,則使用已經在索引settings中配置好的自定義的analyzer或normalizer,或者使用內置的analyzer對字元串進行進行分析和處理。

我們復現的場景中,請求參數request中使用了在索引settings中配置好的normalizer,所以buildCustomAnalyzer方法返回空, 緊接著執行了getAnalyzer方法用於獲取自定義的normalizer, 看一下getAnalyzer方法的邏輯:

private static Analyzer getAnalyzer(AnalyzeAction.Request request, AnalysisRegistry analysisRegistry, IndexService indexService) throws IOException {  		if (request.analyzer() != null) {  			 	...               return analyzer;              }          }          if (request.normalizer() != null) {              // Get normalizer from indexAnalyzers              if (indexService == null) {                  throw new IllegalArgumentException("analysis based on a normalizer requires an index");              }              Analyzer analyzer = indexService.getIndexAnalyzers().getNormalizer(request.normalizer());              if (analyzer == null) {                  throw new IllegalArgumentException("failed to find normalizer under [" + request.normalizer() + "]");              }          }          if (request.field() != null) {              ...          }          if (indexService == null) {              return analysisRegistry.getAnalyzer("standard");          } else {              return indexService.getIndexAnalyzers().getDefaultIndexAnalyzer();          }

上述邏輯用於獲取已經定義好的analyzer或者normalizer, 但是問題就出在與當request.analyzer()不為空時,正常返回了定義好的analyzer, 但是request.normalizer()不為空時,卻沒有返回,導致程式最終走到了最後一句return, 返回了默認的standard analyzer.

所以最終的結果就可以解釋了,即使自定義的有normalizer, getAnalyer()始終返回了默認的standard analyzer, 導致最終對字元串進行解析時始終使用的是standard analyzer, 對"Wi-fi"的處理結果正是"wi"和"fi"。

單元測試沒有測試到嗎

通過查找TransportAnalyzeActionTests.java類中的testNormalizerWithIndex方法,發現對normalizer的測試用例太簡單了:

public void testNormalizerWithIndex() throws IOException {          AnalyzeAction.Request request = new AnalyzeAction.Request("index");          request.normalizer("my_normalizer");          request.text("ABc");          AnalyzeAction.Response analyze              = TransportAnalyzeAction.analyze(request, registry, mockIndexService(), maxTokenCount);          List<AnalyzeAction.AnalyzeToken> tokens = analyze.getTokens();            assertEquals(1, tokens.size());          assertEquals("abc", tokens.get(0).getTerm());      }

對字元串"ABc"進行測試,使用自定義的my_normalizer和使用standard analyzer的測試結果是一樣的,所以這個測試用例通過了,導致這個bug沒有及時沒發現。

提交PR

在確認了問題的原因後,我提交了PR:#48866, 主要的改動點有:

  1. TransportAnalyzeAction.getAnalyzer()方法判斷normalizer不為空時返回該normalizer
  2. TransportAnalyzeActionTests.testNormalizerWithIndex()測試用例中把用於測試的字元串修改我"Wi-fi", 確保自定義的normalizer能夠生效。

改動的並不多,社區的成員在確認這個bug之後,和我經過了一輪溝通,認為應當對測試用例生成的結果增加註釋說明,在增加了說明之後,社區成員進行了merge, 並表示會在7.6版本中發布這個PR。

總結

本次提交bug修復的PR,過程還是比較順利的,改動點也不大,總結的經驗是遇到新版本引入的bug,可以從單元測試程式碼入手,編寫更加複雜的測試程式碼,進行調試,可以快速定位出問題出現的原因並進行修復。