如下图所示的,日志文本多种高亮样式渲染,内容可分词进行点击以处理快速操作。
随着智研日志汇的发展,用户对前台日志检索体验的需求不断增加。在发展的各个阶段中,为了满足用户快速定位问题日志的需求,而从零开始,一步步迭代前台日志呈现的功能。
# | 需求 or 问题 | 处理 / 优化逻辑 |
---|---|---|
0 | 需求:检索关键词高亮 | 通过关键词 split 日志原文后,关键词首尾加上高亮样式 span 标签 |
1 | 需求:兼容忽略关键词的大小写 | 拷贝一份关键词数据和日志原文数据,通过toLowerCase,来标记分割的位置,再根据标记的位置来操作原关键词、原日志 |
2 | 问题:v-html导致的特殊字符问题 | 日志原文、关键词,全文替换特殊字符 |
3 | 问题:多关键词时,插入的样式标签会导致不同关键词split时相互影响 | 以split字符串为宽,不同关键词为深,递归split、添加样式标签 |
4 | 需求:需要对日志原文分词,以支持对每个词进行点击操作 | 分词:根据分词符字符集分词,输入string,输出[{isWordLike:true, segment: “…”},…]; 兼容高亮逻辑:在原有的递归高亮逻辑上,对分割出来的数组中的每个字符串进行分词,关键词默认当作一个词 |
5 | 问题:高亮逻辑破坏了分词逻辑 | 对分词好后的分词数组进行高亮逻辑处理 |
6 | 问题:分词逻辑破坏了高亮逻辑,例如高亮字符串和多个分词有交集的场景 | // TO BE CONTINUE… |
实现细节:
整体整合难点:
大体功能可以分为两大模块:「高亮逻辑模块」和「分词模块」。而两个模块底层实现上,都是对原始日志的字符串内容进行操作——根据不同的需要,对目标子串(eg: 需要高亮的字符串、被分词逻辑分出来的字符串)包装上所需要的html标签,来实现对应的功能。而问题在于,这两个功能模块是很有可能被相互影响到的。
比如以下这个字符串:
Hello World!
首先,这个字符串将被分词为(先抛结果,具体算法先略过,只有当isWorldLike===true时,才是可操作的):
[
{ "value": "Hello", "isWorldLike": true},
{ "value": " ", "isWorldLike": false},
{ "value": "World", "isWorldLike": true},
{ "value": "!", "isWorldLike": false}
]
如果用户配置了高亮关键词:「lo w」。那么,高亮逻辑和分词逻辑将会同时产生交集和并集的情况。
首先,解决两大功能模块孰先孰后的方向问题。所谓孰先孰后,就是选择打断哪一个匹配的字符串,来保证另一个的字符串完整性的问题。语言文字描述比较抽象,按上面文本:「Hello World!」、高亮「lo w」的例子来讲,我们有两种解决方案:
// 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>!
这就能很清楚的了解,分词的逻辑优先级是跟高的——因为打断分词会影响到分词功能的使用,而高亮仅作为渲染展示功能,被打断所受的影响更小。
其次,就是如何在高亮基础上做分词的问题。这里先简述下上表中,方案3的实现思路:
具体如下图所示:
这段旧的逻辑,可以复用到现在的需求当中来。区别在于:
最后,高亮功能模块输出了一个,需要高亮的子串首位下标的数组。
初版分词,直接调用浏览器的Intl.Segmenter来进行分词。但由于浏览器的自然语义分词方案,和ElasticSearch可支持自定义分词符配置不能完全吻合,故放弃该方案。
现分词方案如下图所示:(比较简单,不再赘述)
最后,分词功能模块输出了一个,由「segment(存储词语文本或分词符)」和「isWordLike」两个字段组成的结构体的数组。
简要思路,遍历一边日志文本,根据遍历到的节点,给分词包装上相应功能的HTML标签,给高亮关键词包装上渲染样式的HTML标签:
功能设计大致如下:
具体实现看下示代码(整体包装模块):
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
},
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
},
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
}]
}
},
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。