本文正在参加「金石计划」
需求
日志服务平台是提供 一站式的日志解决方案的日志服务。
具体提供日志接入
、数据采集
、日志管理
、日志告警
、日志检索
、图表分析
、链路追踪
等。
日志检索是我们日志服务平台必备的核心能力,同时也是用户使用频率最高的一个模块,所以针对该模块提供更优质的体验、更稳定的服务就成为我们首要任务。
Kibana ES7 版本提供了 KQL 检索,这个功能可以提供模糊匹配,智能提示语法等能力。可帮助用户快速的、便捷的进行日志检索。
目前给用户开放的是 Kibana ES6 版本,ES7 版本 和 ES6 版本底层之间有差异,不能直接连通使用。
正因为 ES6 版本不支持,加上日志服务平台是一站式的日志服务,所以我们日志服务平台需要实现语句 KQL模式检索
。
那么就有了一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。
该功能详见 kibana es7版本。有条件的可以去使用一下,感受一番。
如果让大家做这么一个需求?大家会有什么思路?会怎么做?请先思考。
需求分析
使用了一下该功能,感觉还是挺复杂的。不好实现啊,我,我,我。。。
不过因为 kibana 是开源的,我就去 github 上看了一下源码。
- 首先人家是 React 版本,我的项目是 Vue 版本,我不能行使拿来主义。
- 一个 input 框的核心代码写了一千多行,不包括一些工具函数,公共组件之类。
我的方案
因为 kibana 是开源的,我就去 github 上看了一下源码。
- 首先人家是 React 版本,我的项目是 Vue 版本,我不能行使拿来主义。
- 一个 input 框的核心代码写了一千多行,不包括一些工具函数,公共组件之类。
源码分析:
Kibana ES7 版本的 KQL 查询的输入框智能匹配前端代码逻辑可以分为以下几个部分:
-
监听用户输入:前端代码会监听用户在搜索框中输入的内容。
-
发送请求获取匹配项:当用户输入内容时,前端代码会向 Elasticsearch 服务器发送请求,获取与用户输入内容匹配的查询项。
-
处理匹配项:前端代码会将 Elasticsearch 返回的匹配项进行处理,以便在搜索框中展示给用户。
-
展示匹配项:前端代码会将处理后的匹配项展示在搜索框下拉列表中,供用户选择。
-
监听用户选择:当用户选择某个匹配项时,前端代码会将该项的值填充到搜索框中,并触发查询操作。
总的来说,这些功能可以帮助用户快速输入正确的查询语句,提高查询效率。
方案一、
Q:我先研究源码,再把研究好的源码全部转成 vue 版本输出?
A:由于源码部分也是非常复杂,并没有第一时间内全部消化,只梳理了大致的流程。同时该方案短时间内看不到效果,需要好好梳理其源码。是一个 0 或者 1 的问题,如果研究好了并实现转化出来,那就是 1,如果期间遇到问题阻塞了,那就是短时间看不到产出效果。不敢冒险。
方案二、
Q:创建一个 React 项目,把相关的这部分代码拆分出来,以微前端的方式内嵌到我的项目中?
A:因为我们的业务也不是全部一样,存在差异。同时不知道在拆分代码和组装代码的过程中会遇到什么问题?未知,不敢冒险去耽误时间,也是一个 0 或者 1 的问题。
方案三、
Q:了解了源码大致流程,加上自研自己研究 KQL 语法,自己摸索规则,自己实现其逻辑?
A:由于项目排期紧张,不敢太过冒险,就选择了自研。起码能看到进度。😁
我最后选择的是方案三:自研(梳理了源码大致逻辑 + 自研语法分析器) 。
难点就在没有人告诉我规则,需要一个“门外汉”来自行摸索,深入业务使用,自己研究。
针对自研方案,我们就开干吧!撸起袖子加油干!😄
准备
首先,我们需要一些准备工作,我需要了解 KQL 语法是什么?然后使用它,研究其规则,梳理其逻辑。
Kibana 查询语言 (Kibana Query Language、简称 KQL) 是一种使用自由文本搜索或基于字段的搜索过滤 Elasticsearch 数据的简单语法。 KQL 仅用于过滤数据,并没有对数据进行排序或聚合的作用。
KQL 能够在您键入时建议字段名称、值和运算符。
KQL 能够查询嵌套字段和脚本字段。 KQL 不支持正则表达式或使用模糊术语进行搜索。
更为详细的可以看官方文档 Kibana Query Language
Kibana的KQL(Kibana Query Language)是一种基于Lucene查询语法的查询语言,用于在Kibana中进行数据查询和分析。以下是KQL的语法规则:
-
查询语句以查询关键字(如
query
、filter
、must
、should
等)开头,后面跟着查询条件。 -
查询条件由字段名和查询值组成,中间用冒号分隔。例如:
field_name:query_value
。 -
多个查询条件可以通过逻辑运算符(如
AND
、OR
、NOT
)组合起来。例如:field_name1:query_value1 AND field_name2:query_value2
。 -
查询值可以使用通配符(如
*
、?
)进行模糊匹配。例如:field_name:query_*
。 -
查询值可以使用引号将多个单词组合成一个短语进行精确匹配。例如:
field_name:"query phrase"
。 -
查询值可以使用布尔运算符(如
>
,<
,>=
,<=
,=
,!=
)进行比较。例如:field_name > query_value
。 -
查询语句可以使用括号来控制运算符的优先级。例如:
(field_name1:query_value1 OR field_name2:query_value2) AND field_name3:query_value3
。 -
查询语句可以使用特殊字符(如
+
、-
、()
、[]
、{}
等)进行高级查询。例如:+field_name:query_value -field_name2:query_value2
。 -
查询语句可以使用聚合函数(如
count
、sum
、avg
、min
、max
等)进行数据分析。例如:count(field_name)
。
总的来说,KQL的语法规则与Lucene查询语法类似,但是KQL更加简洁易懂,适合非专业人士使用。
简单总结:
- key method value 标准单个语句
- key method value OR/AND key method value OR/AND key method value …. 标准多个语句
- key OR/AND key OR/AND key OR/AND key method value …. 不标准多个语句
- (key method value OR/AND key method value) OR/AND key method value …. 优先级
- key method value OR/AND (key method value OR/AND key method value) …. 优先级
- ......
Tips:key(字段名称)、method(运算符)、value(值)
实现
textarea
- 由于用户可以输入多行文本信息,所以需要 textarea。type="textarea"
- 为了用户能清楚看到输入内容,以及input 的美观,初始行数 :rows="2"
- 因为我们能支持关键字和KQL两种情况,所以 placeholder="KQL/关键字"
- 获取焦点需要打开下拉展示框 @focus="dropStatus = true"
- 失去焦点且没有操作下拉选项则关闭下拉框 @blur="changeDrop"
- 由于下拉框的位置需要跟着 textarea 高度变化,所以 v-resizeHeight="inputResizeHeight"
<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
>
</el-input>
changeDrop 需要判断用户是否正在操作下拉框内容,如果是,就不要关闭。这块你会怎么实现呢?可以先思考自己的实现方式,再看下边是我个人的实现方式。
其实理论就是给下拉框操作的时候增加标记,在失去焦点要关闭的时候,判断是否有这个标记,如果有,就不要关闭,否则就关闭。但这个标记又不能影响真正的失焦状态下关闭动作。
我想到的就是定时器,定时器能增加一个变量,同时还能自动销毁。具体的实现方式:
// 不关闭下拉框标记
noCloseInput() {
this.$refs.searchInputRef.focus()
if (this.timer) clearInterval(this.timer)
let time = 500
this.timer = setInterval(() => {
time -= 100
if (time === 0) {
clearInterval(this.timer)
this.timer = null
}
}, 100)
}
// 失焦操作
changeDrop() {
setTimeout(() => {
if (!this.timer) this.dropStatus = false
}, 200)
}
这么做需要有以下几点注意:
- 失焦操作因为需要切换到下拉框有一定延迟需要定时器,而定时器的时间必须小于标记里边的定时器时间
- 定时器 this.timer = setInterval() 中 this.timer 是定时器的 id
- clearInterval(this.timer) 只会清除定时器,不会清空 this.timer
v-resizeHeight="inputResizeHeight" 这个是我写的一个自定义指令来检测元素的高度变化的,不知道你有什么好的方法吗?有的话请请共享一下,😍
const resizeHeight = {
// 绑定时调用
bind(el, binding) {
let height = ''
function isResize() {
// 可根据需求,调整内部代码,利用 binding.value 返回即可
const style = document.defaultView.getComputedStyle(el)
if (height !== style.height) {
// 此处关键代码,通过此处代码将数据进行返回,从而做到自适应
binding.value({ height: style.height })
}
height = style.height
}
// 设置调用函数的延时,间隔过短会消耗过多资源
el.__vueSetInterval__ = setInterval(isResize, 100)
},
unbind(el) {
clearInterval(el.__vueSetInterval__)
}
}
export default resizeHeight
下拉面板
下拉框是左右布局,右侧是检索语法说明的静态文案,可忽略。左侧是语句提示内容。
语句提示内容经过研究其实有四种:
key(字段名称)、method(运算符)、value(值)、connectionSymbol(连接符)
由于可能会有多个语句,其实我们是只对当前语句进行提示的,所以我们只分析当前语句的情况。
// 当前语句详情
{
cur_fields: '', // 当前 key
cur_methods: '', // 当前 method
cur_values: '', // 当前 value
cur_input: '' // 当前用户输入内容,可模糊匹配检索
}
有四部分,肯定就是需要在符合条件的情况下分别展示对应的 options 面板内容。
那判断条件就是如下图,其中后续需要注意的就是这几个判断条件的值赋值场景要准确。
语法分析器
想处理输入内容,做一个语法分析器,首先需要去监听用户的输入,那么就用 vue 提供的 watch。
watch: {
input: debounce(function(newValue, oldValue) {
if (newValue !== oldValue) this.dealInputShow(newValue)
}, 500)
}
基本大概思路如下:
其中获取输入框的光标位置的方法如下:
const selectionStart = this.$refs.searchInputRef.$el.children[0].selectionStart
修改完了之后,光标会自动跑的最后,这样有点违反用户操作逻辑,所以需要设置一下光标位置:
if (this.endValue) {
this.$nextTick(() => {
const dom = this.$refs.searchInputRef.$el.children[0]
dom.setSelectionRange(this.input.length, this.input.length)
this.input += this.endValue
})
}
还有面板里边有四项内容,那每一项内容选择都可以通过鼠标点击选择,点击选择后,就需要按照规则处理一下,进行最终的字符串 this.input 拼接,得到最终结果。
// 当前 key 点击选择
curFieldClick(str) {},
// 当前 method 点击选择
curMethodClick(str) {},
// 当前 value 点击选择
curValueClick(str) {},
// 当前 链接符 点击选择
curConnectClick(str) {},
这部分需要注意的就是点击面板 input 会失去焦点,就加上前边说到的 noCloseInput()
不关闭下拉面板标记。
键盘快捷操作
必备的目前就 3 个事件 enter、up、down,其他算是锦上添花,由于排期紧张,暂时只做了必备的 3 个 事件:
<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
@keydown.enter.native.capture.prevent="getSearchEnter($event)"
@keydown.up.native.capture.prevent="getSearchUp($event)"
@keydown.down.native.capture.prevent="getSearchDown($event)"
>
</el-input>
那么,我们的这几个键盘事件都需要怎么处理呢??接下来就直接上代码简单分析一下:
// 键盘 enter 事件,有两种情况
// 一种就是 选择内容,第二种就是 相当于回车事件直接触发接口
getSearchEnter(event) {
event.preventDefault()
// 当前下拉面板的展示的 options
const suggestions = this.get_suggestions()
// 满足可以选的条件
if (this.dropStatus && this.dropIndex !== null && suggestions[this.dropIndex]) {
// 光标之后是否有内容,有就需要截取处理
// ......
// 当前项是否是手动输入的,需要做截取处理
// .......
// 拼接 enter 键选择的选项
this.input += suggestions[this.dropIndex] + ' '
// 光标之后是否有内容,就需要设置光标在当前操作位置,并拼接之前截取掉的光标后的内容
// .......
// 设置当前语法区域的各个当前项 cur_fields、cur_methods、cur_values
// ......
// 恢复键盘 up、down 选择初始值
this.dropIndex = 0
} else {
// 不满足选的条件,就关闭选择面板,并触发检索查询接口
this.dropStatus = false
this.$emit('getSearchData', 2)
}
},
// 键盘 up 事件
getSearchUp(event) {
event.preventDefault()
// 满足上移,就做 dropIndex 减法
if (this.dropStatus && this.dropIndex !== null) {
this.decrementIndex(this.dropIndex)
}
},
// 键盘 down 事件
getSearchDown(event) {
event.preventDefault()
// 满足下移,就做 dropIndex 加法
if (this.dropStatus && this.dropIndex !== null) {
this.incrementIndex(this.dropIndex)
}
},
// 加法,注意边界问题
incrementIndex(currentIndex) {
let nextIndex = currentIndex + 1
const suggestions = this.get_suggestions()
// 到最后边,重置到第一个,形成循环
if (currentIndex === null || nextIndex >= suggestions.length) {
nextIndex = 0
}
this.dropIndex = nextIndex
// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},
// 减法,注意边界问题
decrementIndex(currentIndex) {
const previousIndex = currentIndex - 1
const suggestions = this.get_suggestions()
// 到最前边,重置到最后,形成循环
if (previousIndex < 0) {
this.dropIndex = suggestions.length - 1
} else {
this.dropIndex = previousIndex
}
// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},
键盘事件的核心逻辑上述基本说清楚了,那么其中需要注意的一个点,那就是被选择的选项如果不在可视范围之内,需要滚动到可视区
,这样可提高用户体验。那这块到底怎么做呢?其实实现起来还挺有意思的。
import scrollIntoView from './scroll-into-view'
// 滚动 optiosns 区域,保持在可视区域
scrollToOption() {
if (this.dropStatus === true) {
const target = document.getElementsByClassName('drop-active')[0]
const menu = document.getElementsByClassName('search-drop__left')[0]
scrollIntoView(menu, target)
}
},
scroll-into-view.js 内容如下:
export default function scrollIntoView(container, selected) {
// 如果当前激活 active 元素不存在
if (!selected) {
container.scrollTop = 0
return
}
const offsetParents = []
let pointer = selected.offsetParent
while (pointer && container !== pointer && container.contains(pointer)) {
offsetParents.push(pointer)
pointer = pointer.offsetParent
}
const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0)
const bottom = top + selected.offsetHeight
const viewRectTop = container.scrollTop
const viewRectBottom = viewRectTop + container.clientHeight
if (top < viewRectTop) {
container.scrollTop = top
} else if (bottom > viewRectBottom) {
container.scrollTop = bottom - container.clientHeight
}
}
针对上述内容几个技术点做出简单解释:
offsetParent:就是距离该子元素最近的进行过定位的父元素,如果其父元素中不存在定位则 offsetParent为:body元素。
offsetParent 根据定义分别存在以下几种情况:
- 元素自身有 fixed 定位,父元素不存在定位,则 offsetParent 的结果为 null(firefox 中为:body,其他浏览器返回为 null)
- 元素自身无 fixed 定位,且父元素也不存在定位,offsetParent 为
<body>
元素 - 元素自身无 fixed 定位,且父元素存在定位,offsetParent 为离自身最近且经过定位的父元素
<body>
元素的 offsetParent 是 null
offsetTop:元素到 offsetParent 顶部的距离
offsetHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding)和边框(border),不包含外边距(margin),是一个整数,单位是像素 px。
通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条(如果存在且渲染的话),不包含 :before或 :after 等伪类元素的高度。
scrollTop:可以获取或设置一个元素的内容垂直滚动的像素数。
一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为0。
clientHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding),不包含边框(border),外边距(margin)和滚动条,是一个整数,单位是像素 px。
clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在)来计算。
最全各个属性相关图如下:
效果
效果怎么说呢,也算顺利上线生产环境了,在此截图几张,给大家看看效果。
小结
做这个需求,最难的点是要求自己去研究 KQL 的语法规则,以及使用方式,然后总结规则,写出自己的词法分析器。
其中有什么技术难点吗?似乎并没有,都是各种判断条件,最简单的 if-else。
所以想告诉大家的是,不要一心只钻研技术,在做业务的时候也需要好好梳理业务,做一个懂业务的技术人。业务和技术互相成就!
最后,如果感到本文还可以,请给予支持!来个点赞、评论、收藏三连,万分感谢!😄🙏