记一次向Elasticsearch开源社区贡献代码的经历

背景

在针对线上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" : "",
      "position" : 0
    },
    {
      "token" : "fi",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "",
      "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 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,可以从单元测试代码入手,编写更加复杂的测试代码,进行调试,可以快速定位出问题出现的原因并进行修复。

你可能感兴趣的:(记一次向Elasticsearch开源社区贡献代码的经历)