- 开发工具
- 静态分析
- 代码质量
- IDE
- 代码生成
【免费下载链接】tools
[mirror] Go Tools
导读
本文以 gopls/internal/protocol/generate/README.md 为主线,深入剖析 gopls(Go 语言服务器)如何自动生成 LSP(Language Server Protocol)协议的 Go 类型声明、客户端与服务端接口、JSON 编解码代码。你将掌握该生成器的整体架构、运行方式、LSP 规范中 8 种 TypeScript 类型到 Go 类型的映射策略、以及生成器为兼容既有 gopls 代码而设计的三大调整机制与检查体系,从而理解 gopls 与 LSP 版本同步的底层原理。
一、背景:gopls 与 LSP 协议的消息模型
LSP(Language Server Protocol)在客户端与语言服务器之间通过 JSON 编码的消息交换数据,gopls 是其中作为语言服务器(server)的一端。消息分为两类:
- Request(请求):必须产生 Response(响应);
- Notification(通知):不产生任何响应。
每条 Request 或 Notification 都带有一个方法名(method),例如"textDocument/hover",它既标明消息的语义,也决定了服务器中由哪个函数来处理该消息。协议规范以文字形式描述在一个 Web 页面上,同时提供一份机器可读的metaModel.json文件,本代码采用后者,从而可以把协议的精确版本绑定到一个 git 提交哈希(githash)上。
LSP 规范(specification)整体上分为五个部分:
- Requests:描述请求方法的 Request/Response 类型(如
textDocument/didChange); - Notifications:描述通知方法的 Request 类型;
- Structures:描述命名结构体类型的集合;
- TypeAliases:描述类型别名;
- Enumerations:描述命名常量。
Requests 与 Notifications 都带有 Method 标签(如"textDocument/hover"),但规范并不指定处理消息的函数名,这些名字由生成器内部的methodNames映射表指定。此外,规范中的枚举在 TypeScript 中作用域限定在命名空间(namespace)内,而在 Go 中作用域是包级别的,因此生成的 Go 常量名可能需要修改以避免命名冲突——这正是disambiguate映射表及其用途所在。最后,规范中定义的类型是 TypeScript 类型,与 Go 类型差异巨大,这是整个生成器存在的前提。
二、运行方式:一条 go generate 命令生成全部协议代码
生成器位于 gopls/internal/protocol/generate,由 doc.go 中的//go:generate go run ./generate指令驱动,因此只需在 protocol 目录执行:
go generate ./...生成器默认会下载github.com/microsoft/vscode-languageserver-node仓库到临时目录(通过git fetch --depth=1浅克隆),并 checkout 固定的 git ref。当前版本在 main.go 中定义:
const vscodeRepo = "https://github.com/microsoft/vscode-languageserver-node" // lspGitRef names a commit in vscodeRepo. // It implicitly determines the protocol version of the LSP used by gopls. var lspGitRef = "release/protocol/3.18.2"lspGitRef隐式决定了 gopls 使用的 LSP 协议版本。从生成的 tsclient.go 文件头可以确认当前生成产物对应的精确版本:
// Code generated from protocol/metaModel.json at ref release/protocol/3.18.2 (hash 20969f3e75cb3cd35c2bb794b1d15cc2dfbe4abb). // LSP metaData.version = 3.18.0.生成器支持三个命令行参数(见 main.go):
| 参数 | 含义 | 默认值 |
|---|---|---|
-d | 指定本地已克隆的 vscode-languageserver-node 仓库目录(调试用),为空则自动下载 | 空(自动克隆到临时目录) |
-o | 输出目录 | .(当前目录) |
-l | 在生成的代码中附加 JSON 源文件行号注释 | false |
当指定本地目录-d时,lspGitRef会被改写为(not git, local dir ...)以便在文件头中区分来源。
生成过程的核心流程在processinline()(main.go)中:解析protocol/metaModel.json→ 注入两个 gopls 自定义扩展方法(command/resolve与interactive/listEnum)→findTypeNames为未命名类型分配名称 →generateOutput生成各输出片段 → 写出四个文件 →checkTables校验调整表是否全部被使用。
四个输出文件
生成器产出四个文件(README 称为 "four output files"),位于 gopls/internal/protocol 下:
| 文件 | 内容 | 当前规模 |
|---|---|---|
| tsclient.go | protocol.Client接口的定义与实现,以及按 Method 分发的 dispatch 代码 | 约 323 行 |
| tsserver.go | protocol.Server接口的定义与实现,以及按 Method 分发的 dispatch 代码 | 约 1561 行 |
| tsjson.go | "or" 联合类型的自定义 MarshalJSON/UnmarshalJSON 编解码代码 | 约 2228 行 |
| tsprotocol.go | 类型与常量定义 | 约 7025 行 |
Client接口中的每个方法对应一条 client 方向的消息,例如 tsclient.go 中可见LogTrace(context.Context, *LogTraceParams) error、PublishDiagnostics(context.Context, *PublishDiagnosticsParams) error等声明,每个方法前都附有指向 LSP 规范对应片段的链接注释。
每个文件头都通过fileHeader(main.go)生成,其中会从克隆仓库的.git/HEAD读取 40 位 githash,保证"生成产物 ↔ 规范版本 ↔ 仓库提交"三者可追溯。代码统一经过go/format格式化后写入*outputdir目录。
三、类型映射:TypeScript 类型到 Go 类型的八种转换策略
规范使用的类型远多于 Go 原生类型,README 将其归纳为 "base"、"reference"、"map"、"literal"、"stringLiteral"、"tuple"、"and"、"or" 八类,映射逻辑的核心实现在 typenames.go 的nameType函数中。下面逐一说明。
1. base 类型
"base" 类型与 Go 类型直接对应,但部分需要人工选择映射目标,例如URI与DocumentUri。README 特别指出RegExp、BooleanLiteral、NumericLiteral这三种 base 类型在规范中从不出现。实际映射由 tables.go 的goplsType表控制,例如:
"boolean": "bool", "integer": "int32", "uinteger": "uint32", "decimal": "float64", "DocumentUri": "DocumentURI",2. reference 类型
"reference" 类型即规范 Structures 部分定义的结构体类型,其名称大多可直接用于 Go,但以下划线开头的名称(如_Initialize)需要改写成XInitialize才能被导出,供 JSON 序列化/反序列化使用。该逻辑见 generate.go:
// Names beginning _ are not exported if strings.HasPrefix(s, "_") { s = strings.Replace(s, "_", "X", 1) }3. map 类型
"map" 类型与 Go 的 map 直接对应,且所有 map 的键类型都是DocumentUri(如map[DocumentURI][]TextEdit)。
4. stringLiteral 类型
"stringLiteral" 是"类型名与取值都是单个字符串"的类型,生成器的 Go 等价方案是把类型设为string、取值设为一个常量(另一方案是生成一个新的命名类型,但显得冗余)。在nameType中,stringLiteral直接映射为string(typenames.go)。
5. literal 类型
"literal" 类型等价于 Go 的匿名结构体,因此必须为其命名。命名有两种思路:从类型组成部分构造名字,容易产生误导性的双关(punning)且在添加组件时不稳定;从定义上下文(即其所在类型的路径)构造名字更稳健。例如Lit__InitializeParams_clientInfo就是位于_InitializeParams结构体clientInfo字段处的 literal 类型。nameFromPath(typenames.go)将路径组件用_拼接,并把方法名中的/替换为_:
func nameFromPath(prefix string, path []string) string { nm := prefix + "_" + strings.Join(path, "_") nm = strings.ReplaceAll(nm, "/", "_") return nm }该方案对组件顺序敏感,但代码假定"重排组件"是协议中不可能发生的变更。
6. tuple 类型
"tuple" 类型生成为 Go 结构体,整个规范中仅有一个实例,即两个uint32字段的元组。生成代码见 output.go,名为UIntCommaUInt,字段为Fld0 uint32与Fld1 uint32。
7. and 类型
"and" 类型生成为 Go 结构体并内嵌各组件类型,唯一实例是And_Param_workspace_configuration。生成逻辑见 output.go。
8. or 类型:最复杂的联合类型
"or" 类型数量众多且没有简单的 Go 等价物,是生成器处理的重头戏。其方案是:生成一个只含单个Value interface{}字段的结构体,并配套自定义 JSON 编解码代码(写入 tsjson.go)。用户可以给Value赋任意值,但自定义 marshal 代码会校验类型并正确序列化;unmarshal 代码同样校验类型,因此Value反序列化后必定是允许的类型之一(nil始终被允许)。规范中约有 40 个"or"类型只有单一非空组件,这些会被直接转换为该组件类型。相关逻辑在 output.go 的genMarshal中生成,例如 tsjson.go 中的模式:
func (t OrPLocation_workspace_symbol) MarshalJSON() ([]byte, error) { switch x := t.Value.(type) { case Location: return json.Marshal(x) ... } } func (t *OrPLocation_workspace_symbol) UnmarshalJSON(x []byte) error { if string(x) == "null" { t.Value = nil return nil } // 依次尝试每个允许的组件类型,全部失败则返回 &UnmarshalError{...} }tsjson.go 中定义了UnmarshalError类型,用于表示"JSON 值不符合 LSP 联合类型任何预期分支"的错误。
可选性(Optionality):*与,omitempty
规范可以标记结构体字段为 Optional,客户端在某些场景下会区分"字段缺失"与"字段为 null"。Go 侧对可选类型的翻译是:确保字段值可以为nil,并加上json:",omitempty"标签。前者通过给非引用类型追加*实现。propStar函数(generate.go)负责计算某个字段是否需要*与,omitempty,其默认规则包括:数值类型uint32/int32保持非指针(0 与缺省在部分语义下有区别)、切片与 map 本身是引用无需指针、bool/string/any为兼容 gopls 不追加指针等。
四、处理流程与命名算法
生成器解析 JSON 规范文件后扫描全部类型(代码主体在 typenames.go)。findTypeNames(typenames.go)遍历规范的五大部分,为每个未命名类型分配名字:
- 结构体:为
Extends、Mixins与每个Properties的类型命名; - 枚举与类型别名:为各自的底层类型命名;
- 请求:为
Params(前缀Param)、Result(前缀Result)、RegistrationOptions(前缀RegOpt)命名; - 通知:为
Params、RegistrationOptions命名。
nameType递归地为每个Type计算名字并缓存到typeNamesmap,同时把需要生成定义的命名类型(and/literal/tuple/or)记录到genTypes列表,供后续输出阶段使用。
值得一提的是nameType对 "or" 类型的一个特殊处理(typenames.go):如果 "or" 的组件中有一个是null,则直接用另一个组件的类型替代,不再生成 "or" 类型——这从生成代码中消去了约 40 个类型(如_InitializeParams.trace是 3 个 stringLiteral 的 "or",直接变成string)。
此外,types.go 的addLineNumbers会在解析前向 JSON 的每个{后注入"line": N字段,从而让规范中的每个类型/字段都能追溯到 metaModel.json 的原始行号(生成时通过-l开关可把行号写进注释)。
五、兼容 gopls 的三大调整机制
生成器在输出时(主要在 output.go 与 main.go)会对生成的代码做调整,使现有 gopls Go 代码无需改动即可编译。README 将调整分为三大类,全部由 tables.go 中的映射表驱动。这种"先按规范生成、再按表调整"的组织方式使代码结构更简单,但代价是产生大量未使用的类型。
调整一:类型名替换——goplsType表
第一类调整是把生成的类型名改成 gopls 期望的名字。部分替换不改变类型语义、只改名字;但由于历史原因,大量条目是把 "or" 类型替换成其单个组件(直到最近 Go 侧只看到/只用其中的一个组件)。goplsType表位于 tables.go,例如:
"Or_Declaration": "[]Location", "Or_ProgressToken": "any", "Or_Result_textDocument_definition": "[]Location", "Or_TextDocumentContentChangeEvent": "TextDocumentContentChangePartial", "[]uinteger": "[]uint32",goplsName(output.go)在拿到规范名后查goplsType表完成翻译,并标记usedGoplsType。
调整二:结构体字段类型替换——renameProp表
第二类调整针对结构体字段的类型,由renameProp表(tables.go)控制。它把规范给出的字段类型替换为更贴合 gopls 的类型,例如:
{"Command", "arguments"}: "[]json.RawMessage", {"Diagnostic", "data"}: "json.RawMessage", // delay unmarshalling quickfixes {"Hover", "contents"}: "MarkupContent", {"FileCreate", "uri"}: "DocumentURI", {"WorkspaceEdit", "documentChanges"}: "[]DocumentChange",其中大量字段被改为json.RawMessage或any,目的是延迟反序列化(如命令参数、诊断附带数据),或让字段具备更宽松的语义(如ServerCapabilities中多个 provider 字段改为any)。
调整三:可选性覆盖——goplsStar表
第三类调整处理可选性:当默认的*与,omitempty规则与 gopls 预期不符时,用goplsStar表(tables.go)覆盖。表的取值有三档:
const ( nothing = iota // 不加 *、不加 omitempty wantOpt // 只加 omitempty wantOptStar // omitempty + 指针 )例如PublishDiagnosticsParams.version使用wantOpt(0 值代表"缺失",见注释中引用的 issue #73501)、FoldingRange的四个坐标字段使用wantOptStar("unset != zero")、DidSaveTextDocumentParams.text使用wantOptStar。表中每个条目都附有注释,说明去掉该条目的不平凡原因(例如A.B.C.D形式的表达式意味着B或C若变为指针就需要额外的 nil 检查或测试改动)。之所以如此谨慎,是因为形如A.B.C.S的中间组件如果是可选的,代码将需要大量无用的 nil 检查,而 TypeScript 有语言构造可以规避这类检查。
其他调整与特殊案例
除了三大调整外还有若干特殊案例:
- 避免递归类型:
LSPArray本是[]LSPAny,而LSPAny是包含LSPArray的 "or" 类型,解决方案是把LSPAny变成interface{}(通过goplsType表"LSPAny": "any"与输出中的type LSPAny = any共同实现); _InitializeParams.trace:类型是 3 个 stringLiteral 的 "or",直接简化为string;workspace/configuration特殊处理(output.go):其参数类型原为And_Param_workspace_configuration,但ParamConfiguration内嵌于该类型中,直接替换会产生循环定义,因此对参数显式使用*ParamConfiguration;- 枚举常量消歧:
disambiguate表(tables.go)为各枚举值添加前缀/后缀以避免包级常量冲突,例如DiagnosticSeverity加前缀Severity、WatchKind加前缀Watch、SymbolTag加后缀Symbol; - 处理函数命名:
methodNames表(tables.go)把约 130 个 LSP 方法名映射为 Go 处理函数名,如"textDocument/hover" → Hover、"initialize" → Initialize,未知方法会导致log.Fatalf; - gopls 非标准扩展:生成器在解析后向 model 注入
command/resolve(交互式重构的客户端到服务器请求,参数与结果均为ExecuteCommandParams)与interactive/listEnum(动态枚举列表)两个方法,见 main.go,并在 tsprotocol.go 中为ExecuteCommandParams追加内嵌的InteractiveParams字段(tables.go)。
六、输出代码的生成细节
generateOutput(output.go)对每个 Request/Notification 依次调用genDecl(接口声明)、genCase(dispatch 的 switch case)、genFunc(Dispatcher 的方法实现),随后生成结构体、别名、未命名类型、常量与 JSON 编解码代码。
Client/Server 接口与 Dispatch
tsclient.go 与 tsserver.go 各包含三节:接口声明、dispatch case、Dispatcher 方法。以 tsclient.go 为例(main.go),生成的Client接口定义所有 client 方向的方法,随后是clientDispatch与ClientDispatchCall:后者是一个switch method,按 Method 反序列化参数并调用接口方法,未识别的 Method 返回valid=false,由 jsonrpc2 层判定为无需回复。Dispatcher 方法(genFunc)则通过sender.Call/sender.Notify发送请求或通知,实现s.sender.Call(ctx, "textDocument/hover", params, &result)这样的调用模式。
参数一致性校验
genCase中有一处值得关注的运行时一致性校验(output.go):当方法参数extends(继承自)TextDocumentPositionParams时,若客户端只提供Position而未提供Range,则自动合成一个位于该 Position 的零宽Range;若提供了Range但Position不在其中,则直接报错返回。对应地,tables.go 为TextDocumentPositionParams.position追加了"Deprecated: gopls should use Range instead"的注释,并在 output.go 中为其追加了非标准的Range可选字段。
结构体生成策略
genStructs(output.go)对每个 Structure 生成 Go struct:一般结构体把Extends的父类型作为内嵌字段(SymbolInformation是唯一例外,直接展开父类型属性),Mixins也以内嵌方式加入;每个属性通过goplsName翻译类型、通过propStar计算指针与 omitempty、生成json:"..."标签,并附带来自规范文档的 doc 注释。枚举由genConsts生成type声明与常量块,如type DiagnosticSeverity int32与形如SeverityError DiagnosticSeverity = 1的常量。
七、检查与测试:保证生成的正确性
生成器内置多层自检,确保规范解析与代码生成没有遗漏与冗余。
TestAll(main_test.go):以-l模式运行整个生成流程,用于获取代码覆盖率;TestParseContents(main_test.go):把 metaModel.json 解析结果重新序列化后与原始 JSON 做字段集合比对,若规范引入新字段而Model结构体未覆盖,测试将失败;checkTables(main.go):运行结束时检查disambiguate、renameProp、goplsStar、goplsType四张表中的每个条目是否都被实际使用(对应usedXxxmap),未被使用的条目会打日志;同时renameProp与goplsStar的条目若与默认规则等价(redundant)也会被记录;- 首版一次性 diff 检查:代码首次发布时,将新旧生成的 tsclient.go 与 tsserver.go 做 diff,结果仅剩空白与注释差异;tsprotocol.go 的差异除空白与注释外,还有大量旧代码(更启发式)未生成的新类型定义(未使用的
_InitializeParams有轻微差异且不值得修复)。
上述测试默认需要HOME下存在vscode-languageserver-node克隆仓库(README 与测试注释均说明了这一点),实际 CI 中则依赖生成器默认的自动克隆逻辑。
八、历史演进与未来方向
历史:从手写 stub 到 Go 生成器
最初 gopls 的协议 stub 代码是手写的,但协议处于活跃演进期,手写难以为继。Web 页面早于 JSON 规范存在,却滞后于实现且难以被机器处理。于是早期的生成代码用 TypeScript 编写,借助 TypeScript 编译器 API 解析仓库中的协议代码,再用一套启发式规则提取协议元素、用另一套重叠的启发式规则生成 Go 代码。输出虽然可用,但风格独特(idiosyncratic),代码本身脆弱、几乎不可维护。如今这套 Go 生成器以metaModel.json为唯一权威输入,从根本上解决了可维护性问题。
未来:减少调整、走向独立模块
README 对未来的展望可以归纳为三点:
- 减少表驱动调整:
goplsType等表中的大部分条目可以通过修改 gopls 自身的命名(主要是名字层面)来消除; - 更多使用 "or" 类型:gopls 使用更多 "or" 类型需要更复杂但模式化的改动;
- 模块化与健壮性:即便消除了全部调整,让生成器成为独立模块仍面临依赖拆分的难题。此外当前自定义 unmarshal 代码"知道"它期望哪些类型(未匹配即返回
UnmarshalError),而更贴合 JSON "忽略未知值"哲学的设计是遇到未知类型时返回any,但那样 Go 侧就需要额外的类型检查。
结语
gopls 的协议生成器是一个"以单一权威 JSON 规范为输入、以确定性规则产出全部协议代码"的经典实践:它用typenames.go解决命名、用tables.go的映射表桥接"规范视角"与"gopls 既有代码视角"、用output.go生成接口/dispatch/编解码代码、再用测试与自检机制兜底。理解这套生成逻辑,不仅有助于深入 gopls 的协议层实现,也为其他"以机器可读规范驱动语言绑定代码生成"的项目提供了可借鉴的架构范本。若要进一步探索,可依次阅读 README.md、main.go、typenames.go、tables.go、output.go,并以 tsprotocol.go 中的生成产物对照验证。
- 开发工具
- 静态分析
- 代码质量
- IDE
- 代码生成
【免费下载链接】tools
[mirror] Go Tools
相关推荐
Stetho 代码生成器 scraper.js:从 Chrome DevTools 协议(protocol.json)生成 Java 类型与命令桩代码
Stetho 代码生成器 scraper.js:从 Chrome DevTools 协议(protocol.json)生成 Java 类型与命令桩代码 导读 b
开发工具移动开发Bunster代码生成器:从AST到Go代码的转换
Bunster代码生成器:从AST到Go代码的转换 Bunster作为一款将Shell脚本编译为静态二进制文件的工具,其核心能力在于代码生成器将抽象语法树(AS
ESP8266 USB硬件设计指南:Wemos D1 Mini接线图与电路分析
ESP8266 USB硬件设计指南:Wemos D1 Mini接线图与电路分析 ESP8266 USB设备是一个基于Software only实现的开源项目,让
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考