前端日志个性化渲染方案衍化与设计实现

2023-11-21 14:52:29 浏览数 (1)

目标功能

如下图所示的,日志文本多种高亮样式渲染,内容可分词进行点击以处理快速操作。

目标功能示例截图目标功能示例截图

背景

随着智研日志汇的发展,用户对前台日志检索体验的需求不断增加。在发展的各个阶段中,为了满足用户快速定位问题日志的需求,而从零开始,一步步迭代前台日志呈现的功能。

迭代阶段摘要

#

需求 or 问题

处理 / 优化逻辑

0

需求:检索关键词高亮

通过关键词 split 日志原文后,关键词首尾加上高亮样式 span 标签

1

需求:兼容忽略关键词的大小写

拷贝一份关键词数据和日志原文数据,通过toLowerCase,来标记分割的位置,再根据标记的位置来操作原关键词、原日志

2

问题:v-html导致的特殊字符问题

日志原文、关键词,全文替换特殊字符

3

问题:多关键词时,插入的样式标签会导致不同关键词split时相互影响

以split字符串为宽,不同关键词为深,递归split、添加样式标签

4

需求:需要对日志原文分词,以支持对每个词进行点击操作

分词:根据分词符字符集分词,输入string,输出[{isWordLike:true, segment: “…”},…]; 兼容高亮逻辑:在原有的递归高亮逻辑上,对分割出来的数组中的每个字符串进行分词,关键词默认当作一个词

5

问题:高亮逻辑破坏了分词逻辑

对分词好后的分词数组进行高亮逻辑处理

6

问题:分词逻辑破坏了高亮逻辑,例如高亮字符串和多个分词有交集的场景

// TO BE CONTINUE…

方案设计

功能需求和技术难点

功能需求

  1. 能够高亮检索匹配到的关键词
  2. 能够高亮用户自定义的关键词
  3. 将原始日志进行分词操作,每个词支持点击快速添加到日志检索条件中
  4. 值为JsonString的日志字段内容,支持格式成结构化样式,格式化后的内容,需要兼容前面三个功能

技术难点

实现细节:

  1. 功能 1 和功能 2 可以合并为同一个功能,用相同的逻辑渲染不同的样式。
  2. 功能 3 的注意点在于,可点击的triger将会很多,需要注意性能优化问题;分词逻辑的设计。
  3. 功能 4 的麻烦点在于如何将开源社区的组建,和本项目非常个性化的功能相结合起来。
  4. 还需要注意,当单条日志长度超级长时的极端情况,所可能造成的前端性能问题。

整体整合难点:

大体功能可以分为两大模块:「高亮逻辑模块」和「分词模块」。而两个模块底层实现上,都是对原始日志的字符串内容进行操作——根据不同的需要,对目标子串(eg: 需要高亮的字符串、被分词逻辑分出来的字符串)包装上所需要的html标签,来实现对应的功能。而问题在于,这两个功能模块是很有可能被相互影响到的。

比如以下这个字符串:

代码语言:javascript复制
Hello World!

首先,这个字符串将被分词为(先抛结果,具体算法先略过,只有当isWorldLike===true时,才是可操作的):

代码语言:javascript复制
[
	{ "value": "Hello", "isWorldLike": true},
	{ "value": " ", "isWorldLike": false},
	{ "value": "World", "isWorldLike": true},
	{ "value": "!", "isWorldLike": false}
]

如果用户配置了高亮关键词:「lo w」。那么,高亮逻辑和分词逻辑将会同时产生交集和并集的情况。

功能设计

功能框架

首先,解决两大功能模块孰先孰后的方向问题。所谓孰先孰后,就是选择打断哪一个匹配的字符串,来保证另一个的字符串完整性的问题。语言文字描述比较抽象,按上面文本:「Hello World!」、高亮「lo w」的例子来讲,我们有两种解决方案:

代码语言:javascript复制
// plan1:
<link>Hel<highlight>lo</highlight></link><highlight> </highlight><link><highlight>W</highlight>orld</link>!

// plan2:
<link>Hel</link><highlight><link>lo</link> <link>W</link></highlight><link>orld</link>!
  • plan1:是优先保证分词逻辑的完整性,把高亮内容打断
  • plan2:是优先保证高亮内容的完整性,把分词的内容打断

这就能很清楚的了解,分词的逻辑优先级是跟高的——因为打断分词会影响到分词功能的使用,而高亮仅作为渲染展示功能,被打断所受的影响更小。

高亮方案设计

其次,就是如何在高亮基础上做分词的问题。这里先简述下上表中,方案3的实现思路:

  1. 将高亮关键词由长到短进行排序(优先高亮更长的关键词,以此略过有交集、并集的情况)
  2. 以高亮关键词数组为纵深,进行递归:
    1. 递归参数:当前日志文本字符串、当前遍历的高亮关键词
    2. 处理逻辑:
      1. 用高亮关键词split分割日志文本字符串
      2. 将每个得到分割的数组,带上下一个高亮关键词进入新的递归
      3. 遍历边界:遍历完所有高亮关键词即退出

具体如下图所示:

高亮功能流程图高亮功能流程图

这段旧的逻辑,可以复用到现在的需求当中来。区别在于:

  • 旧的逻辑:每层退出遍历前,会将高亮关键词包装上高亮的样式「<span class=”***”>highlight_keyword</span>」,作为参数,将split完、经历递归包装的日志文本字符串数组再join起来,最后返回一串innerHTML字符串
  • 新的逻辑:不再进行join操作,也不再返回一个innerHTML字符串。而是返回需要高亮的子串首位下标位置

最后,高亮功能模块输出了一个,需要高亮的子串首位下标的数组。

分词方案设计

初版分词,直接调用浏览器的Intl.Segmenter来进行分词。但由于浏览器的自然语义分词方案,和ElasticSearch可支持自定义分词符配置不能完全吻合,故放弃该方案。

现分词方案如下图所示:(比较简单,不再赘述)

分词功能流程图分词功能流程图

最后,分词功能模块输出了一个,由「segment(存储词语文本或分词符)」和「isWordLike」两个字段组成的结构体的数组。

两大模块整合方案设计

简要思路,遍历一边日志文本,根据遍历到的节点,给分词包装上相应功能的HTML标签,给高亮关键词包装上渲染样式的HTML标签:

功能设计大致如下:

整体实现流程图整体实现流程图

具体实现看下示代码(整体包装模块):

代码实现

整体包装模块

代码语言:javascript复制
    wrapSegments(text, expand = true) {
      let remain = null
      // 性能优化:如果文本长度超长,则隐藏超长部分
      if (text?.length > this.foldLimit) {
        if (expand) {
          remain = text.slice(this.foldLimit, text.length)
        }
        text = text.slice(0, this.foldLimit)
        if (!expand) {
          text  = '...'
        }
        this.expand = expand === true
      }
      // 获取高亮范围:
      const hlRange = this.getDecorateRanges((text   (remain || '')).toLowerCase(), 0, this.keyword.length - 1).sort((a, b) => a.start - b.start)
      // 获取分词数组:
      const segments = this.segmenter(text)
      if (remain) {
        segments.push({
          segment: remain,
          isWordLike: false
        })
      }

      let result = ''
      let hlIndex = 0 // 扫描到的高亮关键词下标
      let head = 0 // 记录扫描过的分词长度,高亮替换时减掉
      const spanClass = this.logConfig.segmenter ? 'class="quick-search-segment"' : ''
      for (const segment of segments) {
        let str = segment.segment
        let buffer = 0 // 每个分词当中,已经加上的HTML标签的总长度,用来记录偏移量
        let replaceEnd = 0 // replace end: 记录html关键字符转义结尾
        while (hlRange[hlIndex]?.start < head   segment.segment.length) {
          let before = ''
          switch (hlRange[hlIndex].type) {
            case ('bold'):
              before = `<span ${spanClass} style="font-weight: bold; color: red;">`
              break
            case ('keyword'):
              before = `<span ${spanClass} style="${styles[hlRange[hlIndex].index % styles.length]}">`
              break
            case ('query'):
              before = `<span ${spanClass} style="color: red;">`
          }
          const start = hlRange[hlIndex].start - head   buffer
          let end = hlRange[hlIndex].end - head   buffer
          let moveIndex = false
          if (end > buffer   segment.segment.length) {
            end = str.length
          } else {
            moveIndex = true
          }
          // replaceKeyChar:替换HTML关键字符(<、>、&、")
          const beforeStr = this.replaceKeyChar(str.slice(replaceEnd, start))
          const kwStr = this.replaceKeyChar(str.slice(start, end))

          // 连带包装好的关键词的 从头到当前扫描位置的 字符串
          const tmpStr = `${str.slice(0, replaceEnd)}${beforeStr}${before}${kwStr}</span>`

          // 字符转换、高亮标签增加的长度
          buffer  = beforeStr.length - str.slice(replaceEnd, start).length   kwStr.length - str.slice(start, end).length   before.length   7
          replaceEnd = tmpStr.length

          str = tmpStr   str.slice(end, str.length)
          if (moveIndex) {
            hlIndex  
          } else {
            hlRange[hlIndex].start = head   segment.segment.length
          }
        }
        if (replaceEnd < str.length) {
          str = `${str.slice(0, replaceEnd)}${this.replaceKeyChar(str.slice(replaceEnd, str.length))}`
        }
        if (segment.isWordLike) {
          result  = `<span class="quick-search-segment" title="${this.keyValue}">${str}</span>`
        } else {
          result  = str
        }
        head  = segment.segment.length
      }
      return result
    },

高亮功能模块

代码语言:javascript复制
    getDecorateRanges(text, head, hlIndex) {
      if (hlIndex < 0) {
        return []
      }
      const ranges = []
      const keyword = this.keywordMatcher[hlIndex]
      const arr = (text   '').split(keyword.text.toLowerCase())
      let front = 0
      for (let i = 0; i < arr.length; i  ) {
        if (i < arr.length - 1) {
          ranges.push({
            start: head   front   arr[i].length,
            end: head   front   arr[i].length   keyword.text.length,
            type: keyword.type,
            index: hlIndex
          })
        }
        ranges.push(...this.getDecorateRanges(arr[i], head   front, hlIndex - 1))
        front  = arr[i].length   keyword.text.length
      }
      return ranges
    },

分词功能模块

代码语言:javascript复制
    segmenter(text) {
      if (!this.logConfig.segmenter) {
        return [{
          segment: text,
          isWordLike: false
        }]
      }
      if (this.fieldDataMapping?.isCls || this.keyValue === '@message') {
        return logSegmenter(text, this.fieldDataMapping?.isCls ? CLS_TOKENIZER : undefined)
      }
      const mapping = this.fieldDataMapping ? this.fieldDataMapping[this.keyValue] : null
      if (mapping?.type?.includes('text') && mapping.index === true) {
        return logSegmenter(text, mapping.analyzer?.pattern || undefined)
      } else {
        return [{
          segment: text,
          isWordLike: true
        }]
      }
    },

0 人点赞