記一次向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, 主要的改動點有:
- TransportAnalyzeAction.getAnalyzer()方法判斷normalizer不為空時返回該normalizer
- TransportAnalyzeActionTests.testNormalizerWithIndex()測試用例中把用於測試的字元串修改我"Wi-fi", 確保自定義的normalizer能夠生效。
改動的並不多,社區的成員在確認這個bug之後,和我經過了一輪溝通,認為應當對測試用例生成的結果增加註釋說明,在增加了說明之後,社區成員進行了merge, 並表示會在7.6版本中發布這個PR。
總結
本次提交bug修復的PR,過程還是比較順利的,改動點也不大,總結的經驗是遇到新版本引入的bug,可以從單元測試程式碼入手,編寫更加複雜的測試程式碼,進行調試,可以快速定位出問題出現的原因並進行修復。