目标功能
如下图所示的,日志文本多种高亮样式渲染,内容可分词进行点击以处理快速操作。
背景
随着智研日志汇的发展,用户对前台日志检索体验的需求不断增加。在发展的各个阶段中,为了满足用户快速定位问题日志的需求,而从零开始,一步步迭代前台日志呈现的功能。
迭代阶段摘要
# | 需求 or 问题 | 处理 / 优化逻辑 |
---|---|---|
0 | 需求:检索关键词高亮 | 通过关键词 split 日志原文后,关键词首尾加上高亮样式 span 标签 |
1 | 需求:兼容忽略关键词的大小写 | 拷贝一份关键词数据和日志原文数据,通过toLowerCase,来标记分割的位置,再根据标记的位置来操作原关键词、原日志 |
2 | 问题:v-html导致的特殊字符问题 | 日志原文、关键词,全文替换特殊字符 |
3 | 问题:多关键词时,插入的样式标签会导致不同关键词split时相互影响 | 以split字符串为宽,不同关键词为深,递归split、添加样式标签 |
4 | 需求:需要对日志原文分词,以支持对每个词进行点击操作 | 分词:根据分词符字符集分词,输入string,输出[{isWordLike:true, segment: “…”},…]; 兼容高亮逻辑:在原有的递归高亮逻辑上,对分割出来的数组中的每个字符串进行分词,关键词默认当作一个词 |
5 | 问题:高亮逻辑破坏了分词逻辑 | 对分词好后的分词数组进行高亮逻辑处理 |
6 | 问题:分词逻辑破坏了高亮逻辑,例如高亮字符串和多个分词有交集的场景 | // TO BE CONTINUE… |
方案设计
功能需求和技术难点
功能需求
- 能够高亮检索匹配到的关键词
- 能够高亮用户自定义的关键词
- 将原始日志进行分词操作,每个词支持点击快速添加到日志检索条件中
- 值为JsonString的日志字段内容,支持格式成结构化样式,格式化后的内容,需要兼容前面三个功能
技术难点
实现细节:
- 功能 1 和功能 2 可以合并为同一个功能,用相同的逻辑渲染不同的样式。
- 功能 3 的注意点在于,可点击的triger将会很多,需要注意性能优化问题;分词逻辑的设计。
- 功能 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的实现思路:
- 将高亮关键词由长到短进行排序(优先高亮更长的关键词,以此略过有交集、并集的情况)
- 以高亮关键词数组为纵深,进行递归:
- 递归参数:当前日志文本字符串、当前遍历的高亮关键词
- 处理逻辑:
- 用高亮关键词split分割日志文本字符串
- 将每个得到分割的数组,带上下一个高亮关键词进入新的递归
- 遍历边界:遍历完所有高亮关键词即退出
具体如下图所示:
这段旧的逻辑,可以复用到现在的需求当中来。区别在于:
- 旧的逻辑:每层退出遍历前,会将高亮关键词包装上高亮的样式「<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
}]
}
},