Gopls:设计
来自未来的注记
以下是 gopls 的原始设计文档,汇集自 2018 年至 2019 年的各种来源。自那以后,下面列出的所有功能以及许多其他功能都已实现。前两个目标已实现:gopls 是 LSP 的完整实现,也是 VS Code Go 和许多其他编辑器默认的后端。第三个目标仅部分实现:虽然 gopls 已经获得了许多功能,但它并不像本文档中所用的那样具有可扩展性:扩展 gopls 的唯一方法是修改 gopls。第四个目标尚未实现:尽管一些知名公司能够使用 gopls 和 Bazel,但体验不尽人意,Go 命令是唯一官方支持的构建系统。
另一方面,两个明确的非目标已被重新考虑。其中一个很小:通过语义令牌,LSP 现在支持语法高亮。另一个很重要:随着 gopls 的普及,人们发现其内存占用是一个问题。开发工作区的规模增长速度超过了典型开发环境中(尤其是在容器化开发中)可用的 RAM。Gopls 现在使用磁盘索引和内存缓存的混合体,更详细的信息请参阅我们关于可扩展性的 博客文章。
值得注意的是,本文档在预测困难方面出奇地准确。Gopls 确实在它所构建的核心标准库包方面遇到了困难,其用户体验仍然受限于 LSP。尽管如此,坚持使用标准库和 LSP 是正确的做法,因为尽管我们团队规模很小,但这些决定帮助 gopls 跟上了不断发展的 Go 语言(例如泛型),并与许多新的文本编辑器集成。
四年多后,gopls 的开发仍在继续,重点是简洁性、可靠性和可扩展性。新的、选择加入的 Go 遥测 将帮助我们达到比仅通过 Github 问题所能达到的更高的版本稳定性标准。此外,遥测将使我们能够专注于高优先级功能,并弃用那些给代码库带来负担的历史变通方法。随着速度的提高,我们期待与社区合作,改进重构、静态分析以及未来可能出现的任何东西。
- Rob Findley (rfindley@google.com),2023 年
目标
gopls
应该成为 Go 程序员使用的主要编辑器后端,并由 Go 团队提供全面支持。gopls
将是 LSP 的完整实现,如 LSP 规范中所述,以尽可能标准化其大部分功能。gopls
将是简洁且可扩展的,以便将来能够包含更多功能,使 Go 工具再次成为同类最佳。gopls
将支持备用的构建系统和文件布局,从而在任何环境中使 Go 开发更简单、更强大。
Context
虽然 Go 有许多出色且有用的命令行工具可以增强开发人员的体验,但将这些工具与 IDE 集成可能具有挑战性,这一点已经很清楚。
对这些工具的支持一直依赖于社区成员的善意,并且随着语言、工具链和环境的变化,他们有时会承担巨大的支持负担。结果是许多工具停止工作,出现支持问题,或因分叉和替代品而变得混乱,或提供的体验不如应有的好。有关更多问题和详细信息,请参阅下面的 现有解决方案部分。
对于偶尔使用的工具来说,这没关系,但对于核心 IDE 功能来说,这是不可接受的。自动完成、跳转到定义、格式化和其他此类功能应始终有效,因为它们是 Go 开发的关键。
Go 团队将创建一个适用于任何构建系统的编辑器后端。它还将能够提高 Go 工具的延迟,因为每个工具将不再需要在每次调用时单独运行类型检查器,而是会有一个长期运行的进程,并且数据可以在定义、完成、诊断和其他功能之间共享。
通过承担这些工具的所有权并将它们打包成 gopls 的形式,Go 团队将确保 Go 开发人员的 Go 开发体验不会不必要地复杂化。拥有一个编辑器后端将简化 Go 开发人员、Go 团队以及 Go 编辑器插件维护者的生活。
有关更多背景信息,请参阅 Rebecca 在 GopherCon 上的精彩主题演讲 视频和 幻灯片。
非目标
-
命令行速度
虽然 gopls 将具有命令行模式,但它将针对长期运行进行优化,而不是命令响应速度,因此它可能不适合 CI 系统等用途。对于这种情况,将必须有一个使用相同底层库以确保一致性的备用工具。
-
低内存环境
为了能够以极低的延迟出色地处理大型项目,gopls 将在内存中保留大量信息。人们认为开发人员通常在具有大量 RAM 的系统上工作,这不会成为问题。总的来说,这得到了现有 IDE 解决方案(如 IntelliJ)的大内存使用量的支持。
-
语法高亮
目前没有编辑器将此功能委托给单独的二进制文件,也没有标准化的方法来执行此操作。
现有解决方案
每年,Go 团队都会进行一项调查,询问开发人员关于他们使用该语言的经验。
其中一个问题是“你对你的编辑器感觉如何?”
回复讲述了一个非常负面的故事。一些分类引述
- 设置
- “安装和配置困难”
- “文档不足”
- 性能
- “性能非常差”
- “在大项目中相当慢”
- 可靠性
- “功能有一天有效,第二天就失效了”
- “工具链没有随着新的语言功能而更新”
每个编辑器都有自己的插件,该插件会调用各种工具,其中许多工具会因新的 Go 版本而中断,或者因为它们不再维护。随着语言、工具链和环境的变化,它们有时会承担巨大的支持负担。
每个工具都必须自己完成理解代码及其所有传递性依赖的工作。
每个功能都是一个不同的工具,具有不同的命令行模式,接受输入和解析输出的不同方式,指定源代码位置的不同方式。为了支持其现有功能集,VSCode 安装了 24 个不同的命令行工具,其中许多工具具有用于配置的选项或分叉。在查看需要迁移到模块的工具集时,跨所有编辑器共有 63 个单独的工具。
所有这些工具都需要理解代码,并且它们使用相同的标准库来完成这项工作。这些库针对此类工具进行了优化,但即便如此,处理大量代码仍然需要大量时间。几乎没有工具能够在 100 毫秒内返回结果。当开发人员在编辑器中输入时,需要激活多个此类功能,这意味着他们不仅要支付一次费用,还要多次支付。整体效果是编辑体验感觉迟钝,功能要么未启用,要么有时产生的结果出现得如此之慢,以至于在到达时已不再有用。这个问题随着代码库的增大而增加,这意味着它会随着时间的推移而变得更糟,并且对于公司在更重要的任务中使用 Go 时处理的大型代码库尤其糟糕。
要求
完整的功能集
要使 gopls 获得成功,它必须实现 下文中讨论的全部功能集。这是用户在能够像使用它所取代的工具一样高效地工作所需的功能集。它不包括以前实现中的所有功能,有些功能几乎从未使用过,应该被删除(例如 guru 的指针分析),还有一些功能不容易契合,需要变通(替换保存钩子/linter)。
同等或更好的体验
对于所有这些功能,用户体验必须与所有编辑器中提供的当前体验相匹配或超出。这是一个容易提出的声明,但很难验证或衡量。许多可能的测量方法都未能捕捉到这种体验。
例如,如果尝试测量跳转到定义调用的延迟,与旧的 godef 工具相比,结果将相当一致。从 gopls 实现来看,延迟范围可能大得多,最好的可能快几个数量级,最差的则略差,因为 gopls 尝试做更多的工作,但设法在调用之间缓存它。
或者对于一个补全调用,它可能更慢,但能产生更好的第一个匹配项,从而使用户更频繁地接受它,从而获得更好的整体体验。
在大多数情况下,这必须依赖用户报告。如果用户因为体验没有更好而拒绝切换,那显然还没有完成;如果他们切换了,但大多数人都在抱怨,那可能有很多方面得到了改进,足以促使切换,但其他方面却有所欠缺。如果大多数人都在切换,并且保持沉默或表示积极,那可能就完成了。在编写工具时,用户就是一切。
稳定的贡献者社区
gopls 试图解决的问题的范围和规模对于核心 Go 团队来说是不可承受的,这需要一个强大的社区才能实现这一切。
这意味着代码必须易于贡献,并且许多开发人员可以轻松地并行工作。功能需要良好地解耦,并有完善的测试故事。
延迟在用户可容忍范围内
关于用户可接受操作延迟的研究已经有很多。
对 gopls 影响最大的结果是,直接响应连续用户操作的反馈需要在 100 毫秒以内才能被感知不到,而超过 200 毫秒会惹恼用户。这意味着总的来说,任何在开发人员输入时发生的事情的目标都必须是 <100 毫秒。总会有 gopls 无法满足此截止日期的情况,并且需要有方法在这些情况下使用户体验良好,但总的来说,此截止日期的目的是为基本架构设计提供信息,任何理论上无法长期满足此目标的解决方案都是错误的。
易于配置
开发人员非常挑剔,并且在他们的编码体验方面有着截然不同的愿望。gopls 将不得不支持相当大的灵活性,以满足这些愿望。然而,默认设置在没有任何配置的情况下必须是大多数用户体验最佳的设置,并且在可能的情况下,功能必须是灵活的,无需配置,以便客户端可以轻松地做出有关处理的决策,而无需更改与 gopls 的通信。
困难
数据量
- 小
- 中
- 大
- 企业单体仓库:大得多
解析和类型检查大量代码的成本非常高,并且转换后的形式占用大量空间。由于 gopls 在开发人员输入时必须不断更新这些信息,因此它需要非常小心地管理其转换形式的缓存,以平衡内存使用与速度。
缓存无效
类型检查的基本操作单位是包,但编辑器的基本操作单位是文件。gopls 需要能够有效地将文件映射到包,以便在文件更改时知道哪些包需要更新(以及任何其他以传递方式依赖它们的包)。事实使这变得特别困难,即更改文件的内容会修改它被视为一部分的包(通过更改包声明或构建标签),一个文件可以属于多个包,并且可以在不使用编辑器的情况下对文件进行更改,在这种情况下,它不会通知我们更改。
不合适的内置功能
Go 的基础库(如 go/token、go/ast 和 go/types)都是为类似编译器的应用程序设计的。它们更关注吞吐量而不是内存使用,它们具有旨在在程序退出时增长然后被丢弃的结构,并且它们不是为在源文件存在错误的情况下持续运行而设计的。它们也无法进行增量更改。
让长期运行的服务与这些库良好协作是一个巨大的挑战,但编写新库将花费更多工作,并带来重大的长期成本,因为两套库都必须维护。目前,将工作工具交到用户手中更为重要。从长远来看,这一决定可能需要重新考虑,新的底层库可能是推动能力向前发展的唯一途径。
构建系统功能
gopls 被认为是与构建系统无关的,但它必须使用构建系统来发现文件如何映射到包。当它尝试这样做时,即使功能相同,成本(时间、CPU 和内存)也大不相同,并可能严重影响用户体验。设计 gopls 如何与构建系统交互以尝试最小化或隐藏这些差异是很困难的。
构建标签
Go 的构建标签系统非常强大,并且有许多用例。源文件可以使用关于活动标签集的强大布尔逻辑来排除自身。然而,它旨在指定命令行上的活动标签集,并且所有库都旨在一次处理一个有效的组合。也没有办法找出有效组合的集合。
类型检查一个文件需要了解同一包中的所有其他文件,并且该文件集会受到构建标签的影响。包的导出标识符集也受到包中文件数量的影响,因此也受其构建标签的影响。
这意味着即使对于没有构建标签控制的文件或包,也无法在不知道要考虑的构建标签集的情况下产生正确的结果。这使得在查看文件时很难产生有用的结果。
LSP 不支持的功能
有些事情可以做得很好,但与现有 LSP 协议不太契合。例如,显示控制流信息、自动结构标签、复杂重构……
每个功能都必须仔细考虑,并提出对 LSP 的更改,或添加一种方式来拥有 gopls 特定的协议扩展,这些扩展仍然易于在所有编辑器插件中使用。
为了避免这些,一开始只实现核心 LSP 功能,因为它们足以满足基本要求,但潜在的功能需要在核心架构中牢记。
分发
确保用户使用正确版本的 gopls 将是一个问题。每个编辑器插件可能会以自己的方式安装工具,有些会选择全局安装,有些会保留自己的副本。
由于它是一个全新的工具,它将快速变化。如果用户没有意识到他们使用的是旧版本,他们将遇到已经修复的问题,这对他们来说更糟,然后可能会报告这些问题,这会浪费 gopls 团队的时间。需要有一个机制来检查 gopls 是否是最新版本,以及一种推荐的安装最新版本的方法。
调试用户问题
gopls 本质上是开发人员机器上一个非常状态化且长期运行的服务器。它的基本操作受到许多因素的影响,从用户的环境到本地构建缓存的内容。它正在处理的数据通常是保密的 कोड库,无法共享。所有这些因素都使得用户难以有效地报告错误或创建最小的重现。
需要有简便的方法供用户报告他们可以提供的信息,并有方法在不获取其全部状态的情况下重现问题。这对于生成回归测试也是必需的。
基本设计决策
有一些根本性的架构决策会影响该工具的其余大部分设计,做出影响用户体验的基本权衡。
进程生命周期:由编辑器管理
处理大型代码库以进行完整类型检查和分析(在延迟要求内)是不可行的,也是现有解决方案的主要问题之一。即使计算出的信息被缓存到磁盘,这仍然成立,因为运行分析器和类型检查器最终需要依赖图中所有文件的完整 AST。理论上可以做得更好,但只能通过重写现有的解析和类型检查库来完成,这在目前是不可行的。
这意味着 gopls 应该是一个长期运行的进程,它能够缓存和预先计算内存中的结果,以便在收到请求时能够更快地提供答案。
它可以作为用户机器上的守护进程运行,但管理守护进程有很多问题。从长远来看,这可能是正确的选择,并且应该在基本架构设计中允许它,但一开始它将有一个进程,该进程的持续时间与启动它的编辑器一样长,并且可以轻松重启。
缓存:内存中
持久的磁盘缓存维护成本很高,并且需要解决许多额外的问题。尽管构建所需信息与请求所需的延迟相比成本很高,但与编辑器的启动时间相比,它相对较小,因此预计在 gopls 重启时重建信息是可以接受的。
这样做的好处是,gopls 在重启之间是无状态的,这意味着如果它出现问题或状态混乱,简单的重启通常可以解决问题。它还意味着当用户报告问题时,不需要整个磁盘缓存状态来诊断和重现问题。
通信:stdin/stdout JSON
LSP 规范定义了通常使用的 JSON 消息,但它没有定义这些消息应如何发送,并且存在不使用 JSON 的 LSP 实现(例如,Protocol buffers 是一个选项)。
gopls 的限制是它必须易于集成到所有编辑器所有操作系统上,并且不应有大的外部依赖。
JSON 是 Go 标准库的一部分,也是 LSP 的原生语言,因此最符合逻辑。到目前为止,最受支持的通信机制是进程的标准输入和输出,并且通用客户端实现都有使用 JSON rpc 2 的方法。Go 中没有此协议的完整且依赖项很少的实现,但它是一个相当小的协议,基于 JSON 库,可以通过中等努力来实现,并且无论如何都将是一个普遍有用的库。
将来预计它将以分离的客户端服务器模式运行,因此从一开始就以可以使用套接字而不是 stdin/stdout 的方式编写它,是确保它仍然可行的方法。能够手动运行 gopls 服务器并在编辑器外部进行监视/调试也是一个巨大的调试辅助。
运行其他工具:否
功能
gopls 需要公开一组功能才能成为全面的 IDE 解决方案。以下是功能集,以及它们现有的解决方案以及应如何映射到 LSP。
内省
内省功能在开发人员工作时提供有关其代码的信息。它们不进行或建议更改。
Diagnostics | 代码的静态分析结果,包括编译和 lint 错误 |
---|---|
需要 | 完整的 go/analysis 运行,需要完整的 AST、类型和 SSA 信息 |
LSP | textDocument/publishDiagnostics |
以前 | go build 、go vet 、golint 、errcheck、staticcheck |
这是最重要的 IDE 功能之一,它允许快速迭代,而无需在 shell 中运行编译器和检查器。通常用于为 IDE 中的问题列表、缩进标记和波浪线下划线提供支持。 在让用户自定义要运行的检查集方面,有一些复杂的设计工作要做,最好是无需重新编译主 LSP 二进制文件。 |
悬停 (Hover) | 有关光标下代码的信息。 |
---|---|
需要 | 文件和所有依赖项的 AST 和类型信息 |
LSP | textDocument/hover |
以前 | godoc、gogetdoc |
在阅读代码时用于显示编译器已知但代码中并不总是明显的信息。例如,它可以返回标识符的类型或文档。 |
签名帮助 | 函数参数信息和文档 |
---|---|
需要 | 文件和所有依赖项的 AST 和类型信息 |
LSP | textDocument/signatureHelp |
以前 | gogetdoc |
在代码中键入函数调用时,了解该调用的参数以使开发人员能够正确调用它很有帮助。 |
导航
导航功能旨在让开发人员更容易地在代码库中找到方向。
定义 | 选择一个标识符,然后跳转到该标识符被定义的代码。 |
---|---|
需要 | 文件和所有依赖项的完整类型信息 |
LSP | textDocument/declaration |
textDocument/definition |
|
textDocument/typeDefinition |
|
以前 | godef |
要求编辑器打开符号定义位置是 IDE 中最常用的代码导航工具之一。在探索不熟悉的代码库时尤其有用。 由于编译器输出的限制,无法为此任务使用二进制数据(特别是它不知道列信息),因此必须从源文件解析。 |
实施 | 报告实现接口的类型 |
---|---|
需要 | 完整的代码库类型知识 |
LSP | textDocument/implementation |
以前 | impl |
此功能很难扩展到大型代码库,需要仔细考虑才能正确实现。可能暂时可以实现一个更有限的形式。 |
文档符号 | 提供当前文件中顶级符号的集合。 |
---|---|
需要 | 仅当前文件的 AST |
LSP | textDocument/documentSymbol |
以前 | go-outline、go-symbols |
用于驱动大纲模式等功能。 |
参考 | 查找光标下符号的所有引用。 |
---|---|
需要 | AST 和类型信息(反向传递闭包) |
LSP | textDocument/references |
以前 | guru |
这需要了解可能依赖于当前文件所在包的每个包。过去,这要么通过全局知识实现(这无法扩展),要么通过指定一个“范围”来混淆用户,以至于他们根本不使用这些工具。gopls 长期来看可能需要一个更强大的解决方案,但起初自动限制范围可能会产生可接受的结果。这可能是模块(如果已知),否则是某个合理的父目录。 |
折叠 | 报告块的逻辑层次结构 |
---|---|
需要 | 仅当前文件的 AST |
LSP | textDocument/foldingRange |
以前 | go-outline |
这通常用于在编辑器中提供展开和折叠行为。 |
选择 | 报告光标周围的逻辑选择区域 |
---|---|
需要 | 仅当前文件的 AST |
LSP | textDocument/selectionRange |
以前 | guru |
在编辑器功能(如展开选择)中使用。 |
编辑助手
这些功能为用户建议或应用代码编辑,包括重构功能,有许多潜在用例。重构是 Go 工具可能非常强大的领域之一,但迄今为止尚未实现,因此在开发人员体验方面有巨大的改进潜力。然而,目前还没有清楚地了解人们需要的重构类型以及它们应该如何表达,并且 LSP 协议在这方面存在弱点。这意味着它可能更像是一个研究项目。
格式化 | 修复文件的格式 |
---|---|
需要 | 当前文件的 AST |
LSP | textDocument/formatting |
textDocument/rangeFormatting |
|
textDocument/onTypeFormatting |
|
以前 | gofmt、goimports、goreturns |
它将使用标准的格式包。 目前的限制是它不能处理格式错误的 कोड。可能需要对格式化程序进行一些非常仔细的更改,以允许格式化无效的 AST 或更改以强制 AST 进入有效模式。这些更改也将改进范围和文件模式,但对于 onTypeFormatting 来说基本上是必不可少的。 |
导入 | 自动重写导入块以匹配使用的符号。 |
---|---|
需要 | 当前文件的 AST 以及所有候选包的完整符号知识。 |
LSP | textDocument/codeAction |
以前 | goimports、goreturns |
这需要了解尚未使用的包,以及按名称查找这些包的能力。 它还需要发现的所有包的导出符号信息。 它应该使用标准的导入包来实现,但对于某些交互,可能需要暴露比仅重写文件更细粒度的 API。 |
自动完成 | 提出建议以完成当前正在键入的实体。 |
---|---|
需要 | 文件和所有依赖项的 AST 和类型信息 还需要所有包的完整导出符号知识。 |
LSP | textDocument/completion |
completionItem/resolve |
|
以前 | gocode |
自动完成是最复杂的功能之一,它知道得越多,建议就越好。例如,如果它知道尚未导入的包的公共符号,它可以自动完成。如果它知道你正在编写的程序类型,它可以提出更好的选项建议。如果它知道你通常如何调用一个函数,它可以建议更好的参数。如果它知道它们是常见的代码模式,它可以建议整个代码模式。与许多有特定任务的功能不同,完成自动补全永远不会完成。在候选者及其排名之间取得平衡和改进将是一个长期的研究问题。 |
重命名 | 重命名一个标识符 |
---|---|
需要 | AST 和类型信息(反向传递闭包) |
LSP | textDocument/rename |
textDocument/prepareRename |
|
以前 | golang.org/x/tools/cmd/gorename |
这使用了与查找引用相同的信息,具有相同的问题和限制。它稍微糟糕一些,因为它建议的更改使其对不正确的结果不容忍。使用它来更改包的公共 API 也很危险。 |
建议的修复 | 可以手动或自动接受以更改代码的建议 |
---|---|
需要 | 完整的 go/analysis 运行,需要完整的 AST、类型和 SSA 信息 |
LSP | textDocument/codeAction |
以前 | 不适用 |
这是一个由新的 go/analysis 引擎驱动的全新功能,它应该能够实现大量的自动化重构。 |
本文档的源代码可以在 golang.org/x/tools/gopls/doc 下找到。