VSCode架构分析-Electron项目跨平台最佳实践

VSCode架构分析-Electron项目跨平台最佳实践

前言

VSCode 可以说是最著名的 Electron 应用程序。自开源以来,由于微软的不断改进和新功能的添加,它已成为世界上最成功的IDE(编辑器)之一。现在,VSCode不再只是一个本地应用,而是支持Web、Native、Remote场景的一整套产品解决方案。目前,VSCode产品线包括VSCode、vscode.dev、github.dev、code-server等产品。令人惊讶的是,这些应用程序都是在非 monorepo 存储库中开发的,并且它们的大部分功能是同构的。本文将展示 VSCode 采用了哪些架构设计来实现同构跨平台(Native & Web & Remote)能力,并辅以少量源码来分析各个设计理念的意义。本文假设读者对 Electron 有基本的了解。

在进行具体的架构分析之前,我们需要先明确同构和跨平台的概念。

同构

同构是指开发可以在不同平台上运行的程序。例如,开发一段js代码,可以同时供Web服务器和基于node.js开发的浏览器使用。由于Web端天然受限于一些系统调用(如fs读取等),因此在VSCode中不可能实现完全同构的代码。本文提到的同构更多是指如何抽象出公共的能力,以减少不同平台之间的代码差异量。

跨平台

受益于 Electron 基于 Chromium 的架构,Electron App 天然支持在不同操作系统下运行,因此跨平台概念不是本文的重点。本文提到的跨平台更多是指如何保证应用程序能够同时在Electron App和浏览器中打开。换句话说,跨平台是指跨电子平台和网络平台。另外,在 VSCode 的设计中,还额外增加了 Remote 的跨平台要求,即支持本地 VSCode(Web & Native)连接到云机器。简而言之,VSCode的跨平台本质就是实现以下两个能力:

  1. Web 与 Native 之间的同构
  2. 本机与远程之间的同构

Web 与 Native 之间的同构

忽略依赖运行环境是导致Electron Render进程无法在浏览器中运行的常见因素。另一方面,跨平台能力的核心还在于依赖关系的管理。例如,假设我们要实现点击按钮后向用户提示弹窗你好的功能。一个非常直接的实现方法如下:

// Electron Render environment
function onClickButton() {
   ipcRender.invoke("showMessage", "hello!"); // it works
}
button.onclick = onClickButton;

然而,当我们在浏览器中运行项目时,这一行简单的代码ipcRender.invoke就会导致项目报错。在这种具体情况下,可能只会导致按钮点击失败,但是如果我们的初始化过程也依赖于ipc调用呢?例如,初始化时读取用户的配置语言时,这种场景下可能会出现错误,导致页面白屏。

为了避免这些现象,VSCode做了以下设计:

  • 从文件结构明确代码运行环境
  • 通过依赖注入实现控制反转
  • 基于插件机制,为特定平台注册特殊能力

项目文件组织结构设计及依赖控制

观察VSCode核心逻辑实现代码文件夹vscode/src/vs/platform,可以发现每个子功能文件夹下都固定放置了browser、common、 Electron-main、Electron-sandbox、node、test文件夹,很明显代表了代码的运行环境在文件夹中。

➜  vscode git:(main) ✗ tree src/vs/platform -L 2
src/vs/platform
├── accessibility
│   ├── browser
│   ├── common
│   └── test
├── action
│   └── common
├── actions
│   ├── browser
│   ├── common
│   └── test
├── assignment
│   └── common
├── backup
│   ├── common
│   ├── electron-main
│   ├── node
│   └── test
├── checksum
│   ├── common
│   ├── node
│   └── test
├── clipboard
│   ├── browser
│   ├── common
│   └── test
## ...
├── windows
│   ├── electron-main
│   ├── node
│   └── test
├── workspace
│   ├── common
│   └── test
└── workspaces
    ├── common
    ├── electron-main
    ├── node
    └── test

273 directories, 0 files

这个设计的关键是为后续的几种治理方法提供关键的元信息(运行环境)。作为基石,保证了依赖注入功能的可行性。其具体价值将在后续内容中更加清晰地展现。

依赖注入

依赖注入是整个VSCode跨平台架构中最重要的部分。可以说,其他设计只是辅助或补充而已。本文不详细分析依赖注入的概念及其在 VSCode 中的具体实现。

我们回到上面提到的例子,分别用简单的思路和依赖注入的思路来实现跨平台的按钮提示功能,从而观察依赖注入的优势。

实施简单

思路:注意在Web环境下不能调用ipcRender,所以用if else来区分不同环境下的执行逻辑。

function onClickButton() {
  if (CURRENT_ENVIRONMENT === 'electron-browser') // electron
    (await import('electron')).ipcRender.invoke("showMessage", 'hello!');
  else // web
    alert('hello')
}
button.onclick = onClickButton

虽然它实现了所需的功能,但也保证了代码可以在Web&Electron中运行。但随着ipcRender的使用量增加,代码的圈复杂度会明显增加,从而影响项目的可读性和效率。而且由于没有统一的架构设计,实现调用的方法也会千奇百怪,从而大大提高管理后续功能的能力(换句话说,接口升级困难)。

依赖注入实现

思路:抽象一个IMessageService接口,定义了showMessage方法,分别在Electron-Sandbox和Web下实现该接口,在视图中获取当前环境实例化的对象并绑定该方法。

// common/message.ts
const IMessageService = Symbol("IMessageService");
interface IMessageService {
  showMessage(message: string): void;
}

// electron-sandbox/messageService.ts
class MessageService implements IMessageService {
  showMessage(message: string): void {
    ipcRender.invoke("showMessage", message);
  }
}
collection.registry(IMessageService, new MessageService());

// browser/messageService.ts
class MessageService implements IMessageService {
  showMessage(message: string): void {
    alert(message);
  }
}
collection.registry(IMessageService, new MessageService());

// browser/button.ts
const messageService = collection.get(IMessageService);
button.onclick = function () {
  messageService.showMessage("hello");
};

分析

对于这么简单的功能,依赖注入的实现确实比较复杂,需要更多的代码。但是,如果只关注核心逻辑(browser/button.ts 文件),依赖注入模式显然更清晰、更简洁。而且依赖注入的开发成本是一次性的:实现依赖注入后,调用相应的服务方法时不需要添加if else判断。从长远来看,整个项目的代码量和复杂度肯定会下降。项目的复杂度只与代码行数正相关,但不同项目的复杂度随代码行数有着完全不同的增长曲线。从这个角度来看,依赖注入显然是大型项目的理想解决方案。

跨进程服务间调用

依赖注入带来的另一个好处是它大大简化了跨进程调用服务的成本。受益于依赖注入架构,VSCode的功能以Class的形式组织,具有相对一致的结构和通用约定。基于这些背景,VSCode可以轻松完成跨进程服务的代理和封装,从而方便地完成跨进程服务调用。与Electron Channel的手动注册相比,这种基于Proxy的方法不仅享有TypeScript强大的类型检查支持,而且还自动化了注册通道的过程。

export function fromService<TContext>(
  service: unknown,
  options?: ICreateServiceChannelOptions
): IServerChannel<TContext> {
  const handler = service as { [key: string]: unknown };
  const disableMarshalling = options && options.disableMarshalling;

  // Buffer any event that should be supported by
  // iterating over all property keys and finding them
  const mapEventNameToEvent = new Map<string, Event<unknown>>();
  for (const key in handler) {
    if (propertyIsEvent(key)) {
      mapEventNameToEvent.set(
        key,
        Event.buffer(handler[key] as Event<unknown>, true)
      );
    }
  }

  return new (class implements IServerChannel {
    listen<T>(_: unknown, event: string, arg: any): Event<T> {
      const eventImpl = mapEventNameToEvent.get(event);
      if (eventImpl) {
        return eventImpl as Event<T>;
      }

      if (propertyIsDynamicEvent(event)) {
        const target = handler[event];
        if (typeof target === "function") {
          return target.call(handler, arg);
        }
      }

      throw new Error(`Event not found: ${event}`);
    }

    call(_: unknown, command: string, args?: any[]): Promise<any> {
      const target = handler[command];
      if (typeof target === "function") {
        // Revive unless marshalling disabled
        if (!disableMarshalling && Array.isArray(args)) {
          for (let i = 0; i < args.length; i++) {
            args[i] = revive(args[i]);
          }
        }

        return target.apply(handler, args);
      }

      throw new Error(`Method not found: ${command}`);
    }
  })();
}

export function toService<T extends object>(
  channel: IChannel,
  options?: ICreateProxyServiceOptions
): T {
  const disableMarshalling = options && options.disableMarshalling;

  return new Proxy(
    {},
    {
      get(_target: T, propKey: PropertyKey) {
        if (typeof propKey === "string") {
          // Check for predefined values
          if (options?.properties?.has(propKey)) {
            return options.properties.get(propKey);
          }

          // Dynamic Event
          if (propertyIsDynamicEvent(propKey)) {
            return function (arg: any) {
              return channel.listen(propKey, arg);
            };
          }

          // Event
          if (propertyIsEvent(propKey)) {
            return channel.listen(propKey);
          }

          // Function
          return async function (...args: any[]) {
            // Add context if any
            let methodArgs: any[];
            if (options && !isUndefinedOrNull(options.context)) {
              methodArgs = [options.context, ...args];
            } else {
              methodArgs = args;
            }

            const result = await channel.call(propKey, methodArgs);

            // Revive unless marshalling disabled
            if (!disableMarshalling) {
              return revive(result);
            }

            return result;
          };
        }

        throw new Error(`Property not found: ${String(propKey)}`);
      },
    }
  ) as T;
}

基于插件机制,为特定平台注册特殊能力

受益于 VSCode 精心设计的插件系统,VSCode 可以从代码中提取一些非核心的能力,放到插件中来实现。例如,VSCode 的 JavaScript 调试功能实际上是在内置插件 Node Debug Auto-attach 中实现的。在Web上,您只需从构建的产品中删除此类插件即可删除相关功能。同样的,Web中所必需的一些能力也可以通过内置插件的形式来提供。例如,内置了 GitHub Pull Requests and Issues 插件,安装在 github.dev 下,支持用户方便地在应用程序中进行 Code Review。通过增加或减少内置插件的数量,可以进一步降低VSCode核心代码中逻辑圈的复杂度,从而保证VSCode本身的性能和复杂度。

本机与远程之间的同构

流程结构的抽象

毫无疑问,VSCode Remote系列能力(Remote SSH、code-server…)是其区别于其他IDE的核心竞争能力之一。为了实现远程连接和本地 Electron App 一致的体验,VSCode 封装了一套完整的 ipc 通信协议,以平滑本地 VSCode 和连接的云服务之间的差异。其架构图如下:

VSCode架构分析-Electron项目跨平台最佳实践

用户使用VSCode App时,通过Electron提供的ipc接口与本地运行的VSCode Server进行通信。在代码服务器或SSH模式下,Render进程通过websocket或SSH协议与云端服务器通信。唯一的区别是通信协议不同,调用方法的返回结果基本相同。通过这样的架构设计,VSCode保证了Native和Remote之间体验的一致性,也为VSCode Remote系列能力的成功奠定了坚实的基础。

// src/vs/base/parts/ipc/common/ipc.ts
/**
 * An `IChannel` is an abstraction over a collection of commands.
 * You can `call` several commands on a channel, each taking at
 * most one single argument. A `call` always returns a promise
 * with at most one single return value.
 */
export interface IChannel {
  call<T>(
    command: string,
    arg?: any,
    cancellationToken?: CancellationToken
  ): Promise<T>;
  listen<T>(event: string, arg?: any): Event<T>;
}

/**
 * An `IServerChannel` is the counter part to `IChannel`,
 * on the server-side. You should implement this interface
 * if you'd like to handle remote promises or events.
 */
export interface IServerChannel<TContext = string> {
  call<T>(
    ctx: TContext,
    command: string,
    arg?: any,
    cancellationToken?: CancellationToken
  ): Promise<T>;
  listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}

// src/vs/base/parts/ipc/electron-sandbox/ipc.electron.ts
/**
 * An implementation of `IPCClient` on top of Electron `ipcRenderer` IPC communication
 * provided from sandbox globals (via preload script).
 */
export class Client extends IPCClient implements IDisposable {
  private protocol: ElectronProtocol;

  private static createProtocol(): ElectronProtocol {
    const onMessage = Event.fromNodeEventEmitter<VSBuffer>(
      ipcRenderer,
      "vscode:message",
      (_, message) => VSBuffer.wrap(message)
    );
    ipcRenderer.send("vscode:hello");

    return new ElectronProtocol(ipcRenderer, onMessage);
  }

  constructor(id: string) {
    const protocol = Client.createProtocol();
    super(protocol, id);

    this.protocol = protocol;
  }

  override dispose(): void {
    this.protocol.disconnect();
  }
}

// src/vs/base/parts/ipc/common/ipc.net.ts
export class Protocol extends Disposable implements IMessagePassingProtocol {
  private _socket: ISocket;
  private _socketWriter: ProtocolWriter;
  private _socketReader: ProtocolReader;

  private readonly _onMessage = new Emitter<VSBuffer>();
  readonly onMessage: Event<VSBuffer> = this._onMessage.event;

  private readonly _onDidDispose = new Emitter<void>();
  readonly onDidDispose: Event<void> = this._onDidDispose.event;

  constructor(socket: ISocket) {
    super();
    this._socket = socket;
    this._socketWriter = this._register(new ProtocolWriter(this._socket));
    this._socketReader = this._register(new ProtocolReader(this._socket));

    this._register(
      this._socketReader.onMessage((msg) => {
        if (msg.type === ProtocolMessageType.Regular) {
          this._onMessage.fire(msg.data);
        }
      })
    );

    this._register(this._socket.onClose(() => this._onDidDispose.fire()));
  }

  drain(): Promise<void> {
    return this._socketWriter.drain();
  }

  getSocket(): ISocket {
    return this._socket;
  }

  sendDisconnect(): void {
    // Nothing to do...
  }

  send(buffer: VSBuffer): void {
    this._socketWriter.write(
      new ProtocolMessage(ProtocolMessageType.Regular, 0, 0, buffer)
    );
  }
}

export class Client<TContext = string> extends IPCClient<TContext> {
  static fromSocket<TContext = string>(
    socket: ISocket,
    id: TContext
  ): Client<TContext> {
    return new Client(new Protocol(socket), id);
  }

  get onDidDispose(): Event<void> {
    return this.protocol.onDidDispose;
  }

  constructor(
    private protocol: Protocol | PersistentProtocol,
    id: TContext,
    ipcLogger: IIPCLogger | null = null
  ) {
    super(protocol, id, ipcLogger);
  }

  override dispose(): void {
    super.dispose();
    const socket = this.protocol.getSocket();
    this.protocol.sendDisconnect();
    this.protocol.dispose();
    socket.end();
  }
}

参考

vscode 服务器

贡献

给TA打赏
共{{data.count}}人
人已打赏
0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索