💡第六篇:VSCode语言服务中,LSP到底是什么东西?

68 阅读8分钟

这是vscode架构的第六篇,基于mini-vscode

前言

上一篇文章我们聊了 VSCode 的插件系统:插件是怎么被加载、怎么注册命令、怎么和主线程通信的。插件系统解决了“能力怎么扩展”的问题,而语言服务就是插件系统里最重要的一类能力。

当我们写代码时,能看到语法高亮、错误提示、代码补全,甚至可以跳转到定义,这些能力并不是凭空出现的。它们背后有 Monaco、Provider、LSP、Language Server 等一整套机制。

这一篇就顺着 mini-vscode 的实现,来看看 VSCode 里的语言服务到底是怎么工作的,以及 LSP 在其中到底扮演了什么角色

代码跳转和代码诊断是什么意思?

在阅读代码的时候,我们需要查看某个对象的具体定义,就会按住 ctrl + 鼠标左键,然后编辑器就会跳转到定义的位置,这个位置可能是当前的文件,也可能是另一个文件。这就是代码的跳转

在打开文件的时候,vscode 会对代码做语法诊断、类型检查等等,如果不对,就会在错误的地方,画上红色波浪线,或者在 output 中输出对应的错误。这就是代码诊断。

这些语言能力是编辑器内置的,还是插件提供的?

语言能力分成编辑器基础能力和worker / LSP 提供的更强能力。而编辑器基础能力分成两层,一层是通用的底层能力,一层是内置的能提供较强语言能力的 worker

在通用的底层语言能力,是编辑器本身就会支持的,这个不需要插件额外提供,比如:

  • 语法高亮
  • 括号匹配
  • 代码折叠
  • 注释快捷键
  • 多光标
  • 基础的的 tokenization

这些能力更多的是来自编辑器,所以在渲染 monaco 的时候,需要设置 language,然后就能看见 monaco 出了渲染不同语言,同时提供基础的编辑体验。

第二层 Monaco 自身内置了一些 worker,这些 worker 能够提供一些更强的语言能力。比如 jsonWorker、cssWorker、htmlWorker、tsWorker、editorWorker

tsWorker 提供了补全、Hover、跳转定义、引用、诊断、格式化等能力;jsonWorker 提供了语法诊断、schema 补全、格式化等能力、htmlWorker 提供了标签不全、Hover、格式化等能力

你可能会问了,为什么 Monaco 要自己做这么多?不是交给插件就好了吗?

我是这么理解的,Monaco 不仅仅会在 VSCode 场景下使用,还会在其他的 Microsoft 的云产品中使用,甚至是 IE 的开发者工具中,这些场景是没有什么 lsp 的,vscode 开发团队希望在这些场景中,可以提供一些基本语言能力。

而且 Monaco 被独立出来成为了一个 npm 三方库,那方便开发者可以开箱即用,所以就提供这些现成的 worker,用以提供基本的编程体验。

在完整工作区场景下,LSP / tsserver 这类独立语言服务通常更强,因为它可以长期维护项目状态,读取 tsconfig、node_modules、跨文件依赖。

Monaco 提供了编辑器基础能力,以及 json/css/html/ts 等 worker;但 VS Code 级别的项目语言能力通常不会直接依赖 Monaco worker。

在真正的 VS Code 里,TS/JS 语言能力主要来自内置 TypeScript 扩展启动的 tsserver,而不是 Monaco tsWorker;也不是严格意义上的 LSP。

为什么 VS Code 要引入 LSP,而不是每种语言写一套专用逻辑?

VSCode 要成为通用的语言编辑器,为所有语言编写兼容代码是不现实的,唯一可行的办法是公开一个标准协议(LSP),让第三方各自的 language server 对接到 LSP 上就可以,这样 vscode 不关心每种语言的内部的语法,语义等信息,就可以实现代码跳转以及代码诊断

LSP 里的 client 和 server 分别是谁?

在 mini-vscode 中,专门写一个插件通过 ts-lsp,vscode 与该插件的通信链条是这样的:

vscode monaco <--RPC Server--> ts-lsp <--LSP JSON-RPC--> ts-language-server

其中 ts-lsp 插件,就是 LSPclientts-language-server就是 LSPservermonaco 与 插件通信是 RPC(上篇文章有讲过);LSP 的 clientserver 之间通信是 LSP 协议

LSP 的 client 和 server 是怎么通信的?

client 采用一问一答的方式,数据结构采用 JSON-RPC 的方式通信。通过 id 标识请求,

LSP server 采用的是懒加载的方式,等有需求来了,就会初始化

注册 provider

const server = createTypescriptLspServer(onNotification, onServerRequest)

context.subscriptions.push(
  vscode.languages.registerDefinitionProvider(['typescript', 'typescriptreact'], {
    async provideDefinition(document, position) {
      // 获取lsp server的连接
      const c = server.getConnection() || (await server.ensureConnection(findRoot(document.uri.path)))
    }
  })
)

在 vscode 调用provideDefinition时候,才会真正初始化

启动连接

const c = server.getConnection() || (await server.ensureConnection(findRoot(document.uri.path)))

刚开始server.getConnection()的返回值是空的,逻辑会走到server.ensureConnection

const cp = require('child_process')
const path = require('path')
const { createConnection } = require('./lspProtocol')
const { pathToUri } = require('./lspUtils')

function createTypescriptLspServer(onNotification, onServerRequest) {
  let conn = null
  let starting = null

  function ensureConnection(rootPath) {
    if (conn) return Promise.resolve(conn)
    if (starting) return starting

    const cli = require.resolve('typescript-language-server/lib/cli.mjs')
    console.log('[ts-lsp] spawning server:', cli, 'root=', rootPath)
    const child = cp.spawn(process.execPath, [cli, '--stdio'], {
      // 让 Electron 二进制以 Node 模式运行子进程(VSCode 同款手法)
      env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
      stdio: ['pipe', 'pipe', 'inherit']
    })

    child.on('exit', code => {
      console.log('[ts-lsp] server exited', code)
      conn = null
      starting = null
    })

    const c = createConnection(child, onNotification, onServerRequest)
    starting = c
      .request('initialize', {
        processId: process.pid,
        rootUri: pathToUri(rootPath),
        workspaceFolders: [{ uri: pathToUri(rootPath), name: path.basename(rootPath) }],
        capabilities: {
          textDocument: {
            synchronization: { dynamicRegistration: false },
            definition: {},
            publishDiagnostics: {}
          }
        }
      })
      .then(() => {
        c.notify('initialized', {})
        conn = c
        starting = null
        console.log('[ts-lsp] initialized')
        return c
      })

    return starting
  }

  return {
    ensureConnection,
    getConnection() {
      return conn
    }
  }
}

module.exports = { createTypescriptLspServer }

为了方便理解,我就把所有的代码都放出来了。直接看ensureConnection的逻辑

  • 如果有连接,就返回 conn
  • 如果没有,就看是否是正在连接的。因为在连接建立起来之前,可能会有两个连续的请求打过来
  • 建立连接就是以 node 程序运行子进程的方式,执行一个 js 文件。

createConnection对子进程的发送消息接送消息做了一层封装。暴露出来两个方法requestnotifyconn 的就是这个东西

可以看到,在建立成功之后,就向 server 发送了两个消息,一个是initialize,在初始化完成后,再发送了个initialized

握手

其实握手就是初始化。没啥好讲的,可以接着这个机会讲讲 JSON-RPC 收发信息是什么样的

he/** mini JSON-RPC over stdio(LSP 分帧) */
function createConnection(child, onNotification, onServerRequest) {
  let buffer = Buffer.alloc(0)
  let contentLength = -1
  const pending = new Map()
  let nextId = 1

  function send(obj) {
    const json = JSON.stringify(obj)
    child.stdin.write('Content-Length: ' + Buffer.byteLength(json, 'utf8') + '\r\n\r\n' + json)
  }

  function dispatch(msg) {
    if (msg.id !== undefined && msg.method !== undefined) {
      // 服务器 → 客户端 请求:必须回应,否则服务器可能卡住
      Promise.resolve(onServerRequest(msg.method, msg.params)).then(result =>
        send({ jsonrpc: '2.0', id: msg.id, result: result === undefined ? null : result })
      )
    } else if (msg.id !== undefined) {
      const p = pending.get(msg.id)
      pending.delete(msg.id)
      if (p) {
        if (msg.error) p.reject(new Error(msg.error.message || 'LSP error'))
        else p.resolve(msg.result)
      }
    } else if (msg.method) {
      onNotification(msg.method, msg.params)
    }
  }

  child.stdout.on('data', chunk => {
    buffer = Buffer.concat([buffer, chunk])
    for (;;) {
      if (contentLength < 0) {
        const headerEnd = buffer.indexOf('\r\n\r\n')
        if (headerEnd < 0) break
        const header = buffer.slice(0, headerEnd).toString('ascii')
        const m = /Content-Length:\s*(\d+)/i.exec(header)
        contentLength = m ? parseInt(m[1], 10) : 0
        buffer = buffer.slice(headerEnd + 4)
      }
      if (buffer.length < contentLength) break
      const body = buffer.slice(0, contentLength).toString('utf8')
      buffer = buffer.slice(contentLength)
      contentLength = -1
      let msg
      try {
        msg = JSON.parse(body)
      } catch {
        continue
      }
      dispatch(msg)
    }
  })

  return {
    request(method, params) {
      const id = nextId++
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject })
        send({ jsonrpc: '2.0', id, method, params })
      })
    },
    notify(method, params) {
      send({ jsonrpc: '2.0', method, params })
    }
  }
}

module.exports = { createConnection }

requestnotify都是发送消息,只不过 request 是需要回复的,所以将请求 id 放进了 pending 中存起来。而 notify 是不需要的,直接发出去就完事

发送消息都会经过send函数,将参数 string 化,然后拼接成Content-Length: ${length}/r/n/r/n${jsonData}

这个数据格式和 DAP RPC 传输的格式是一模一样的,感兴趣的话,可以翻看我其他的文章,有对 DAP 做详细讲解。

DAP 是代码调试的时候,底层的 debug server 和 debug adapter 通信的一种协议。vscode 对调试做的架构设计和 lsp 的设计几乎是一样的,将 vscode 和语言调试服务用一套标准协议连接起来,为的就是 vscode 可以调试任意语言程序

接收数据会经过child.stdout.on('data')的回调函数,将接收的字符串转成一个完整的数据对象,然后才会交给dispatch做具体的消息类型判断。

处理字符串的过程,因为底层是 TCP 通信,并不会保证上层消息的完整性,只保证数据的无误。所以可能会出现半包和粘包的问题,这里就是出处理这个问题的。

具体的处理细节,在讲 DAP 的文章提到过,感兴趣可以看看

初始化发送了很多信息过去:

c.request('initialize', {
    processId: process.pid,
    rootUri: pathToUri(rootPath),
    workspaceFolders: [{ uri: pathToUri(rootPath), name: path.basename(rootPath) }],
    capabilities: {
      textDocument: {
        synchronization: { dynamicRegistration: false },
        definition: {},
        publishDiagnostics: {}
      }
    }
  })
  .then(() => {
    c.notify('initialized', {})
    conn = c
    starting = null
    console.log('[ts-lsp] initialized')
    return c
  })

这里告诉 lsp server:

  • 我是客户端
  • 我的工作区 root 是哪里
  • 我支持文档同步
  • 我需要 definition
  • 我能接收 diagnostics

启动完成后,发送initialized 通知,这样就算 LSP Server 启动完成了。

总结

这一篇我们从代码跳转和代码诊断开始,拆开了 VSCode 语言服务背后的几层结构。

Monaco 本身提供了基础编辑能力,也内置了一些 worker,可以提供轻量级语言能力。但在完整的编辑器场景里,仅靠 Monaco worker 还不够,因为真正的项目级语言理解需要读取 tsconfig、分析跨文件依赖、维护项目状态。

所以 VSCode 引入了 Provider 和 LSP。Provider 是编辑器暴露给插件的能力接口,比如“给我当前位置的定义”“给我这个文件的诊断信息”;LSP 则把这些语言能力进一步标准化,让插件可以作为 client,去连接真正的 language server。

最终可以把这套链路理解成:

  • Monaco 负责编辑器显示
  • Provider 负责承接语言能力
  • LSP Client 负责协议转发
  • Language Server 负责真正理解代码

这样 VSCode 不需要为每种语言都写一套专用逻辑,只要提供统一的接入方式,就可以让不同语言通过各自的语言服务器接进来。这就是 LSP 最核心的价值。