[Node] 淡如止水 TypeScript (九):通信过程

0. 回顾

上一篇,我们介绍了进程间通信,主进程通过 child_process 启动了 tsserver
主进程通过 child.stdin.write 写内容,向子进程发消息。

child.stdin.write(`${JSON.stringify(openFile)}\n`);

子进程处理完业务逻辑之后,向自己进程的 stdout 发消息,
回调到主进程的 child.stdout.on data 事件监听函数中。

child.stdout.on('data', data => {
  console.log(data.toString());
});

下文我们来跟踪一下这些消息的处理过程,看看有哪些值得注意的地方。

1. 启动调试

以上我们启动了两个 VSCode 实例,分别称为 client 端与 server 端,准备调试 tsserver。
我们看到 server 端的 .vscode/launch.json 是与项目无关的,因此,可以放到 TypeScript 源码仓库的调试配置中。

[Node] 淡如止水 TypeScript (九):通信过程_第1张图片

然后按以下步骤启动调试。

(1)client 端,按 F5 启动调试

[Node] 淡如止水 TypeScript (九):通信过程_第2张图片

client 端执行完 spawn 后, tsserver 就启动了。

(2)server 端,按 F5 attach 到已经启动的 tsserver

[Node] 淡如止水 TypeScript (九):通信过程_第3张图片

我们看到两个 VSCode 实例,都停在了断点处。

(3)server 端 lib/tsserver 的调试,仍然会遇到无法进入 .ts 的问题
与第二篇一样,我们需要在 require 的时候,点击 Step Into,进入 src/compiler/core.ts#L1 中。

[Node] 淡如止水 TypeScript (九):通信过程_第4张图片

[Node] 淡如止水 TypeScript (九):通信过程_第5张图片

2. tsserver 启动事件

tsserver 启动后,在没有收到任何消息时,会先向主进程发送一条消息,
为了理解这条消息的发送逻辑是怎样的,我们需要将 client 端 child.stdin.write 的内容先注释掉。

client 端 index.js 的文件内容修改如下,

const path = require('path');
const { spawn } = require('child_process');

const root = '/Users/.../TypeScript';  // <- 这是 TypeScript 源码仓库的根目录

const child = spawn('node', [
  '--inspect-brk=9002',
  path.join(root, 'bin/tsserver'),
]);

child.stdout.on('data', data => {
  console.log(data.toString());
});

child.on('close', code => {
  console.log(code);
});

// 注释掉了 child.stdin.write

然后我们启动 client 端,再 attach server 端。
通过 “灵犀一指”,我们断定 tsserver 会执行到这里。

[Node] 淡如止水 TypeScript (九):通信过程_第6张图片

发生在 attach 函数中,src/tsserver/server.ts#L325,

class ... implements ITypingsInstaller {
  ...

  attach(projectService: ProjectService) {
    ...
    this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv });
    this.installer.on("message", m => this.handleMessage(m));

    this.event({ pid: this.installer.pid }, "typingsInstallerPid");
    ...
  }

  ...
}

我们来分析调用栈,
bin/tsserver#L2,加载 ../built/local/tsserver.js 文件,

...
require('../built/local/tsserver.js');

VSCode 根据 source map 反查到 src/tsserver/server.ts 文件。

src/tsserver/server.ts#L976,加载过程中会执行,new IOSession

namespace ts.server {
  ...
  const ioSession = new IOSession();
  ...
}

new IOSession 会调用父类 Session 的构造函数,src/tsserver/server.ts#L506,

class IOSession extends Session {
  ...
  constructor() {
    ...
    super({
      ...
    });

    ...
  }
  ...
}
export class Session implements EventSender {
  ...

  constructor(opts: SessionOptions) {
    ...
    this.projectService = new ProjectService(settings);
    ...
  }

  ...
}

Session 构造函数中,会调用 new ProjectService,src/server/editorServices.ts#L430,

export class ProjectService {
  ...

  constructor(opts: ProjectServiceOptions) {
    ...
    this.typingsInstaller.attach(this);
    ...
  }

  ...
}

接着调用了 this.typingsInstaller.attach,src/tsserver/server.ts#L282,

class NodeTypingsInstaller implements ITypingsInstaller {
  ...

  attach(projectService: ProjectService) {
    ...
    this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv });
    this.installer.on("message", m => this.handleMessage(m));

    this.event({ pid: this.installer.pid }, "typingsInstallerPid");
    ...
  }

  ...
}

attach 函数中,又启动了一个子进程,为全局 TypeScript 缓存安装类型依赖。

[Node] 淡如止水 TypeScript (九):通信过程_第7张图片

我们看到新启动的进程 --inspect-brk=9003,相当于这样调用,

$ node --inspect-brk=9003 /Users/.../TypeScript/built/local/typingsInstaller.js \
--globalTypingsCacheLocation /Users/.../Library/Caches/typescript/3.7 \
--typesMapLocation /Users/.../TypeScript/built/local/typesMap.json \

built/local/typingsInstaller.js 会在 /Users/.../Library/Caches/typescript/3.7 这个位置安装依赖。
这里的逻辑暂时先不用在意。

安装完依赖之后,attach 函数调用了 this.event,向 stdout 发消息。
由于主进程中监控了 tsserver 子进程的 stdout 事件。
所以,启动 tsserver 之后,主进程会先收到一条消息。

[Node] 淡如止水 TypeScript (九):通信过程_第8张图片

消息内容如下,

Content-Length: 76

{"seq":0,"type":"event","event":"typingsInstallerPid","body":{"pid":19087}}

3. 与 tsserver 的交互

3.1 command: open

了解了 tsserver 的启动事件之后,client 端就不会被莫名其妙的一条消息搞糊涂了。
现在我们取消 client 端 index.jschild.stdin.write 相关的注释,与上一篇内容一致。

const path = require('path');
const { spawn } = require('child_process');

const root = '/Users/.../TypeScript';  // <- 这是 TypeScript 源码仓库的根目录

const child = spawn('node', [
  '--inspect-brk=9002',
  path.join(root, 'bin/tsserver'),
]);

child.stdout.on('data', data => {
  console.log(data.toString());
});

child.on('close', code => {
  console.log(code);
});

const filePath = path.join(root, 'debug/index.ts');

const openFile = {
  seq: 0,
  type: 'request',
  command: 'open',
  arguments: {
    file: filePath,
  }
};
const getQuickInfo = {
  seq: 1,
  type: 'request',
  command: 'quickinfo',
  arguments: {
    file: filePath,
    line: 1,
    offset: 7
  }
};

child.stdin.write(`${JSON.stringify(openFile)}\n`);
child.stdin.write(`${JSON.stringify(getQuickInfo)}\n`);

重新启动 client 端,然后 attach server 端。
server 端启动事件执行完之后,我们继续运行 client 端到 child.stdin.write 位置。

[Node] 淡如止水 TypeScript (九):通信过程_第9张图片

注意,child.stdin.write 尾部 \n 换行符。

然后我们去 server 端 src/tsserver/server.ts#L576 打个断点,

class IOSession extends Session {
  ...

  listen() {
    rl.on("line", (input: string) => {
      const message = input.trim();
      this.onMessage(message);
    });
    
    ...
  }
}

client 端继续执行,就会发现 server 端跑到了断点中,


[Node] 淡如止水 TypeScript (九):通信过程_第10张图片

我们看到 message 的值正是 child.stdin.write 发送过来的。
接着 server 端会处理这个消息。

不幸的是,这段消息是一个 open command,

{
  seq: 0,
  type: 'request',
  command: 'open',  // open 类型的 command
  arguments: {
    file: filePath,
  }
}

tsserver 对于 open command 并不会向主进程返回消息。
所以,主进程并不会收到任何消息。
我们在 server 端按 F5 让它跑完。

3.2 command: quickinfo

上文我们了解到 child.stdin.write 发送的一条 open command 并没有返回任何消息给主进程,
我们让 server 端代码继续执行了。

现在回到 client 端,继续执行下一条 child.stdin.write

server 端立即收到了新消息,进入断点中,

[Node] 淡如止水 TypeScript (九):通信过程_第11张图片

message 内容正好是 child.stdin.write 写入的内容。

{
  seq: 1,
  type: 'request',
  command: 'quickinfo',  // quickinfo 类型的 command
  arguments: {
    file: filePath,
    line: 1,
    offset: 7
  }
}

这是一条 quickinfo command 执行完毕之后,tsserver 是会向主进程返回消息的。
我们来看会返回什么,于是 server 端按 F5 执行完。

client 端的断点会跑到 child.stdout.on data 事件中,并且会连续进入两次,
第一次,会打印 tsserver 启动事件发回的消息,
第二次,并不是打印 open command 的消息(因为它不返回消息),而是打印了 quickinfo command 返回的消息。

[Node] 淡如止水 TypeScript (九):通信过程_第12张图片

Content-Length: 76

{"seq":0,"type":"event","event":"typingsInstallerPid","body":{"pid":19563}}

Content-Length: 245

{"seq":0,"type":"response","command":"quickinfo","request_seq":1,"success":true,"body":{"kind":"const","kindModifiers":"","start":{"line":1,"offset":7},"end":{"line":1,"offset":8},"displayString":"const i: number","documentation":"","tags":[]}}

最后,我们来看看 quickinfo command 返回了什么内容,
关键内容是 displayString 的内容,

const i: number

这正是我们在 debug/index.ts 文件中,鼠标悬停到 i 标识符上展示的内容,

const i: number = 1;

而我们传入的 quickinfo command 参数中,

{
  seq: 1,
  type: 'request',
  command: 'quickinfo',
  arguments: {
    file: filePath,
    line: 1,   // 第 1 行
    offset: 7  // 第 7 个字符,刚好是 i
  }
}

1 行(line: 1),第 7 个字符(offset: 7),刚好是 i


总结

本文跟踪了 tsserver 启动事件,以及 openquickinfo 两个 command 的消息交互过程。
tsserver 端的业务逻辑,我们没有详细追究。

但是留意到,tsserver 启动时,不接受任何消息,也会主动向主进程发送一条 typingsInstallerPid 消息,

Content-Length: 76

{"seq":0,"type":"event","event":"typingsInstallerPid","body":{"pid":19087}}

其次,open command 并不会返回任何消息,
最后,quickinfo command 会返回 debug/index.ts 中变量 i 鼠标悬停上去展示的内容,
详见 displayString 的值。

Content-Length: 245

{"seq":0,"type":"response","command":"quickinfo","request_seq":1,"success":true,"body":{"kind":"const","kindModifiers":"","start":{"line":1,"offset":7},"end":{"line":1,"offset":8},"displayString":"const i: number","documentation":"","tags":[]}}

参考

TypeScript v3.7.3

你可能感兴趣的:([Node] 淡如止水 TypeScript (九):通信过程)