Painless language reference
출처: 문서
본문
Painless 언어 레퍼런스
이 페이지에서는 Painless의 문법, 즉 타입, 연산자, 문장, 함수, 정규 표현식을 설명해요. 특정 스크립트 컨텍스트가 제공하는 필드와 변수에 대해서는 스크립트에서 도큐먼트 필드 접근하기와 스크립트 컨텍스트를 참고하세요.
이 페이지의 어떤 문법이든 직접 해 보려면 Execute Inline Script API를 사용하세요. 이 API는 스크립트를 실행하고 결과를 반환해요. 이 페이지의 스니펫이나 여러분이 직접 작성한 스크립트를 다음 요청의 source 필드에 넣어 보세요:
POST _scripts/painless/_execute
{
"script": {
"source": "int a = 4; def b = 2.5; return a * b"
}
}
{% include copy-curl.html %}
OpenSearch는 스크립트 결과를 문자열로 변환해서 반환해요:
{
"result": "10.0"
}
타입
Painless는 정적 타입 언어지만, 타입 선택을 나중으로 미룰 수도 있어요. 타입을 알고 있다면 구체적인 타입으로 변수를 선언하세요:
int count = 42;
double price = 249.99;
boolean onSale = true;
String sku = "AUD-1001";
타입이 달라질 수 있다면 def로 선언하세요. 한 도큐먼트에서는 필드가 단일 값을 담고, 다른 도큐먼트에서는 값의 배열을 담을 수 있는데, def 변수는 둘 다 받아들일 수 있기 때문에 스크립트가 둘 중 무엇을 받았는지 검사할 수 있어요:
def ratings = params.ratings;
return ratings instanceof List ? ratings.size() : 1;
구체적인 타입은 스크립트가 컴파일될 때 검사되는 반면, def 타입은 스크립트가 실행될 때마다 검사돼요. 검사를 미루면 약간의 성능 비용이 들고 타입 불일치가 런타임 오류로 바뀌기 때문에, 타입이 고정된 곳에서는 구체적인 타입으로 선언하는 게 좋아요. 앞선 스크립트의 변수를 List로 선언하면 평점이 여러 개인 도큐먼트에서는 평점 개수를 반환하지만, 평점이 하나뿐인 도큐먼트에서는 class java.lang.Integer cannot be cast to class java.util.List 오류와 함께 런타임에 실패해요.
다음 표는 기본(primitive) 타입과 def와 호환되는 박싱(boxed) 타입을 정리한 거예요.
| 타입 | 설명 |
|---|---|
byte, short, int, long |
너비가 점점 커지는 부호 있는 정수예요. 산술 연산은 오버플로 시 오류를 내지 않고 랩어라운드(wrap)해요. |
float, double |
부동소수점 숫자예요. 소수점이 들어간 숫자 리터럴은 double이 돼요. |
boolean |
true 또는 false예요. |
char |
하나의 UTF-16 코드 유닛이에요. 문자 리터럴이 없기 때문에 한 글자짜리 문자열에서 캐스팅하세요: (char) 'A'. |
def |
모든 타입을 나타내는 자리표시자예요. 런타임에 결정돼요. |
정수 나눗셈은 0 쪽으로 절삭하고(truncate), 정수와 부동소수점 값을 함께 다루는 연산은 부동소수점 결과를 반환해요. 다음 표에 각각의 예시가 나와요.
| 표현식 | 결과 |
|---|---|
7 / 2 |
3 |
7 / 2.0 |
3.5 |
정수 산술 연산은 오버플로가 나면 조용히 랩어라운드되기 때문에, int i = 2147483647; return i + 1은 -2147483648을 반환해요. int 범위를 넘을 수 있는 값에는 long을 사용하세요.
{: .warning}
캐스팅
값을 더 넓은 범위의 값을 담을 수 있는 숫자 타입으로 캐스팅하는 것, 예컨대 int를 double로 캐스팅하는 것은 자동으로 이뤄져요. 반대로 더 좁은 범위를 담는 숫자 타입으로 캐스팅하는 것, 예컨대 double을 int로 캐스팅하는 것은 명시적으로 작성해야 하고, 반올림하지 않고 값을 잘라 내요:
double d = 9.7;
return (int) d; // 9
def 값은 대상 타입으로 자동 캐스팅되지만, 그 검사는 컴파일 시점이 아니라 런타임에 일어나요. def 값을 더 좁은 범위의 타입으로 캐스팅할 때는 여전히 명시적으로 작성해야 해요:
def x = 41.7;
int y = (int) x;
return y;
캐스팅이 없으면 int y = x는 일단 컴파일된 뒤 첫 번째 도큐먼트에서 cannot implicitly cast def [double] to int 오류로 실패해요. 이것이 def의 실질적인 트레이드오프예요. 같은 대입을 구체적인 타입으로 작성한 double d = 9.7; int i = d는 대신 컴파일 시점에 거부되거든요.
def 값의 런타임 타입은 instanceof로 검사할 수 있어요:
def x = new ArrayList();
return x instanceof List; // true
연산자
Painless는 자바의 산술, 비교, 비트, 논리 연산자를 지원하고, 자체 연산자 두 개를 더 제공해요. 다음 표는 Painless 고유의 연산자와 자바와 다른 연산자를 정리한 거예요.
| 연산자 | 설명 |
|---|---|
?: |
null 병합(null-coalescing) 연산자로, Elvis 연산자라고도 불러요. x ?: "fallback"은 x가 null이 아닌 한 x로 평가돼요. |
?. |
null 세이프 멤버 접근이에요. m.a?.toString()은 m.a가 null일 때 오류를 내는 대신 null로 평가돼요. |
=~ |
정규 표현식이 문자열의 어딘가에서 일치하는지 검사해요. 예시는 정규 표현식을 참고하세요. |
==~ |
정규 표현식이 문자열 전체와 일치하는지 검사해요. 예시는 정규 표현식을 참고하세요. |
?.와 ?:를 조합하면 흔히 쓰는 null 검사를 대체할 수 있어요:
def m = ["a": null];
return m.a?.toString() ?: "was null"; // was null
+로 문자열을 연결하면 다른 피연산자가 자동으로 변환돼요. 그래서 "id-" + 42는 id-42를 반환해요.
컬렉션과 배열
Painless에는 리스트와 맵용 리터럴 문법이 있어서, 클래스 이름을 대지 않고도 스크립트가 구조화된 값을 만들 수 있어요. 다음 표는 두 리터럴 형태와 각각이 만들어내는 값을 정리한 거예요.
| 표현식 | 결과 |
|---|---|
[3, 1, 2] |
[3, 1, 2] |
["a": 1, "b": 2] |
{a=1, b=2} |
빈 맵 리터럴은 [:]이고, 빈 리스트 리터럴은 []이에요. 리스트 인덱스에 음수를 쓰면 끝에서부터 세기 때문에, [10, 20, 30][-1]은 30이에요.
맵 엔트리에는 점 표기법과 대괄호 표기법 어느 쪽으로도 접근할 수 있어요. params.a와 params["b"]는 동등하고, 키가 유효한 식별자가 아니라면 대괄호 표기법을 써야 해요.
배열은 자바 문법을 그대로 쓰고 length 프로퍼티를 갖고 있어요:
int[] a = new int[3];
a[0] = 5;
return a[0] + a.length; // 8
다차원 배열도 자바와 똑같이 동작해요: int[][] g = new int[2][2].
맵은 엔트리 셋으로 순회할 수 있어요:
def m = ["b": 2, "a": 1];
def out = [];
for (def e : m.entrySet()) {
out.add(e.getKey() + "=" + e.getValue());
}
return out; // [a=1, b=2]
문장
Painless는 if/else, 삼항 조건식, for, 향상된 for, while, do/while, break, continue, return을 지원해요:
int s = 0;
for (int i = 1; i <= 4; i++) {
s += i;
}
return s; // 10
int s = 0;
for (int v : [2, 4, 6]) {
s += v;
}
return s; // 12
명시적인 return으로 끝나지 않는 스크립트는 마지막 표현식의 값을 반환해요. 그래서 doc['price'].value * 2 같은 한 줄짜리 스크립트에는 return이 필요 없어요.
try/catch 블록은 Painless 허용 목록(allow list)에 있는 예외 타입만 잡을 수 있어요:
int z = 0;
try {
return 4 / z;
} catch (ArithmeticException e) {
return "divide by zero";
}
throw 문은 예외를 던지는데, 같은 스크립트 안의 catch 절이 그 예외를 처리할 수 있어요:
try {
throw new IllegalArgumentException("bad sku");
} catch (IllegalArgumentException e) {
return "caught: " + e.getMessage();
}
switch 문, 레이블이 붙은 break 문, finally 블록은 Painless에서 쓸 수 없고, 각각 컴파일 오류가 나요. 스크립트는 클래스를 정의할 수 없기 때문에, new를 사용자 정의 타입에 쓸 수 없어요.
{: .note}
함수
함수는 반환 타입과 파라미터 타입을 명시해서 선언해요:
int sq(int n) {
return n * n;
}
return sq(7); // 49
모든 함수 선언은 스크립트의 문장들보다 앞에 와야 해요. 문장 뒤에 오는 선언은 unexpected token ['('] was expecting one of [{<EOF>, ';'}] 오류로 컴파일에 실패하기 때문에, int a = 1; int sq(int n) { return n * n }은 유효하지 않아요. 선언이 나오는 순서는 자유로워서, 함수가 자기보다 뒤에 선언된 다른 함수를 호출할 수도 있어요.
함수는 재귀 호출이 가능하고, 파라미터 개수가 다르면 오버로딩도 할 수 있어요. 파라미터 개수가 같은 두 함수는 타입과 상관없이 충돌해서 invalid function definition: found duplicate function [f/1] 오류가 나요. Painless는 함수를 이름과 arity(파라미터 개수)만으로 구별하기 때문이에요.
다음 표는 재귀 함수와 오버로딩된 함수 쌍의 예시, 그리고 각 스크립트가 반환하는 결과를 정리한 거예요.
| 표현식 | 결과 |
|---|---|
int f(int n) { return n <= 1 ? 1 : n * f(n-1) } return f(5) |
120 |
int f(int n){return n} int f(int a,int b){return a+b} return f(1,2) |
3 |
함수 본문은 자기 파라미터만 읽을 수 있어요. 스크립트에서 선언한 변수를 읽을 수 없고 params도 읽을 수 없어서, int g() { return (int) params.n }은 cannot resolve symbol [params.n] 오류로 컴파일에 실패해요. 함수에 필요한 값은 모두 파라미터로 전달하세요. int g(int v) { return v } return g((int) params.n)처럼요.
선언은 문장 뒤에 올 수 없기 때문에, 스크립트 변수를 읽는 함수는 어디에 두느냐에 따라 다른 오류가 나요. int g() { return x } int x = 5;는 cannot resolve symbol [x]를 보고하고, 반면 int x = 5; int g() { return x }는 unexpected token ['('] was expecting one of [{<EOF>, ';'}]를 보고해서 변수에는 아예 도달하지 못해요.
람다와 메서드 레퍼런스
람다는 함수형 인터페이스의 본문을 채워 주기 때문에, 자바 컬렉션과 스트림 메서드를 활용할 수 있어요:
def l = [3, 1, 2];
l.sort((a, b) -> a - b);
return l; // [1, 2, 3]
def l = [1, 2, 3, 4];
l.removeIf(x -> x % 2 == 0);
return l; // [1, 3]
메서드 레퍼런스는 ::를 써요:
def l = ["b", "a"];
l.sort(String::compareTo);
return l; // [a, b]
자바 스트림 메서드를 쓸 수 있지만, Stream에는 sum 메서드가 없어요. 호출하면 member method [java.util.stream.Stream, sum/0] not found 오류로 컴파일에 실패해요. 먼저 스트림을 프리미티브 스트림으로 변환하세요:
return [1, 2, 3].stream().mapToInt(x -> x * 2).sum(); // 12
정규 표현식
정규 표현식 리터럴은 슬래시로 감싸고, 닫는 슬래시 뒤에 플래그를 붙여요. 검색에는 =~를, 문자열 전체 일치에는 ==~를 쓰세요. 다음 표에 각 연산자의 예시가 나와요.
| 표현식 | 결과 |
|---|---|
"AUD-1001" =~ /^AUD-/ |
true |
"AUD-1001" ==~ /^[A-Z]{3}-\d{4}$/ |
true |
두 연산자의 차이는 패턴이 문자열 전체와 일치해야 하는지 여부예요. 다음 요청은 두 연산자를 같은 값에 적용해요:
POST _scripts/painless/_execute
{
"script": {
"source": "def found = params.sku =~ /\\d{4}/; def matched = params.sku ==~ /^[A-Z]{3}-\\d{4}$/; return \"found=\" + found + \" matched=\" + matched",
"params": { "sku": "AUD-1001" }
}
}
{% include copy-curl.html %}
두 연산자 모두 일치한다고 보고해요:
{
"result": "found=true matched=true"
}
sku를 Refurbished AUD-1001 unit로 바꾸면 found=true matched=false가 나와요. 네 자리 숫자가 여전히 문자열 어딘가에 있으니 =~는 일치하고, 문자열 전체로는 더 이상 SKU가 아니니 ==~는 일치하지 않아요.
캡처 그룹을 쓰려면 Matcher를 만드세요:
Matcher m = /(\w+)-(\d+)/.matcher("AUD-1001");
if (m.find()) {
return m.group(2); // 1001
}
return "no match";
그룹은 숫자로만 접근할 수 있어요. m.group("name")은 Cannot cast from [java.lang.String] to [int] 오류로 컴파일에 실패하는데, group의 문자열 오버로드가 허용 목록에 없어서 이름 붙은 캡처 그룹은 읽을 수 없거든요.
{: .note}
Painless는 자바의 replaceAll과 replaceFirst 메서드를 자체 버전으로 대체했는데, 이 버전들은 문자열 두 개 대신 패턴과 함수를 받아요. 함수를 넘기면 무엇이 일치했는지에 따라 치환 결과를 정할 수 있어요. 다음 표에 각 메서드의 예시가 나와요.
| 표현식 | 결과 |
|---|---|
"AUD-1001".replaceAll(/\d/, m -> "#") |
AUD-#### |
"a1b2".replaceFirst(/\d/, m -> "X") |
aXb2 |
치환으로 일반 문자열을 넘기면 Cannot cast from [java.lang.String] to [java.util.function.Function] 오류로 실패해요.
정규 표현식은 기본적으로 복잡도 예산(complexity budget) 안에서 실행되고, 아예 비활성화할 수도 있어요. 자세한 내용은 정규 표현식 제어하기를 참고하세요.
사용 가능한 라이브러리
Painless는 자바 표준 라이브러리의 일부를 노출해요. 자주 쓰이는 클래스로는 String, StringBuilder, Math, 박싱된 숫자 타입들, List, Map, Set, ArrayList, HashMap, HashSet, java.time 클래스들, 그리고 Matcher와 Pattern이 있어요. 전체 목록은 OpenSearch 저장소의 허용 목록 정의를 참고하세요. 여기에는 허용된 각 클래스에서 호출할 수 있는 필드, 생성자, 메서드가 나열되어 있어요. 다음 표는 허용 목록에 있는 클래스를 호출하는 예시예요.
| 표현식 | 결과 |
|---|---|
" Aurora ".trim().toUpperCase() |
AURORA |
Math.round(Math.sqrt(50) * 100) / 100.0 |
7.07 |
ZonedDateTime.parse("2024-03-15T00:00:00Z").getDayOfWeek().toString() |
FRIDAY |
허용 목록에 없는 클래스를 언급하거나 없는 메서드나 생성자를 호출하면 컴파일 시점에 오류가 나기 때문에, 그런 스크립트는 여러분의 데이터에 대해 절대 실행되지 않아요. 다음 표는 대표적인 오류를 정리한 거예요.
| 시도 | 오류 |
|---|---|
"x".getClass() |
member method [java.lang.String, getClass/0] not found |
new java.io.File("/etc/passwd") |
Not a type [java.io.File]. |
new Thread() |
Not a type [Thread]. |
리플렉션, 클래스 로딩, 파일과 네트워크 접근, 스레드 생성, 시계 읽기는 모두 제외되어 있어요. 그 배경과 스크립팅을 더 제한하는 설정에 대해서는 스크립트 보안을 참고하세요.
값의 타입 확인하기
값이 예상과 다른 타입이라서 스크립트가 실패할 때는, 그 값을 Debug.explain으로 감싸 보세요. 스크립트는 멈추고, OpenSearch가 오류 응답에 타입을 알려 줘요. 다음 검색은 테스트 셋업에서 만든 scripting-products 인덱스의 날짜 필드를 살펴봐요:
GET scripting-products/_search
{
"_source": false,
"query": { "term": { "sku": "AUD-1001" } },
"script_fields": {
"inspect": {
"script": { "source": "Debug.explain(doc['release_date'].value)" }
}
}
}
{% include copy-curl.html %}
painless_class, java_class, to_string 필드가 그 값을 식별해 줘요:
응답
{
"error": {
"root_cause": [
{
"type": "script_exception",
"reason": "runtime error",
"painless_class": "java.time.ZonedDateTime",
"to_string": "2024-03-15T00:00Z",
"java_class": "java.time.ZonedDateTime",
"script_stack": [
"Debug.explain(doc['release_date'].value)",
" ^---- HERE"
],
"script": "Debug.explain(doc['release_date'].value)",
"lang": "painless",
"position": {
"offset": 33,
"start": 0,
"end": 40
}
}
],
"type": "search_phase_execution_exception",
"reason": "all shards failed",
"phase": "query",
"grouped": true
},
"status": 400
}
Debug.explain은 항상 요청을 실패로 만들기 때문에, 답을 확인했으면 제거하세요.
예시
다음 스크립트는 사용자 정의 함수, 캡처 그룹이 있는 정규 표현식, 맵, 루프를 조합해서 제품 목록을 분류해요:
POST _scripts/painless/_execute
{
"script": {
"source": "String tier(double p) { if (p >= 500) return 'premium'; else if (p >= 100) return 'standard'; return 'budget' } def out = [:]; for (int i = 0; i < params.items.length; i++) { def it = params.items[i]; Matcher m = /^([A-Z]{3})-/.matcher(it.sku); String dept = m.find() ? m.group(1) : 'UNK'; out[it.sku] = dept + ':' + tier(it.price) } return out",
"params": {
"items": [
{ "sku": "AUD-1001", "price": 249.99 },
{ "sku": "DSP-3001", "price": 599.0 },
{ "sku": "x", "price": 12.5 }
]
}
}
}
{% include copy-curl.html %}
각 제품에는 부서 접두사와 가격 등급이 라벨로 붙어요:
{
"result": "{DSP-3001=DSP:premium, AUD-1001=AUD:standard, x=UNK:budget}"
}
Painless에서는 작은따옴표도 큰따옴표와 마찬가지로 문자열 구분자예요. JSON 요청 본문 안에서 작은따옴표를 쓰면 스크립트의 따옴표를 하나하나 이스케이프하지 않아도 돼요. {: .tip}