介绍
全部代码已上传至GitHub. darkFernMoss/jsonpath: Get the row number of the matched data via jsonpath/通过jsonpath来获取匹配到的数据的行号 (github.com)
首先我们要弄清楚该算法解决了什么问题?
该问题的出现来源于一个业务场景:在处理json时需要通过类jsonpath的路径来获取目标json中特定对象的行号(注意这里不是获取对象的结果,而是获取匹配到的第一个对象相对json起始行的行号的偏移量)
举例
这里有一个json,我想知道user_1.user_detail.links.self匹配到的结果所在的行号,即16,我应该怎么做呢?
{
"user_1": {
"name": "darkFernMoss",
"hobbies": [],
"user_detail": {
"pwd_strength": "Strong",
"create_time": "2023-04-24 02:57:17.0",
"settings": [
"dark",
"female",
"ice-cream"
],
"links": {
"next": "",
"previous": "",
"self": "https://iam.cn-east-3.myhuaweicloud.com"
},
"isDeleted": false
}
}
}
算法前提与细节
- json的起始行号从1开始。
- 匹配的目标如果数组,那么数组只能为最终的value,不支持从数组的某一元素开始继续寻址。
- 在计算行号时可能会路过多种json对象,这就需要累加它们的行号。如由最简单的单行组成(user_1.name),或由嵌套json组成(user_1.user_detail),或由json数组组成(user_1.user_detail.settings),而且数组中的每个对象仍然有可能是由上述三种组成。
- 在累加数组类型的json时,若数组为空,“[]”括号不打开,则它所占的行号仍为1,如(user_1.hobbies)。
解法一(标记重捕法)
思路:在第一次遍历时将匹配到的(key-value)对中的key打上标签(这里可以理解为将key做标记,例如加上某固定后缀,在这里我加的后缀是_cspm_highlight,方便后面能再次找到这一行),在打好标签后,使用json.indent将json数据格式化,目的是能在每一行之间加上换行符(默认的json.marshal是最紧凑格式化,不包含换行符),再通过一次遍历累加换行符个数、同时记录打过标签的key所在的行号。
func findHitLines(sourceJson, alarmJson string) (formatJson string, lines []int, err error) {
result, err := processJSON(alarmJson, sourceJson)
if err != nil {
return "", nil, err
}
bufLines := bytes.Buffer{}
err = json.Indent(&bufLines, []byte(result), "", " ")
if err != nil {
return "", nil, err
}
formatJs, lines := getLinesAndFormatJson(bufLines.String())
return formatJs, lines, nil
}
核心框架就在这个函数里,我们先通过processJson打标签,再通过json.indent格式化,最后通过getLinesAndFormatJson统计结果行号并将打过标签的key恢复原格式。
processJson函数
func processJSON(alarmJson, jsonData string) (string, error) {
m := ordermap.New()
err := json.Unmarshal([]byte(jsonData), &m)
if err != nil {
return "", err
}
kvSlice := []alarmKV{}
err = json.Unmarshal([]byte(alarmJson), &kvSlice)
if err != nil {
return "", err
}
for _, kv := range kvSlice {
splitK := strings.Split(kv.Key, ".")
tmpM := m
for i, k := range splitK {
if val, ok := tmpM.Get(k); ok {
if i == len(splitK)-1 {
isMatch := compareValues(val, kv.Value)
if isMatch {
newK := fmt.Sprintf("%s_cspm_highlight", k)
tmpM.ReplaceKey(k, newK)
}
} else {
if valMap, ok := val.(ordermap.OrderedMap); ok {
tmpM = &valMap
} else if sliceMap, ok := val.([]interface{}); ok {
// 如果不是map, 需要直接处理, 处理完把整个value替换
var newSliceMap []ordermap.OrderedMap
for _, v := range sliceMap {
tmpV, ok := v.(ordermap.OrderedMap)
if !ok {
continue
}
tmpK := splitK[i+1]
if val, ok := tmpV.Get(tmpK); ok {
isMatch := compareValues(val, kv.Value)
if isMatch {
newK := fmt.Sprintf("%s_cspm_highlight", tmpK)
tmpV.ReplaceKey(tmpK, newK)
}
}
newSliceMap = append(newSliceMap, tmpV)
}
tmpM.Set(k, newSliceMap)
break
}
}
} else {
break
}
}
}
result, err := json.Marshal(m)
if err != nil {
return "", err
}
return string(result), nil
}
注意上述方法中调用的ordermap是在github开源orderedmap库
"github.com/iancoleman/orderedmap"
的基础上自己封装了replaceKey方法来替换map中的key为我们打完标签的key.
时间复杂度分析
我们是通过alarmJson去匹配sourceJson,其长度分别为m和n,在processJson中遍历alarmJson一遍,调用json.indent也是一个时间复杂度为O(n)的函数,最后再统计行数同样是O(n),最终时间复杂度为O(m+n)。
解法二(优化前的递归解法)
上一个解法虽然容易想到但是代码较为冗长,细节繁杂,为了统计行号重新格式化json数据,那能不能不通过统计换行符的个数就直接获得行号呢?
思路:在递归中如果匹配不到jsonpath中的下一个对象了就返回false和行号,且返回的行号不会计入答案,如果匹配到了下一个对象,那么通过另一个递归函数累加目前已经路过的行号。如果已经到了jsonpath中的最后一个且值也符合,则返回true和当前行号+1。
func find(keys []string, value interface{}, dataMap *orderedmap.OrderedMap, curLine int) (ok bool, line int) {
itf, ok := dataMap.Get(keys[0])
if !ok {
return false, curLine
}
mapKeys := dataMap.Keys()
for _, k := range mapKeys {
if k != keys[0] {
get, _ := dataMap.Get(k)
curLine += countLine(get)
} else {
break
}
}
if len(keys) == 1 {
return compareValues(itf, value), curLine + 1
}
sonMap, ok := itf.(orderedmap.OrderedMap)
if ok {
return find(keys[1:], value, &sonMap, curLine+1)
}
return false, curLine
}
func countLine(itf interface{}) (ans int) {
omap, ok := itf.(orderedmap.OrderedMap)
if ok {
keys := omap.Keys()
for _, k := range keys {
get, _ := omap.Get(k)
ans += countLine(get)
}
return ans + 2
}
arr, ok := itf.([]interface{})
if ok {
if len(arr) == 0 {
return ans + 1
}
for _, obj := range arr {
ans += countLine(obj)
}
return ans + 2
}
return 1
}
时间复杂度分析
可以看到如果只匹配一次,即只有一条jsonpath用以匹配,结果也只有一个数字,那么时间复杂度为O(n),只遍历了dataJson一次,但如果有多条jsonPath,那么时间复杂度就会变成O(m*n)。该递归算法较为低效的点在于有多条jsonpath需要匹配时,前面统计过的行号并没有在下一次统计时被利用,从而造成时间上的浪费。由此引出了下一个优化版的递归算法。
解法三(优化版递归解法)
思路:这次我们对数据json在递归中向下遍历,同时维护一个string 的path来记录已经路过的jsonpath,如果此jsonpath存在于alarmJson,那么将结果添加至答案。
此递归方案最大的优化点在于我们采用逆向思维通过在遍历dataJson时维护path来匹配给定的alarmJson组成的map,充分利用了已经计算过的行号,不存在重复计算。
func matchByFullPath(data interface{}, path string, line *int, alarms map[string]interface{}, results map[string]int) {
v, ok := alarms[path]
if ok {
if compareValues(data, v) {
results[path] = *line
}
}
dataMap, ok := data.(orderedmap.OrderedMap)
if ok {
// TODO: 确认 orderedmap.OrderedMap 的解析是否将空对象的 ']' 换行
*line += b2i(len(dataMap.Keys()) != 0)
for _, k := range dataMap.Keys() {
v, _ := dataMap.Get(k)
matchByFullPath(v, buildPath(path, k), line, alarms, results)
}
//*line += b2i(len(dataMap.Keys()) != 0)
//return
}
l, ok := data.([]interface{})
if ok {
*line += b2i(len(l) != 0)
for _, e := range l {
matchByFullPath(e, path, line, alarms, results)
}
//*line += b2i(len(l) != 0)
//return
}
*line++
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func buildPath(dir, file string) string {
if len(dir) == 0 {
return file
}
return fmt.Sprintf("%s.%s", dir, file)
}
注意注释掉的两行
//*line += b2i(len(dataMap.Keys()) != 0)
//return
这两行本来是用来统计可能存在的大括号或数组括号的结尾,即“}” 或"]",但由于line += b2i(len(l) != 0)这一句在jsonMap或interface数组为0时不会加1,所以在这个函数的结尾 " line++"就可以完美兼顾括号打开与不打开的两种情况。
时间复杂度分析
只遍历dataJson一次,时间复杂度O(n)。