在使用 Elasticsearch 时,有时候会需要通过某个字段批量查询数据,比如通过用户 ID 批量获取用户信息,DSL 语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"query": {
"bool": {
"must": [
{
"terms": {
"user_id": ["123", "456"]
}
}
]
}
}
}

在 PHP 中,通常会使用数组来构造 DSL 语句,然后调用 json_encode() 函数将数组转成 JSON 字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$user_ids = ['123', '456'];

$body = [
'query' => [
'bool' => [
'must' => [
[
'terms' => [
'user_id' => $user_ids,
]
]
]
]
]
];

echo json_encode($body);

但是 $user_ids 一般都是由调用方传过来的,数组里面可能会存在重复的用户 ID,所以需要使用 array_unique() 函数对数组进行去重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$user_ids = ['123', '456', '123', '789'];
$user_ids = array_unique($user_ids);

$body = [
'query' => [
'bool' => [
'must' => [
[
'terms' => [
'user_id' => $user_ids,
]
]
]
]
]
];

echo json_encode($body);

这时候用生成的 DSL 语句再次请求,就会发现报错了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"error" : {
"root_cause" : [
{
"type" : "x_content_parse_exception",
"reason" : "[8:17] [terms_lookup] unknown field [0]"
}
],
"type" : "x_content_parse_exception",
"reason" : "[8:22] [bool] failed to parse field [must]",
"caused_by" : {
"type" : "x_content_parse_exception",
"reason" : "[8:17] [terms_lookup] unknown field [0]"
}
},
"status" : 400
}

通过打印生成的 DSL 语句会发现,这次的 DSL 语句跟我们最初的有些许不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"query": {
"bool": {
"must": [
{
"terms": {
"user_id": {
"0": "123",
"1": "456",
"3": "789"
}
}
}
]
}
}
}

传入的 user_id 数组变成 JSON 对象了,为什么?

首先可以确定的是,肯定跟 array_unique() 函数的去重操作有关系。

我们知道 array_unique() 函数在对数组去重时,如果数组中有重复的元素,会删除后面出现的那个重复元素,并且不会更新数组其他元素的下标。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$arr = ['aaa', 'bbb', 'aaa', 'ccc'];
$unique_arr = array_unique($arr);

print_r($arr);
print_r($unique_arr);

// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => aaa
// [3] => ccc
// )
// Array
// (
// [0] => aaa
// [1] => bbb
// [3] => ccc
// )

在上面的例子中,下标为 2 的重复元素被删除了,但是其它元素的下标依然是保持原样的。

如果再分别调用 json_encode() 函数就会发现,没有去重操作的数组转成 JSON 字符串后是 JSON 数组,而经过去重操作的数组却变成了 JSON 对象。

1
2
arr: ["aaa","bbb","aaa","ccc"]
unique_arr: {"0":"aaa","1":"bbb","3":"ccc"}

通过对比发现,除了两个数组的元素数量不一样以外,另一个区别就是经过去重后的数组的下标不是连续的,所以 PHP 将该数组转为 JSON 字符串时,认为需要将该数组转成 JSON 对象。

为了验证这一想法,我们在 PHP 源码中找到 json_encode() 函数的实现来验证一下,因为调用 json_encode() 函数时传入的是数组类型的变量,所以实际上调用的是 php_json_encode_array() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static int php_json_encode_array(smart_str *buf, zval *val, int options, php_json_encoder *encoder) /* {{{ */
{
// 判断变量是不是数组类型
if (Z_TYPE_P(val) == IS_ARRAY) {
myht = Z_ARRVAL_P(val);
prop_ht = NULL;
// 如果没有强制返回 JSON 对象的话,就调用 php_json_determine_array_type 检查变量要输出
r = (options & PHP_JSON_FORCE_OBJECT) ? PHP_JSON_OUTPUT_OBJECT : php_json_determine_array_type(val);
} else {
// 不是数组的话则返回 JSON_对象
prop_ht = myht = zend_get_properties_for(val, ZEND_PROP_PURPOSE_JSON);
r = PHP_JSON_OUTPUT_OBJECT;
}

// 根据类型在 JSON 字符串首部添加相应的字符
if (r == PHP_JSON_OUTPUT_ARRAY) {
smart_str_appendc(buf, '[');
} else {
smart_str_appendc(buf, '{');
}

// 这里省略了转换的逻辑

// 根据类型在 JSON 字符串末尾添加相应的字符
if (r == PHP_JSON_OUTPUT_ARRAY) {
smart_str_appendc(buf, ']');
} else {
smart_str_appendc(buf, '}');
}

zend_release_properties(prop_ht);
return SUCCESS;
}

通过上面删减过的代码可以发现,我们传入的数组最终转成 JSON 数组还是 JSON 对象,是由 r 这个变量来控制的。当 r 等于 PHP_JSON_OUTPUT_ARRAY 时,将数组转成 JSON 数组,否则转成 JSON 对象。

而变量 r 的值则是由传入的变量的类型来控制的,由于我们传入的是数组类型,并且没有设置强制转为对象的选项,所以最终是由 php_json_determine_array_type() 函数来确定要将数组转换成什么类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static int php_json_determine_array_type(zval *val)
{
int i;
HashTable *myht = Z_ARRVAL_P(val);

// 获取数组的长度
i = myht ? zend_hash_num_elements(myht) : 0;
if (i > 0) {
zend_string *key;
zend_ulong index, idx;

if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
return PHP_JSON_OUTPUT_ARRAY;
}

idx = 0;
// 遍历数组
ZEND_HASH_FOREACH_KEY(myht, index, key) {
if (key) {
// 如果元素设置了 key,返回 PHP_JSON_OUTPUT_OBJECT
return PHP_JSON_OUTPUT_OBJECT;
} else {
// 重点!当元素的下标不等于随着遍历递增的 idx 时,返回 PHP_JSON_OUTPUT_OBJECT
if (index != idx) {
return PHP_JSON_OUTPUT_OBJECT;
}
}
// 下标递增
idx++;
} ZEND_HASH_FOREACH_END();
}

// 默认返回 PHP_JSON_OUTPUT_ARRAY
return PHP_JSON_OUTPUT_ARRAY;
}

该函数的主要逻辑是,通过遍历数组检查每一个元素的 key 和下标。如果数组中的某个元素设置了 key,或者元素的下标不等于随着遍历而递增的索引时,就返回 PHP_JSON_OUTPUT_OBJECT,否则返回 PHP_JSON_OUTPUT_ARRAY。

可以看到 json_encode() 函数的实现跟我们的猜想是一样的。

知道了问题原因,解决的方法就很简单了:在调用 array_unique() 函数之后,再调用一次 array_values() 函数,将数组中的元素依次取出来保存到新数组中,这样新数组的下标就是连续的了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$arr = ['aaa', 'bbb', 'aaa', 'ccc'];
$unique_arr = array_unique($arr);

print_r($arr);
print_r(array_values($unique_arr));

// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => aaa
// [3] => ccc
// )
// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => ccc
// )