代码搜索引擎:基础篇

Posted AI前线

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了代码搜索引擎:基础篇相关的知识,希望对你有一定的参考价值。

作者 | 伴鱼技术团队
0. 引入

最近,我们遇到了两个场景:

  1. 负责基础服务的工程师想下线一个接口但不知道有哪些服务调用

  2. 负责 APM 系统的工程师想知道任意 RPC 接口的所有上游调用方

仔细分析不难发现,二者的本质都在于「维护微服务间的静态依赖关系」。等等!在调用链追踪系统中,我们不是已经获得了接口级别的依赖关系吗?为什么不能直接用那边的数据?目前伴鱼调用链追踪系统中维护的依赖关系在三个方面无法满足上述需求:

  1. 一些上古服务仍然在运行,但没有接入调用链追踪系统

  2. 调用链追踪系统中维护的是「动态依赖关系」,即最近 N 天 (由 retention policy 决定) 捕获的调用关系

  3. 调用链追踪系统中存储的是经采样策略过滤后的数据,可能存在漏采的情况

于是我们开始思考另一个方向:通过代码搜索引擎提取静态依赖关系。恰好在 2020 Q4 末,我们将内部所有项目仓库从 Gerrit 迁移到了 Gitlab,为代码搜索引擎的落地铺平了道路。

在下文中,我们将和大家分享代码搜索引擎的调研报告,期望能帮助读者了解代码搜索引擎如何工作。报告主要讨论以下话题:

  • 为什么做

  • 一般架构

  • 设计决定

  • 实现挑战

  • 开源项目

1. 为什么做

Google 内部曾对工程师做一次 调研,发现平均每位工程师每天会进行 5.3 次代码搜索会话 (session),执行 12 个代码搜索请求;在 Github/Gitlab 等仓库托管服务中,搜索是工程师最常用的功能之一;在「引入」中,我们也介绍了伴鱼搭建代码搜索引擎的初衷。那么在业界的实践中,代码搜索引擎主要被用来解决哪些问题呢?

常见的代码搜索场景包括:

  • 理解代码间的依赖关系

  • 寻找即将弃用 (deprecated) 接口的引用地点

  • 避免重复造轮子

  • 分享编码方案和编码风格

  • 发现低质量的代码

  • 定位安全问题

尽管服务和仓库不一定是一一映射关系,但如果服务被拆分,通常仓库也会被拆分;服务拆分后可观测性 (observability) 会下降,仓库被拆分后可观测性也会下降。在仓库拆分前,搜索代码只需要执行 grep 命令;仓库拆分后,工程师连公司内部存在哪些仓库都无法准确知道,更不用说 clone 到本地进行搜索。因此代码搜索引擎实际上是一种提高仓库可观测性的工具。

2. 一般架构

代码搜索引擎:基础篇

如上图所示,代码搜索引擎通常可以分为两个部分:Web Server 和 Index Serer。

Web Server 负责渲染查询页面,接收用户的查询请求,将查询调度到合适的 Index Server 中,获取查询结果,并返回到前端向用户展示。Index Server 负责从仓库托管服务中按给定的策略拉取相关仓库数据到本地并建立索引。当仓库数据更新时,需要同步仓库变动,更新索引,保证数据的最终一致性。当仓库数量过多,索引体积过大时,Index Server 需要支持横向扩展,分片管理数据;当请求数量过多时,Web Server 也需要支持横向扩展。

实践中,有的项目会将 Web Server 和 Index Server 合而为一,有的会将 Index Server 的仓库同步和索引建立进一步拆分成两个模块,甚至将仓库和索引的元数据用关系型数据库单独管理,但这一切都能够以「一般框架」为起点设计。

3. 设计决定

了解了代码搜索引擎的一般架构后,我们从以下四个角度出发,讨论该系统各个模块的设计决定:

  • 查询语言

  • 索引结构

  • 数据管理

  • 结果排序

3.1 查询语言

代码搜索引擎:基础篇

如上图所示,代码搜索引擎的查询语言通常由两部分构成:「修饰词」和「匹配器」。「修饰词」用来指定查询的范围,如仓库名称、文件名称、编程语言等等。它既用于帮助用户更精确地描述查询内容,也能够为搜索引擎更高效地执行提供线索。「匹配器」则是对目标代码特征的表述,它可以是关键词 (keyword)、子串 (substring)、正则表达式 (regular expression),也可以是包含编程语言特征的结构化 (structural) 描述。

举例如下:语句

r:kubernetes b:master common.*Describe

表示的是:搜索 kubernetes 仓库 master 分支中,匹配正则表达式 common.*Describe 的源码。再看看「匹配器」,假设有一个文档 (document) 内容如下:

func greeting() {
fmt.Println("hello world")
}

通过关键词匹配,你可以搜索单词,如 “greeting”,或词组,如 “hello world”,搜索到该文档,但无法通过搜索子串,如 “greet” 或 “Print” 达到目的;通过子串匹配,你可以搜索任意子串,如 “greet”、”Print”,当然跨越单词的子串也没问题,如 “ello wor”;通过正则表达式,你的描述可以更加灵活,如所有包含 ctx 参数的函数可以搜索 “^func.*ctx”。关键词、子串、正则表达式的表达力依次递增,但三者都属于纯文本匹配器。如果想基于编程语言的语法结构来搜索,那么就需要结构化匹配器。例如在 Sourcegraph 中,你可以通过以下匹配器:

switch :[[v]] := :[x].(type) {:[_] case nil: :[_]}

来匹配 Go 源码中 type switch 代码块中包含 nil case 的情况。

3.2 索引结构

代码搜索引擎之于通用文本搜索引擎,就如时序数据库之于关系型数据库,前者是后者的一个特例。因此驱动代码搜索引擎的许多索引结构源于通用文本搜索引擎。

代码搜索引擎:基础篇

如上图所示,我们大致可以将代码搜索引擎常用的索引结构分为两类:「基于文本」 (text-based) 和「语言感知」 (language-aware)。基于文本的索引结构只对语料做纯文本分析,而语言感知的索引结构需要理解编程语言的语法结构,前者适用于所有文本搜索引擎,后者则为代码搜索引擎特有。

本节我们首先回顾一下「倒排索引」的基础知识,随后依次讨论上图叶子节点中的索引结构。

 3.2.1 倒排索引

文本搜索的实现离不开一个经典的数据结构 — 倒排索引 (Inverted Index)。本节简单回顾倒排索引的基本结构以及它的一些基本变体。如果你对这个话题有兴趣深入了解,我推荐弗莱堡大学的 Information Retrieval 课程 (视频 | 资料)。

给定如下一组文档 (documents):

  1. He likes to wink, he likes to drink.

  2. He likes to drink, and drink, and drink.

  3. The thing he likes to drink is ink.

  4. He likes to wink and drink pink ink.

其中序号即为文档编号。最简单的倒排索引就是先对其分词,找出每个单词对应的文档列表:

{
"and": [2, 4],
"drink": [1, 2, 3, 4],
"he": [1, 2, 3, 4],
"ink": [3, 4],
"is": [3],
"likes": [1, 2, 3, 4],
"the": [3],
"thing": [3],
"to": [1, 2, 4],
"wink": [1, 4]
}

在相关书籍中,这个数据结构被称为 Posting Lists 或 Postings。为了算法实现上的高效,每个单词对应的文档序号列表通常按顺序排列。

有了上述结构,我们就能支持简单的「关键词搜索」(Keyword Search) 功能。假如用户想查询包含单词 “wink” 的文档,就找到单词 “wink” 对应的文档列表即可。上述结构也能支持「词组搜索」 (Phrase Search),比如 “likes to”,就可以分别找到 “likes” 和 “to” 对应的文档列表,取二者交集。但对于这些筛选出来的文档,我们还需要再进行一遍确认,因为 “likes” 和 “to” 出现的位置和顺序都无法保证符合要求,比如文档 “To my mind, I likes the way you achieve this”。

另一种支持的「词组搜索」 的方法是在 Postings 中加入每个词语出现的位置信息,如:

{
"drink": {
"1": [30],
"2": [13, 24, 35],
"3": [23],
"4": [22]
},
// ...
}

通常称这种 Postings 为 Positional Postings。这时支持「词组搜索」就是小菜一碟了,典型的空间换时间。

如果用户记不住完整的单词和词组,怎么办?这就需要支持一种新的搜索方式 —「子串搜索」(Substring Search),如搜索 “ink”,要给出 “drink”、”wink” 和 “ink” 的结果并集。显然,每次遍历 Postings 中的所有 key 效率不高,尤其是需要做多语言支持的时候,那么一个常用的技巧就是 q-gram (或 n-gram)。gram 就是若干连续字符的集合,而前面的 q 或 n 表示的是连续的字符个数,2-gram 就是字符的两两组合,3-gram (trigram) 就是三个字符的任意组合,依次类推。比如字符串 “freiburg” 包含的所有 3-gram 包括:

$$f, $fr, fre, rei, eib, ibu, bur, urg, rg$, g$$

而 q-gram index 就是把前面的 Postings 中的词语换成这些 q-gram。那么查询子串 “reibur” 就可以转化为查询rei AND eib AND ibu AND bur

以上是关于代码搜索引擎:基础篇的主要内容,如果未能解决你的问题,请参考以下文章

Vue 基础篇

三搜索引擎篇-lucene入门代码示例

[vscode]--HTML代码片段(基础版,reactvuejquery)

配置 VScode 编辑器 (前端篇)

T4模板:T4模板之菜鸟篇

Python代码阅读(第19篇):合并多个字典