github:chengwang15
引言:
在日常的業(yè)務(wù)數(shù)據(jù)分析場(chǎng)景中,解析復(fù)雜的 JSON 文件并提取出有用的信息進(jìn)行分析是非常高頻的需求。但是解析復(fù)雜的 JSON 數(shù)據(jù)并不是一件易事,需要在理解數(shù)據(jù)結(jié)構(gòu)的基礎(chǔ)上,再配合一些 JSON 處理包,像剝洋蔥一樣一層一層地往里剝,費(fèi)時(shí)又費(fèi)力。
而在本文中,我們將結(jié)合具體的示例為大家介紹:如何利用 Byzer-lang 的內(nèi)置 ET 插件簡(jiǎn)單快速地進(jìn)行 JSON 數(shù)據(jù)的處理和分析,以及在遇到復(fù)雜處理邏輯時(shí)的最佳實(shí)踐是什么。
那就接著往下看吧~
基本使用
當(dāng)我們從數(shù)據(jù)源中按照不同的業(yè)務(wù)需求獲取數(shù)據(jù)時(shí),數(shù)據(jù)會(huì)以不同層級(jí)嵌套的 JSON 結(jié)構(gòu)的形式呈現(xiàn)。
在 Byzer-lang 中,直接使用內(nèi)置的 JsonExpandExt ET,就能方便地處理結(jié)果集,將一個(gè) JSON 字段展開為多個(gè)字段方便后續(xù)的分析處理。
JSON 結(jié)構(gòu)大體上可以分為兩種類型:JSON Object 和 JSON Array 。
第一種類型:JSON Object
JSON Object 形如 '{ "data": [1,2,3,4] }'
以如下數(shù)據(jù)為例:
-- 首先我們創(chuàng)建了一個(gè) mock_data 表,并將 stu 設(shè)置為一個(gè)層級(jí)嵌套的 JSON 結(jié)構(gòu)列
select '''
{ "id": "1", "name": "student_1", "detail": { "age": "8" } }
''' as stu
as mock_data;
--然后我們使用 JsonExpandExt 去推斷數(shù)據(jù)結(jié)構(gòu)
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
- JsonExpandExt:為Byzer-lang 內(nèi)置的 Json 處理插件
- ``:Byzer-lang 使用 ET 時(shí)的語(yǔ)法規(guī)則,為空即可
- inputCol="stu":表示 JSON 字段叫 stu
- structColumn="true":表示解析 inputCol 字段的數(shù)據(jù)類型
提示:在 Byzer-lang 里,如果想看到對(duì)應(yīng)的 ET 插件包含哪些參數(shù),可以用宏命令查看,例如:!show "et/params/JsonExpandExt"; 可以查看 JsonExpandExt 的相關(guān)參數(shù)。
該 Column 的結(jié)構(gòu)和層級(jí)可以通過(guò) JsonExpandExt 的推斷獲取到:
此時(shí),再想要解析這條數(shù)據(jù)就十分簡(jiǎn)單了,可以直接通過(guò)層級(jí)獲取相應(yīng)字段:
select
stu.id as id,
stu.name as name,
stu.detail.age as age
from mock_data_1 as output;
我們可以看到,此時(shí)我們想要的列已經(jīng)從復(fù)雜的 JSON 格式中被提取并展開成一張二維表:
第二種類型:JSON Array
JSON Array 形如 '[1,2,3,4]'
select '''
[1,2,3,4]
''' as json_arr
as output;
這種格式其實(shí)不怎么方便處理,但是我們可以借助一點(diǎn)技巧,先將 json array 轉(zhuǎn)化成 json object
select '''
[1,2,3,4]
''' as json_arr
as demo_1;
select
-- 利用字符串拼接將 json array 轉(zhuǎn)化成 json object
concat("{\"data\":", json_arr, "}") as json_arr_concat
from demo_1
as demo_2;
concat(col1, col2, ..., colN): Spark SQL 的函數(shù),會(huì)將 col1, col2, ..., colN 拼接后返回,轉(zhuǎn)化完成后我們就能用處理 json object 的方法處理了
如果一個(gè)字段的值是一個(gè)數(shù)組,我們可以使用 Spark SQL 的 explode 方法將數(shù)組展開。
explode(expr): 將數(shù)組 expr 的元素分隔為多行,或?qū)?map expr 的元素分隔為多行和多列。除非另有說(shuō)明,否則對(duì)數(shù)組的元素使用默認(rèn)的列名 col,或?qū)τ成涞脑厥褂面I和值。
-- 以 json array 轉(zhuǎn)化成的 json object 為例
select '''
[1,2,3,4]
''' as json_arr
as demo_1;
select
-- 利用字符串拼接將 json array 轉(zhuǎn)化成 json object
concat("{\"data\":", json_arr, "}") as json_arr_concat
from demo_1
as demo_2;
--然后我們使用 JsonExpandExt 去推斷數(shù)據(jù)結(jié)構(gòu)
run demo_2 as JsonExpandExt.`` where inputCol="json_arr_concat" and structColumn="true" as demo_3;
select
-- 通過(guò) Spark SQL 的 explode 方法將數(shù)組展開
explode(json_arr_concat.data) as item
from demo_3
as output;
問題初現(xiàn)
實(shí)踐中我們發(fā)現(xiàn),在某些特定情況下,若結(jié)果集中所有數(shù)據(jù)的 detail 字段都為 null,此時(shí)使用相同的邏輯,就會(huì)出現(xiàn)問題了。
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
select
stu.id as id,
stu.name as name,
stu.detail.age as age
from mock_data_1 as output;
報(bào)錯(cuò)提示需要一個(gè) struct 但是實(shí)際卻是一個(gè) string,這是什么情況?
第一反應(yīng)猜測(cè)可能是因?yàn)?detail 為 null,先用 case when 判斷試試。
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
select
stu.id as id,
stu.name as name,
case
when isnotnull(stu.detail) then stu.detail.age
else ""
end as age
from mock_data_1 as output;
還是一樣的報(bào)錯(cuò),這是為什么呢?
其實(shí)在 SQL 中第 12 行 stu.detail.age 這個(gè)解析是在很早的階段就進(jìn)行校驗(yàn)的,而 case when 的邏輯則是在執(zhí)行時(shí)。所以 case when 不會(huì)生效,在 SQL 做解析的時(shí)候就已經(jīng)報(bào)錯(cuò)了。
原因分析
本質(zhì)上是因?yàn)榻Y(jié)果集中所有 JSON 數(shù)據(jù)的 detail 字段都為 NULL,導(dǎo)致 JsonExpandExt 推斷該字段沒有嵌套子級(jí),而在后續(xù)處理邏輯中,我們卻將 stu.detail 當(dāng)做一個(gè) struct 去獲取 stu.detail.age,這就與推斷的數(shù)據(jù)結(jié)構(gòu)出現(xiàn)沖突,因此會(huì)報(bào)錯(cuò)。
解決方案
問題知道了,那么就該思考解決方案了。
方案一:利用 Byzer 分支語(yǔ)句處理結(jié)果集
我們首先很容易就會(huì)想到一條路:有這個(gè)字段,就處理,如果全都沒有這個(gè)字段,不處理,這不就行了?這是一個(gè)很明顯的條件判斷。
SQL 是不支持分支語(yǔ)句的,但是在 Byzer-lang 中配合宏命令做到了分支語(yǔ)句的支持,允許我們正常使用 if/else,強(qiáng)化拓展了語(yǔ)言自身的能力,具體使用手冊(cè)請(qǐng)戳這里。
這樣就得到了我們的方案一:利用分支語(yǔ)句處理結(jié)果集。
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
-- 計(jì)算 detail 字段不為 NULL 的數(shù)據(jù)的條數(shù)
set count = `select count(*) from mock_data_1 where stu.detail is not null` where type="sql";
-- 如果所有數(shù)據(jù)的 detail 字段都為 NULL
!if ''' :count == 0 ''';
!then;
select
stu.id as id,
stu.name as name,
-- 指定 age 為空字符串
"" as age
from mock_data_1 as mock_data_2;
!else;
-- 如果有 detail 字段不為空的數(shù)據(jù),那么正常處理
select
stu.id as id,
stu.name as name,
stu.detail.age as age
from mock_data_1 as mock_data_2;
!fi;
select * from mock_data_2 as output;
此時(shí),所有字段都可以被正確獲取到了:
再加一條數(shù)據(jù)試試
select '''
{ "id": "1", "name": "student_1", "detail": { "age": "8" } }
''' as stu
union
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
set count = `select count(*) from mock_data_1 where stu.detail is not null` where type="sql";
!if ''' :count == 0 ''';
!then;
select
stu.id as id,
stu.name as name,
"" as age
from mock_data_1 as mock_data_2;
!else;
select
stu.id as id,
stu.name as name,
stu.detail.age as age
from mock_data_1 as mock_data_2;
!fi;
select * from mock_data_2 as output;
也沒有問題。
方案二:分支語(yǔ)句結(jié)合 SQL 補(bǔ)全字段
在方案一的基礎(chǔ)上擴(kuò)展一下思維,我們是不是也可以自己補(bǔ)全一下缺失的字段?
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
-- 計(jì)算 detail 字段不為 NULL 的數(shù)據(jù)的條數(shù)
set count = `select count(*) from mock_data_1 where stu.detail is not null` where type="sql";
-- 如果所有數(shù)據(jù)的 detail 字段都為 NULL
!if ''' :count == 0 ''';
!then;
select
stu.id as id,
stu.name as name,
-- 通過(guò) Spark SQL 的 map 函數(shù)手動(dòng)給 detail 加上子級(jí)
map("age","123") as detail
from mock_data_1 as mock_data_2;
!fi;
select id, name, detail.age from mock_data_2 as output;
手動(dòng)補(bǔ)全數(shù)據(jù),但不是補(bǔ)全在 stu 字段里,而是把 detail 單獨(dú)出來(lái)了:
方案三:自主開發(fā) ET 滿足需求
方案一和方案二在字段比較少的時(shí)候還是可以使用的,比較簡(jiǎn)單,但是如果數(shù)據(jù)嵌套的層級(jí)過(guò)深,就無(wú)法解決了。
現(xiàn)在就輪到第三個(gè)方案出場(chǎng)了:開發(fā)一個(gè)自定義的 ET,給定一個(gè) schema,強(qiáng)制修改原來(lái)的 schema 為正確值,這樣解析 SQL 時(shí)就可以用 If 函數(shù)不會(huì)報(bào)錯(cuò)了。
Byzer-lang 在此基礎(chǔ)上才能具備足夠靈活的擴(kuò)展性,開發(fā) ET 的方法請(qǐng)戳 這里。
例如下面的一段偽代碼解釋了自主開發(fā)一個(gè)叫 ReplaceColumnExt 的 ET 的參考方式:
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
run mock_data as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_1;
-- 這是一段偽代碼
run mock_data_1 as ReplaceColumnExt.`` where col="stu.detail" and
colType="st(field(age,string))" and if=''' fieldType(stu.detail)=="string" ''' as mock_data_2;
select if(stu.detail is null, "", stu.detail.age) as age
from mock_data_2 as output;
select * from table2 as output;
方案四:使用 python 腳本補(bǔ)全字段
Byzer-lang 對(duì) Python 也提供了支持:Byzer-python,有了 Byzer-python 我們就可以在 Byzer 中擁抱 Python 的生態(tài)了。
所以即使不去自定義 ET,我們也能使用 python 腳本來(lái)修改 schema。
1)首先獲取數(shù)據(jù):
select '''
{ "id": "2", "name": "student_2", "detail": null }
''' as stu
as mock_data;
2)然后用 python 腳本補(bǔ)全數(shù)據(jù)
以 Byzer Notebook 為例,新建一個(gè)類型為 Python 的 cell,增加如下配置

#%python
#%input=mock_data_1
#%output=mock_data_2
#%schema=st(field(stu,string))
#%runIn=driver
#%dataMode=data
#%cache=true
#%env=source activate dev
from pyjava.api.mlsql import PythonContext,RayContext
import json
# type hint
context:PythonContext = context
ray_context = RayContext.connect(globals(),None)
def completion(row):
result = row
result['stu']['detail'] = result['stu'].get('detail') if result['stu']['detail'] else {'age': ""}
result['stu'] = json.dumps(result['stu'])
return result
ray_context.foreach(completion)
3)補(bǔ)全之后的數(shù)據(jù)結(jié)果顯示如下:
這樣就能很輕易的處理 detail 的 age 字段了
run mock_data_2 as JsonExpandExt.`` where inputCol="stu" and structColumn="true" as mock_data_3;
select
stu.id as id,
stu.name as name,
stu.detail.age as age
from mock_data_3 as output;
總結(jié)
以上四種方法只是拋磚引玉,期待小伙伴們有更多優(yōu)雅的姿勢(shì)來(lái) Byzer 社區(qū)一起分享。