引入gRPC

15 阅读7分钟

引入gRPC

gRPC是Google开源的高性能RPC框架,它使用二进制Proto Buffer数据序列化替换JSON格式。本章我们会引入该框架,并且将在webview与vscode extension host之间消息通信中使用gRPC。

添加需要的以来包

package.json

...
"devDependencies": {
  ...
  "globby": "^14.0.2",
  "chalk": "5.6.2",
  "grpc-tools": "^1.13.0",
  "ts-proto": "^2.6.1"
},
"dependencies": {
  ...
  "@grpc/grpc-js": "^1.9.15"
}

webview-ui/package.json

"dependencies": {
  ...
  "uuid": "^9.0.1"
},
"devDependencies": {
  ...
  "@types/uuid": "^9.0.8"
}

运行npm run install:all安装依赖

添加编译Proto Buffer的脚本

根目录下添加scripts目录,从cline目录拷贝以下四个mjs文件过来进行裁剪(目前我们不需要nice-grpc和host-bridge相关内容, 可以直接查看minicline中已经改好的mjs文件内容)。

package.json中添加一个新的protos命令如下。

"scripts": {
  ...
  "lint": "eslint src --ext ts",
  "protos": "node scripts/build-proto.mjs"
},

运行npm run protos会有如下报错信息,不要紧,马上我们会添加第一个proto定义。

PS D:\git\minicline> npm run protos       

> minicline@0.0.1 protos
> node scripts/build-proto.mjs

Missing input file.
Error generating TypeScript for proto files: Error: Command failed: D:\git\minicline\node_modules\grpc-tools\bin\protoc --proto_path="D:\git\minicline\proto" --plugin=protoc-gen-ts_proto="D:\git\minicline\node_modules\.bin\protoc-gen-ts_proto.cmd" --ts_proto_out="D:\git\minicline\src\shared\proto" --ts_proto_opt=env=node,esModuleInterop=true,outputServices=generic-definitions,outputIndex=true,useOptionals=none,useDate=false
    at genericNodeError (node:internal/errors:983:15)
    at wrappedFn (node:internal/errors:537:14)
    at checkExecSyncError (node:child_process:882:11)
    at execSync (node:child_process:954:15)
    at tsProtoc (file:///D:/git/minicline/scripts/build-proto.mjs:67:3)
    at compileProtos (file:///D:/git/minicline/scripts/build-proto.mjs:86:2)
    at async main (file:///D:/git/minicline/scripts/build-proto.mjs:37:2) {
  status: 1,
  signal: null,
  output: [ null, null, null ],
  pid: 28476,
  stdout: null,
  stderr: null
}

编写第一个Proto Buffer模型

现在我们编写我们的第一个proto定义,在根目录下新建proto目录,在下边新建minicline子目录(前边我们添加的mjs脚本会递归遍历proto目录,编译所有*.proto文件),我们在下边新建一个weather.proto文件,定义我们的方法和消息体。

proto/minicline/weather.proto

syntax = "proto3";

package minicline;

service WeatherService {
    rpc getWeather(GetWeatherRequest) returns (GetWeatherResponse);
}

message GetWeatherRequest {
  string location = 1;
  string unit = 2;
}

message GetWeatherResponse {
    string skytext = 1;
    string temperature = 2;
    string degreeType = 3;
}

上边的定义很简单,我们首先定义了一个rpc服务getWeather,参数是GetWeatherRequest,返回值是GetWeatherResponse。 然后我们分别定义了参数和返回值的消息体。 参数内容分别是我们用到的location和unit,类型均为string。 返回值我们返回显示需要用到的skytext,temperature和degreeType,类型也均为string。

再次运行npm run protos,我们看到如下输出。

PS D:\git\minicline> npm run protos

> minicline@0.0.1 protos
> node scripts/build-proto.mjs

Compiling Protocol Buffers...
Processing 1 proto files from D:\git\minicline\proto
Generated ProtoBus files at:
- D:\git\minicline\webview-ui\src\services\grpc-client.ts
- D:\git\minicline\src\generated\hosts\vscode\protobus-service-types.ts
- D:\git\minicline\src\generated\hosts\vscode\protobus-services.ts

其中的webview-ui\src\services\grpc-client.ts是我们一会需要在react中调用的方法WeatherServiceClient.getWeather,内容如下

// GENERATED CODE -- DO NOT EDIT!
// Generated by scripts\generate-protobus-setup.mjs
import * as proto from "@shared/proto/index"
import { ProtoBusClient, Callbacks } from "./grpc-client-base"

export class WeatherServiceClient extends ProtoBusClient {
	static override serviceName: string = "minicline.WeatherService"
    static async getWeather(request: proto.minicline.GetWeatherRequest): Promise<proto.minicline.GetWeatherResponse> {
		return this.makeUnaryRequest("getWeather", request, proto.minicline.GetWeatherRequest.toJSON, proto.minicline.GetWeatherResponse.fromJSON)
	}
}

使用gRPC改写webview端

上边自动生成的代码webview-ui\src\services\grpc-client.ts还无法编译通过,里边引用了一个 ./grpc-client-base,现在让我们添加这个文件。

webview-ui/src/services/grpc-client-base.ts

import { v4 as uuidv4 } from "uuid";
import { vscode } from "../utilities/vscode";

const encodeMessage:Function = <T>(message: T, _encoder: (_: T) => unknown) => message;
const decodeMessage:Function = <T>(message: any, _decoder: (_: { [key: string]: any }) => T) => message;

...

export abstract class ProtoBusClient {
	static serviceName: string;

	static async makeUnaryRequest<TRequest, TResponse>(
		methodName: string,
		request: TRequest,
		encodeRequest: (_: TRequest) => unknown,
		decodeResponse: (_: { [key: string]: any }) => TResponse,
	): Promise<TResponse> {
		return new Promise((resolve, reject) => {
			const requestId = uuidv4();

			// Set up one-time listener for this specific request
			const handleResponse = (event: MessageEvent) => {
				const message = event.data;
				if (message.type === "grpc_response" && message.grpc_response?.request_id === requestId) {
					// Remove listener once we get our response
					window.removeEventListener("message", handleResponse);
					if (message.grpc_response.message) {
						const response = decodeMessage(message.grpc_response.message, decodeResponse);
						resolve(response);
					}
                    ...
				}
			}

			window.addEventListener("message", handleResponse);
			vscode.postMessage({
				type: "grpc_request",
				grpc_request: {
					service: this.serviceName,
					method: methodName,
					message: encodeMessage(request, encodeRequest),
					request_id: requestId
				},
			});
		})
	}
}

这个文件非常关键,是webview端和extension host端进行通信的基础,我们自动生成grpc-client.ts会调用makeUnaryRequest方法,这个方法主要干了下边两件事。

1. 会调用window.addEventListener("message", handleResponse),自动注册一个监听器,该监听器的注册函数在收到期待的消息响应后返回消息体,并且注销该监听器。
2.extension host发送一个类型为grpc_request的消息,其中包含extension host端需要处理该消息请求的服务及方法,还有具体请求的消息体内容。

接下来我们修改我们的react应用,使用自动生成的grpc-client.ts中的代码。

webview-ui/src/App.tsx

首先我们添加一个下边的函数调用WeatherServiceClient.getWeather, 我们使用react的useCallback封装我们的调用,在异步函数返回时显示结果。

const handleCheckWeatherMessage = useCallback(
  async (locationValue: string, unitValue: string) => {
    let messageSent = false;
    const result:GetWeatherResponse = await WeatherServiceClient.getWeather(
      GetWeatherRequest.create({
      location: locationValue,
      unit: unitValue
      }),
    );
    displayWeatherData(result);
  }, []
);

然后修改checkWeather函数如下,删掉之前的vscode.postMessage,改为调用上边的handleCheckWeatherMessage,因为vscode.postMessage已经改为在我们ProtoBusClient.makeUnaryRequest中调用了。

function checkWeather() {
  const location = document.getElementById("location") as TextField;
  const unit = document.getElementById("unit") as Dropdown;
  handleCheckWeatherMessage(location.value, unit.value);
  displayLoadingState();
}

下一步删掉我们之前定义的数据结构WeatherData,改为自动生成的GetWeatherResponse,并且修改对应的函数参数类型。

import { GetWeatherRequest, GetWeatherResponse } from "@shared/proto/minicline/weather";

function displayWeatherData(weatherData: GetWeatherResponse) {
}
function getWeatherSummary(weatherData: GetWeatherResponse) {
}
function getWeatherIcon(weatherData: GetWeatherResponse) {
}

最后,删除我们之前在程序启动时注册事件监听器,因为我们已经在调用ProtoBusClient.makeUnaryRequest时动态注册和注销事件监听器了。

执行npm run build:webview重新编译react应用

使用gRPC改写extension host端

现在我们从webview往extension host端发送的消息格式已经发生了变化,让我们修改下WeatherViewProvider._setWebviewMessageListener函数,先打印以下收到消息,确认webview端已经成功通过调用自动生成的代码发送了消息。

private _setWebviewMessageListener(webviewView: WebviewView) {
  webviewView.webview.onDidReceiveMessage(
    (message: any) => {
      console.log("收到webview消息", message);
    },
    undefined,
    this._disposables
  );
}

重新启动插件,点击webview中的Check按钮,如果一切正常,在DEBUG CONSOLE中会看到如下图内容

chapter-4-1.png

接下来我们修改WeatherViewProvider处理新的消息体。

src/providers/WeatherViewProvider

import { WebviewMessage } from "@/shared/WebviewMessage";
import { ExtensionMessage } from "@/shared/ExtensionMessage";
import { Controller } from "@/core/controller";
import { handleGrpcRequest } from "@/core/controller/grpc-handler";

export class WeatherViewProvider implements WebviewViewProvider {
  ...
  private webview?: WebviewView;
  private controller?: Controller;
  private _disposables: Disposable[] = [];

  constructor(private readonly _extensionUri: Uri, private readonly _context: ExtensionContext) {
    // Create controller with cache service
    this.controller = new Controller(_context);
  }
  ...

  private _setWebviewMessageListener(webviewView: WebviewView) {
    webviewView.webview.onDidReceiveMessage(
      (message: any) => {
        this.handleWebviewMessage(message);
      },
      undefined,
      this._disposables
    );
  }

  async handleWebviewMessage(message: WebviewMessage) {
    const postMessageToWebview = (response: ExtensionMessage) => this.postMessageToWebview(response);
    switch (message.type) {
      case "grpc_request": {
        if (message.grpc_request) {
          if (this.controller) {
            await handleGrpcRequest(this.controller, postMessageToWebview, message.grpc_request);
          }
        }
        break;
      }
      default: {
        console.error("Received unhandled WebviewMessage type:", JSON.stringify(message));
      }
    }
  }

  private async postMessageToWebview(message: ExtensionMessage): Promise<boolean | undefined> {
    return this.webview?.webview.postMessage(message);
  }
}

上边代码我们新的函数handleWebviewMessage处理webview端发送的请求,并且请求处理完毕后调用postMessageToWebview给webview发送消息,同时为了更好的区分webview和extension的消息,我们定义了数据结构WebviewMessage和ExtensionMessage。

处理gRPC的代码逻辑在grpc-handler中,文件内容如下.

src/core/controller/grpc-handler.ts

import { serviceHandlers } from "@/generated/hosts/vscode/protobus-services";

export type PostMessageToWebview = (message: ExtensionMessage) => Thenable<boolean | undefined>;

export async function handleGrpcRequest(
	controller: Controller,
	postMessageToWebview: PostMessageToWebview,
	request: GrpcRequest,
): Promise<void> {
	await handleUnaryRequest(controller, postMessageToWebview, request);
}

async function handleUnaryRequest(
	controller: Controller,
	postMessageToWebview: PostMessageToWebview,
	request: GrpcRequest,
): Promise<void> {
	try {
		// Get the service handler from the config
		const handler = getHandler(request.service, request.method);
		// Handle unary request
		const response = await handler(controller, request.message);
		// Send response to the webview
		await postMessageToWebview({
			type: "grpc_response",
			grpc_response: {
				message: response,
				request_id: request.request_id,
			},
		});
	} catch (error) {
        ...
	}
}

function getHandler(serviceName: string, methodName: string): any {
	const serviceConfig = serviceHandlers[serviceName];
	const handler = serviceConfig[methodName];
	return handler;
}

函数handleUnaryRequest根据请求的service和method,找到本地对应的处理函数handler,将消息体的具体内容request.message交由该handler处理。

让我们查看**@/generated/hosts/vscode/protobus-services**中serviceHandlers的内容。

// Weather Service
import { getWeather } from "@core/controller/weather/getWeather"

const WeatherServiceHandlers: serviceTypes.WeatherServiceHandlers = {
    getWeather: getWeather,
} 

export const serviceHandlers: Record<string, any> = {
    "minicline.WeatherService": WeatherServiceHandlers,
}

可以发现,当我们使用参数"minicline.WeatherService"和"getWeather"获取handler时,最终我们返回的是在@core/controller/weather/getWeather下的getWeather函数。所以,我们需要在src/core/controller/weather目录下到处一个名为getWeather的处理函数,让我们在该目录下新建一个getWeather.ts,具体内容如下。

src/core/controller/weather/getWeather.ts

import { GetWeatherRequest, GetWeatherResponse } from "@/shared/proto/minicline/weather";
import { Controller } from "..";

import * as weather from "weather-js";

export async function getWeather(_controller: Controller, request: GetWeatherRequest): Promise<GetWeatherResponse> {
    return new Promise((resolve, reject) => {
        weather.find({ search: request.location, degreeType: request.unit }, (err: any, result: any) => {
            // Get the weather forecast results
            const weatherForecast = result[0];
            resolve(GetWeatherResponse.create({
                skytext: weatherForecast.current.skytext,
                temperature: weatherForecast.current.temperature,
                degreeType: weatherForecast.location.degreetype
            }));
        });
    });
}

大家可能注意到,在我们的代码里用到了一个Controller的类,这个类是cline里边的核心类,整个cline的生命周期就是围绕它而运行的,但是目前我们的代码没有使用到它任何功能,所以我们先在src/core/contorller/index.ts中新建一个空的类,内容如下

import * as vscode from "vscode";

export class Controller {
    constructor(readonly context: vscode.ExtensionContext) {
    }
}

再次运行我们的插件,点击Check按钮,webview将刷新显示天气情况,如下图所示。

chapter-2-4.png

总结

本章我们引入了gRPC,利用proto定义自动生成代码,同时在webview端和extension host端调用自动生成的方法和数据结构进行通信。下一章开始我们将添加新的proto定义,来和本地的ollama进行交互。