记一次向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,可以从单元测试代码入手,编写更加复杂的测试代码,进行调试,可以快速定位出问题出现的原因并进行修复。