通过类JsonPath路径获取json结果所在行号的三种算法

272 阅读6分钟

介绍

全部代码已上传至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
    }
  }
}

算法前提与细节

  1. json的起始行号从1开始。
  2. 匹配的目标如果数组,那么数组只能为最终的value,不支持从数组的某一元素开始继续寻址。
  3. 在计算行号时可能会路过多种json对象,这就需要累加它们的行号。如由最简单的单行组成(user_1.name),或由嵌套json组成(user_1.user_detail),或由json数组组成(user_1.user_detail.settings),而且数组中的每个对象仍然有可能是由上述三种组成。
  4. 在累加数组类型的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)。