Elasticsearch:Painless scripting

Painless 使用类似于 Groovy 的 Java 样式语法。实际上,大多数 Painless 脚本也是有效的 Groovy,而简单的 Groovy 脚本通常是有效的 Painless 脚本。 (本规范假定您至少对 Java 和相关语言有一定的了解。)

Painless 本质上是 Java 的子集,具有一些其他脚本语言功能,使脚本更易于编写。但是,有一些重要的差异,尤其是在铸造模型上。使用 ANTLR4 和 ASM 库来解析和编译 Painless 脚本。Painless 脚本直接编译为 Java 字节码,并针对标准 Java 虚拟机执行。该规范使用 ANTLR4 语法符号来描述允许的语法。但是,实际的 Painless 语法比此处显示的更为紧凑。Painless 是一种专门用于 Elasticsearch 的简单安全的脚本语言。它是 Elasticsearch 的默认脚本语言,可以安全地用于 inline 和 stored 脚本。

我们可以在 Elasticsearch 中可以使用脚本的任何地方使用 Painless。 Painless 功能包括:

  • 快速的性能:Painless 脚本的运行速度比其他脚本快几倍。
  • 语法:扩展 Java 语法以提供 Groovy 风格的脚本语言功能,使脚本更易于编写。
  • 安全性:具有方法调用/字段粒度的细粒度白名单。 (有关可用类和方法的完整列表,请参阅《 Painless API参考》。)
  • 可选类型:变量和参数可以使用显式类型或动态 def 类型。
  • 优化:专为 Elasticsearch 脚本设计。

让我们通过将一些学术统计数据加载到 Elasticsearch 索引中来说明 Painless的 工作方式:

PUT academics/_bulk
{"index":{"_id":1}}
{"first":"Agatha","last":"Christie","base_score":[9,27,1],"target_score":[17,46,0],"grade_point_index":[26,82,1],"born":"1978/08/13"}
{"index":{"_id":2}}
{"first":"Alan","last":"Moore","base_score":[7,54,26],"target_score":[11,26,13],"grade_point_index":[26,82,82],"born":"1976/10/12"}
{"index":{"_id":3}}
{"first":"jiri","last":"Ibsen","base_score":[5,34,36],"target_score":[11,62,42],"grade_point_index":[24,80,79],"born":"1983/01/04"}
{"index":{"_id":4}}
{"first":"William","last":"Blake","base_score":[4,6,15],"target_score":[8,23,15],"grade_point_index":[26,82,82],"born":"1990/02/17"}
{"index":{"_id":5}}
{"first":"Shaun","last":"Tan","base_score":[5,0,0],"target_score":[8,1,0],"grade_point_index":[26,1,0],"born":"1993/06/20"}
{"index":{"_id":6}}
{"first":"Peter","last":"Hitchens","base_score":[0,26,15],"target_score":[11,30,24],"grade_point_index":[26,81,82],"born":"1969/03/20"}
{"index":{"_id":7}}
{"first":"Raymond","last":"Carver","base_score":[7,19,5],"target_score":[3,17,4],"grade_point_index":[26,45,34],"born":"1963/08/10"}
{"index":{"_id":8}}
{"first":"Lee","last":"Child","base_score":[2,14,7],"target_score":[8,42,30],"grade_point_index":[26,82,82],"born":"1992/06/07"}
{"index":{"_id":39}}
{"first":"Joseph","last":"Heller","base_score":[6,30,15],"target_score":[3,30,24],"grade_point_index":[26,60,63],"born":"1984/10/03"}
{"index":{"_id":10}}
{"first":"Harper","last":"Lee","base_score":[3,15,13],"target_score":[6,24,18],"grade_point_index":[26,82,82],"born":"1976/03/17"}
{"index":{"_id":11}}
{"first":"Ian","last":"Fleming","base_score":[3,18,13],"target_score":[6,20,24],"grade_point_index":[26,67,82],"born":"1972/01/30"}

在上面,我们已经创建好一个叫做 academics 的 Elasticsearch 索引。在下面,我们通过 Painless 的脚本来对这个索引进行操作。

 

通过 Painless 访问 doc 值

可以从名为 doc 的 map 访问文档值。 例如,以下脚本计算学生的总体目标。 本示例使用强类型的 int 和 for 循环。

GET academics/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "lang": "painless",
          "source": """
             int total = 0; 
             for (int i = 0; i < doc['base_score'].length; ++i) { 
               total += doc['base_score'][i]; 
             } 
             return total;
          """
        }
      }
    }
  }
}

在上面请注意,我们使用了两个 """ 包含我们的 Painless 代码。这是为了能够让我们写出来比较好看格式的代码。必须指出的是:在上面,我们使用了 doc['base_score'] 的相似来访问数据,但是其实,我们也可以使用 . 的形式来进行访问,比如 doc.base_score。这样的写法也非常直接。但是对于一些特殊的字段,比如 @timestamp 或一些汉字的字段,这个并不适用。比如 doc.@timestamp 这个是不被接受的。上面的代码我们可以写作为:

GET academics/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "lang": "painless",
          "source": """
             int total = 0; 
             for (int i = 0; i < doc.base_score.length; ++i) { 
               total += doc.base_score[i]; 
             } 
             return total;
          """
        }
      }
    }
  }
}

从上面的代码我们可以看出来,代码和 Java 代码非常相似。通过这个循环,我们访问 doc 的 map 值,从而计算出 base_score 的总值。返回的结果为:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 87.0,
        "_source" : {
          "first" : "Alan",
          "last" : "Moore",
          "base_score" : [
            7,
            54,
            26
          ],
          "target_score" : [
            11,
            26,
            13
          ],
          "grade_point_index" : [
            26,
            82,
            82
          ],
          "born" : "1976/10/12"
        }
      },
     ...
  ]

在上面我们可以看出来我们的 _score 的分数为 base_score 的总和7 + 54 + 26 = 87。

另外,我们可以使用 script_fields 而不是 function_score 来做同样的事情:

GET academics/_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "total_goals": {
      "script": {
        "lang": "painless",
        "source": """
           int total = 0; 
           for (int i = 0; i < doc['base_score'].length; ++i) { 
             total += doc['base_score'][i]; 
            } 
            return total;
        """
      }
    }
  }
}

返回结果:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "fields" : {
          "total_goals" : [
            37
          ]
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.0,
        "fields" : {
          "total_goals" : [
            87
          ]
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "fields" : {
          "total_goals" : [
            75
          ]
        }
     ...
 ]

在这里我们可以看到有一个叫做 total_goals 的 field,它包含了所有文档的 base_score 的分数相加的结果。细心的读者可能已经看出来了,在返回的结果里,没有 _source 这个字段,为了能够得到 _source 这个字段,我们必须在请求中特别提出来:

GET academics/_search
{
  "_source" : [],
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "total_goals": {
      "script": {
        "lang": "painless",
        "source": """
           int total = 0; 
           for (int i = 0; i < doc['base_score'].length; ++i) { 
             total += doc['base_score'][i]; 
            } 
            return total;
        """
      }
    }
  }
}

在上面,我们可以看出来,我特意添加了 "_source": [] 在请求中,这样,我们可以得到如下的响应:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 1.0,
        "_source" : {
          "base_score" : [
            5,
            0,
            0
          ],
          "last" : "Tn",
          "born" : "1993/06/20",
          "target_score" : [
            8,
            1,
            0
          ],
          "first" : "Shaun",
          "grade_point_index" : [
            26,
            1,
            0
          ]
        },
        "fields" : {
          "total_goals" : [
            5
          ]
        }
      },
  ...
 ]

我们可以看到这次除了新增加的 total_goals 之外,我们所需要的 _source 也在返回的数据中。

脚本字段还可以使用 params ['_ source'] 访问实际的 _source 文档,并提取要从中返回的特定元素。上面的请求也可以表述为:

GET academics/_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "total_goals": {
      "script": {
        "lang": "painless",
        "source": """
           int total = 0; 
           for (int i = 0; i < params['_source']['base_score'].length; ++i) { 
             total += params['_source']['base_score'][i]; 
           } 
           return total
        """
      }
    }
  }
}

了解 doc['my_field'] .value 和 params['_ source'] ['my_field'] 之间的区别很重要。 第一个使用 doc 关键字,将导致将该字段的术语加载到内存中(缓存),这将导致执行速度更快,但会占用更多内存。 另外,doc [...] 符号仅允许使用简单值字段(你不能从中返回 json 对象),并且仅对未分析或基于单个术语的字段有意义。 但是,仍然建议使用 doc(即使有可能)从文档访问值的方式,因为 _source 每次使用时都必须加载和解析。 使用 _source 非常慢。

我们也注意到所有文档的分数都是 1.0,并且返回的结果不是按照我们想要的分数从高到底进行排序的。我们无法通过 sort 多 total_goals 进行排序,因为这个字段不是在 source 里的字段。但是我们可以进行如下的方法来进行排序:

GET academics/_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "total_goals": {
      "script": {
        "lang": "painless",
        "source": """
           int total = 0; 
           for (int i = 0; i < doc['base_score'].length; ++i) { 
             total += doc['base_score'][i]; } 
             return total;
        """
      }
    }
  },
  "sort": {
    "_script": {
      "type": "string",
      "order": "asc",
      "script": {
        "lang": "painless",
        "source": """
          return doc['first.keyword'].value + ' ' + doc['last.keyword'].value
        """
      }
    }
  }
}

以上示例使用 Painless 脚本按学生的姓和名对学生进行排序。 使用 doc['first.keyword'].value 和 doc['last.keyword'].value 访问名称。在上面,我们通过 doc['first.keyword'].value + ' ' + doc['last.keyword'].value 来重新组合一个新的字段,并按照这个字段按照升序进行排序。这种排序的结果是分数变为 null:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : null,
        "fields" : {
          "total_goals" : [
            37
          ]
        },
        "sort" : [
          "Agatha Christie"
        ]
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : null,
        "fields" : {
          "total_goals" : [
            87
          ]
        },
        "sort" : [
          "Alan Moore"
        ]
      },
    ...
  ]

 

使用 Painless 来更新字段

通过访问 ctx._source。<field-name> 字段的原始源,我们还可以轻松更新字段。

首先,我们通过提交以下请求来查看学生的源数据:

GET academics/_search
{
 "query": {
   "term": {
     "_id": 1
   }
 }
}

它显示的结果是:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "first" : "Agatha",
          "last" : "Christie",
          "base_score" : [
            9,
            27,
            1
          ],
          "target_score" : [
            17,
            46,
            0
          ],
          "grade_point_index" : [
            26,
            82,
            1
          ],
          "born" : "1978/08/13"
        }
      }
    ]

要将 id 为 1 的学生的姓氏更改为 “Frost”,只需将 ctx._source.last 设置为新值:

POST academics/_doc/1/_update
{
  "script": {
    "lang": "painless",
    "inline": "ctx._source.last = params.last",
    "params": {
      "last": "Frost"
    }
  }
}

在上面我们通过 ctx._source.last = params.last 把 _source.last 改为在 params 里定义的 Frost。请注意,有的开发者可能认为如下的脚本会更加直接:

ctx._source.last = “Frost"

因为脚本在每次执行的时候都会被编译。一旦编译就会存于被缓存以便以后引用。编译一个脚本也会花时间的。如果一个脚本不发生变化就不会被重新编译,而引用之前编译好的脚本。如果我们使用上面的直接引用 Frost 的方式,那么如果接下来对其它的 id 来进行同样的操作,那么脚本将发生改变,那么新的脚本将被重新编译,从而浪费 CPU 的时间。这也就是我们为啥使用 params 来传输参数,而保持脚本不变。

我们还可以将字段添加到文档中。 例如,此脚本添加了一个新字段,其中包含学生的昵称 “JS”。

POST academics/_doc/1/_update
{
  "script": {
    "lang": "painless",
    "inline": "ctx._source.last = params.last; ctx._source.nick = params.nick",
    "params": {
      "last": "Smith",
      "nick": "JS"
    }
  }
}

在上面我们使用脚本 cxt._source.nick = params.nick 来为我们的文档添加了一个叫做 nick 的新字段。我们可以通过如下的方法来查询:

GET academics/_search
{
 "query": {
   "term": {
     "_id": 1
   }
 }
}

返回结果:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "first" : "Agatha",
          "last" : "Smith",
          "base_score" : [
            9,
            27,
            1
          ],
          "target_score" : [
            17,
            46,
            0
          ],
          "grade_point_index" : [
            26,
            82,
            1
          ],
          "born" : "1978/08/13",
          "nick" : "JS"
        }
      }
    ]

我们可看到 _id 为 1 的文档多了一个 nick 的字段,同时它的l ast 也修改为 Smith。

 

Dates 

日期字段显示为 ReadableDateTime,因此它们支持诸如 getYear,getDayOfWeek 和 getMillis 之类的方法。 例如,以下请求返回每个大学生的出生年份:

GET academics/_search
{
  "script_fields": {
    "birth_year": {
      "script": {
        "source": "doc.born.value.year"
      }
    }
  }
}

返回结果为:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.0,
        "fields" : {
          "birth_year" : [
            1976
          ]
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "fields" : {
          "birth_year" : [
            1983
          ]
        }
      },
   ...
 ]

在上面我可以看出来有一个 birth_year 含有该学生的出生年份。

 

Regular Expressions

默认情况下,正则表达式是禁用的,因为它们绕过了 Painless 对长时间运行且占用大量内存的脚本的保护。 更糟糕的是,即使看起来无害的正则表达式也可能具有惊人的性能和堆栈深度行为。 它们仍然是一个了不起的强大工具,但太可怕了,无法默认启用。 在elasticsearch.yml 中将 script.painless.regex.enabled 设置为 true 以启用它们。

Painless 对正则表达式的本机支持具有语法构造:

/pattern/:模式文字可创建模式。 这是 Painless 创建模式的唯一方法。 /.../ 内的模式只是 Java 正则表达式。

  • =〜:find运算符返回一个布尔值,如果文本的子序列匹配,则返回 true,否则返回 false。
  • ==〜:match 运算符返回一个布尔值,如果文本匹配则返回 true,否则返回 false。

使用查找运算符(=〜),我们可以更新所有姓氏中带有“b”的学术学生:

POST academics/_update_by_query
{
  "script": {
    "lang": "painless",
    "source": """
       if (ctx._source.last =~ /b/) {
         ctx._source.last += "matched"
       } else {
         ctx.op = 'noop'
       }
    """
  }
}

我们查看一下我们的文档:

GET academics/_search
{
  "query": {
    "regexp": {
      "last": ".*matched"
    }
  }
}

返回结果:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "_source" : {
          "base_score" : [
            5,
            34,
            36
          ],
          "last" : "Ibsenmatched",
          "born" : "1983/01/04",
          "target_score" : [
            11,
            62,
            42
          ],
          "first" : "jiri",
          "grade_point_index" : [
            24,
            80,
            79
          ]
        }
      }
    ]

在上面的结果中,我们可以看出来 last 为 Ibsen 的后面加上了一个 matched。

使用匹配运算符(==〜),我们可以更新所有名称以辅音开头并以元音结尾的文档:

POST academics/_update_by_query
{
  "script": {
    "lang": "painless",
    "source": """
      if (ctx._source.last ==~ /[^aeiou].*[aeiou]/) {
        ctx._source.last += "matched"
      } else {
        ctx.op = 'noop'
      }
    """
  }
}

我们再次重新查询一下:

GET academics/_search
{
  "query": {
    "regexp": {
      "last": ".*matched"
    }
  }
}

我们可以看到:

      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.0,
        "_source" : {
          "base_score" : [
            7,
            54,
            26
          ],
          "last" : "Moorematched",
          "born" : "1976/10/12",
          "target_score" : [
            11,
            26,
            13
          ],
          "first" : "Alan",
          "grade_point_index" : [
            26,
            82,
            82
          ]
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "4",
        "_score" : 1.0,
        "_source" : {
          "base_score" : [
            4,
            6,
            15
          ],
          "last" : "Blakematched",
          "born" : "1990/02/17",
          "target_score" : [
            8,
            23,
            15
          ],
          "first" : "William",
          "grade_point_index" : [
            26,
            82,
            82
          ]
        }
      },
    ...
  ]

在上面,我们看到 Blake 是复合条件的一个 last。它是辅音 b 开头并以元音 e 结尾。它的 last 最后修改为 Moorematched。

我们可以直接使用 Pattern.matcher 获取 Matcher 实例,并删除所有姓氏中的所有元音:

POST academics/_update_by_query
{
  "script": {
    "lang": "painless",
    "source": "ctx._source.last = /[aeiou]/.matcher(ctx._source.last).replaceAll('')"
  }
}

我们再次查询一下:

GET academics/_search
{
  "_source": ["last"]
}

显示结果:

    "hits" : [
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 1.0,
        "_source" : {
          "last" : "Tn"
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "6",
        "_score" : 1.0,
        "_source" : {
          "last" : "Htchns"
        }
      },
      {
        "_index" : "academics",
        "_type" : "_doc",
        "_id" : "7",
        "_score" : 1.0,
        "_source" : {
          "last" : "Crvr"
        }
      }
  ...
 ]

我们可以看到,在上的结果中,索引的 last 里都没有一个是属于 [aeiou] 的字母。

Matcher.replaceAll 只是对 Java Matcher 的 replaceAll 方法的调用,因此它支持 $1 和 \ 1 进行替换:

POST academics/_update_by_query
{
  "script": {
    "lang": "painless",
    "source": "ctx._source.last = /n([aeiou])/.matcher(ctx._source.last).replaceAll('$1')"
  }
}

在这之前,我也有另外一篇文章 “Elasticsearch: Painless script编程”。它和本文章有些地方是重复的地方。供大家从不同的角度来学习。