最近我在参与设计一个软件的架构,期间也遇到不少困难,但是只要用心去思考还是可以解决的。前端和隔离层(包括 UI层,文件管理系统,插件系统,数据处理等)的微服务基于 React + Nextjs + Nodejs + Lerna + gRPC,后端协议遵循HTTP/2的长连接及其双向通信。那么不得不说一说 gRPC。也许很多中小型项目都并未采用,它相对于REST是有一些麻烦的地方。但是鉴于大型项目的数据交互,请求与相应,也许它是一个非常好的选择。关于 gRPC 请自行去搜索相关资料了解,这篇文章主要是完整实现一个从零到一的演示。
这个过程中,编译 proto 文件会用到 grpc, grpc-web 和相关的一些插件, 服务测试将使用 envoy 代理和 webpack。好了废话不多说,我们开始吧:
目录结构
grpc-getting-started/
├── README.md
├── LICENSE
├── package.json
├── package-lock.json
├── envoy.yaml
├── server.js
├── build/
├── scripts/
├── dist/
│ ├── client-main.js
│ └── index.html
├── proto/
│ ├── example.proto
│ └── other.proto
├── src/
│ ├── proto/
│ ├── client/
│ └── server/
└──
(1) 定义服务
我们首先定义一个服务,指定可以远程调用的方法及其参数和返回类型。
这是使用在 .proto 文件中使用协议缓冲完成的,它们还用于描述有效负载消息的结构。
创建一个 proto 文件 proto/example.proto
:
// 步骤 1. 基本配置
// ================================================ ====
// 第一行告诉编译器这个文件中使用了什么语法。
// 第二行属于命名空间,用来防止不同的消息类型有命名冲突
syntax = "proto3";
package hello;
// 步骤 2. 定义消息结构
// ================================================ ====
// 这定义了请求负载。 此处进入消息的每个属性都与其类型一起定义。
// 需要为每个属性分配一个唯一的编号,称为标签。 协议缓冲区使用此标记来表示属性,而不是使用属性名称。
// 所以,不像 JSON 我们每次都会传递属性名称 firstName,protocol buffer 会使用数字 1 来表示 firstName。 响应负载定义类似于请求。
message HelloRequest {
string firstName = 1;
string lastName = 2;
}
message HelloResponse {
string greeting = 1;
}
// 步骤 3. 定义服务契约
// ================================================ ====
// 最后,让我们定义服务契约。 对于我们的 HelloService,我们定义了一个 GetHelloReq() 操作:
service HelloService {
rpc GetHelloReq(HelloRequest) returns (HelloResponse);
}
(2) 生成代码 —— 将 .proto
文件编译为 .js
步骤 2.1。 安装 grpc-web 运行时库
$ cd /{your_directory}/grpc-getting-started
$ npm i --save-dev grpc-web
步骤 2.2。 安装生成 TypeScript 的插件 ts-protoc-gen
$ npm i --save-dev ts-protoc-gen @improbable-eng/grpc-web
步骤 2.3。 安装代码生成器插件 protoc
$ PROTOC_ZIP=protoc-22.2-osx-x86_64.zip
$ curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v22.2/$PROTOC_ZIP
$ sudo unzip -o $PROTOC_ZIP -d /usr/local bin/protoc
$ sudo unzip -o $PROTOC_ZIP -d /usr/local 'include/*'
$ rm -f $PROTOC_ZIP
也可以使用如下命令安装(macOS):
$ brew install protobuf
安装完成后查看版本
$ protoc --version
步骤 2.4。 继续安装插件 protoc-gen-js 和 protoc-gen-grpc-web
$ sudo npm i -g protoc-gen-js protoc-gen-grpc-web
步骤 2.5。 编译执行
运行以下命令编译.proto
文件,生成我们可以识别的.js
文件。
$ npm run build:protos
它会生成四个文件:
src/proto/example_pb.js
src/proto/example_pb.d.ts
src/proto/example_pb_service.js
src/proto/example_pb_service.d.ts
可以下载protobuf-javascript进行测试。 教程请访问这里。
$ mkdir src/proto
要生成 protobuf 消息类,请运行以下命令:
$ protoc --proto_path=./proto --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts --js_out=import_style=commonjs,binary:src/proto --ts_out="src/proto" proto/example.proto
要生成 客户端存根,请运行以下命令:
$ protoc --proto_path=./proto --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts --ts_out="service=grpc-web:src/proto" proto/example.proto
(3) 服务器入口
接下来,我们在后端 gRPC 服务中使用 Node 实现我们的 HelloService
接口。 这将处理来自客户的请求。 教程请访问这里。
步骤 3.1。 安装插件 grpc-node
$ npm i --save-dev @grpc/grpc-js @grpc/proto-loader
步骤 3.2。 创建文件 src/server/index.js
:
const path = require('path');
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const PROTO_PATH = path.resolve(__dirname, '../../proto/example.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const newsProto = grpc.loadPackageDefinition(packageDefinition);
/*
{
hello: {
HelloRequest: {
format: 'Protocol Buffer 3 DescriptorProto',
type: [Object],
fileDescriptorProtos: [Array]
},
HelloResponse: {
format: 'Protocol Buffer 3 DescriptorProto',
type: [Object],
fileDescriptorProtos: [Array]
},
HelloService: [class ServiceClientImpl extends Client] {
service: [Object],
serviceName: 'HelloService'
}
}
}
*/
class gRPC extends grpc.Server {
constructor() {
super();
this.addService(newsProto.hello.HelloService.service, {
getHelloReq: this.getHelloReq
});
}
/**
* request handler.
*/
getHelloReq(call, callback) {
const { firstName, lastName } = call.request;
if( firstName !== '' ) {
callback(null, {
greeting: `Hello: ${firstName} ${lastName}`
});
} else {
callback({
message: 'Name not found',
code: grpc.status.INVALID_ARGUMENT
});
}
}
}
function main() {
const server = new gRPC();
server.bindAsync(
'127.0.0.1:9090', grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) throw err;
console.log(`Server running at http://127.0.0.1:${port}`);
server.start();
}
);
}
main();
/*
function copyMetadata(call) {
const metadata = call.metadata.getMap();
const responseMetadata = new grpc.Metadata();
for (let key in metadata) {
responseMetadata.set(key, metadata[key]);
}
return responseMetadata;
}
function getHelloReq(call, callback) {
const { firstName, lastName } = call.request;
if( firstName !== '' ) {
callback(null, {
greeting: `Hello: ${firstName} ${lastName}`
}, copyMetadata(call));
} else {
callback({
message: 'Name not found',
code: grpc.status.INVALID_ARGUMENT
});
}
}
function main() {
const server = new grpc.Server();
server.addService(newsProto.hello.HelloService.service, {
getHelloReq: getHelloReq
});
...
}
*/
(4) 客户端入口
创建文件 src/client/index.js
:
const { HelloRequest } = require('../proto/example_pb.js');
const { HelloServiceClient } = require('../proto/example_pb_service.js');
const client = new HelloServiceClient('http://' + window.location.hostname + ':12345', null, null);
function todo(str1, str2) {
return new Promise((resolve, reject) => {
const req = new HelloRequest();
req.setFirstname(str1);
req.setLastname(str2);
client.getHelloReq(req, {}, function (err, response) {
if (err) {
resolve(err);
//reject(err);
} else {
resolve(response.getGreeting());
}
});
})
}
// 创建一个表单
//===================
const container = document.createElement("div");
const input1 = document.createElement("input");
input1.type = "text";
input1.id = "input1";
input1.placeholder = 'FirstName'
container.appendChild(input1);
const input2 = document.createElement("input");
input2.type = "text";
input2.id = "input2";
input2.placeholder = 'LastName'
container.appendChild(input2);
const hr = document.createElement("hr");
container.appendChild(hr);
const btn = document.createElement("button");
btn.innerHTML = "Submit";
btn.id = "btn";
container.appendChild(btn);
document.body.appendChild(container);
const $btn = document.getElementById('btn');
$btn.addEventListener('click', (e) => {
e.preventDefault();
main(document.getElementById('input1').value, document.getElementById('input2').value);
});
// 显示后端服务器响应的内容
//===================
async function main(str1, str2) {
const data = await todo(str1, str2);
console.log(data);
const div = document.createElement("h3");
div.innerHTML = data;
document.body.appendChild(div);
}
(5) 生成客户端文件
最后,将所有这些放在一起,我们可以将所有相关的 JS 文件编译成一个可以在浏览器中使用的 JS 库。
步骤 5.1。 安装依赖
$ npm i --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin browserify google-protobuf
步骤 5.2。 为自定义 webpack 配置创建一个文件
build/client.config.js
:
const path = require('path');
const HtmlWebpackPlugin = require("html-webpack-plugin");
const clientPort = process.env.PORT || 10005;
const clientHost = process.env.HOST || 'localhost';
const devMode = process.env.NODE_ENV !== 'production';
module.exports = {
mode: 'production',
performance: {
hints: !devMode ? "warning" : false
},
resolve: {
fallback: {
"fs": false
},
extensions: ['.js']
},
entry: {
'client-main': './src/client/index.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../dist')
},
plugins: [
new HtmlWebpackPlugin({
title: "Webpack Output",
}),
],
devServer: {
// Enable compression
compress: false,
//
host: clientHost,
port: clientPort
}
};
步骤 5.3。 编译JS库
$ npm run build:client
或者
$ npx webpack --progress --mode production --config ./build/client.config.js
它将生成一个 js 文件 dist/client-main.js
和一个 html 文件 dist/index.html
步骤 5.4。 Webpack 服务器配置
创建一个新的服务器文件 server.js
const Webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const webpackConfig = require('./client.config.js');
const compiler = Webpack(webpackConfig);
const devServerOptions = { ...webpackConfig.devServer, open: true };
const server = new WebpackDevServer(devServerOptions, compiler);
const runServer = async () => {
console.log('Starting server...');
await server.start();
};
runServer();
(6) 部署后端服务并测试
步骤 6.1。 安装 envoy
编译 envoy 需要完整安装 Xcode.app。 仅安装命令行工具是不够的。
如 macOS 12.6.3,需要下载: Xcode_14.2
$ brew update
$ brew install envoy
$ envoy --version
$ go version
⚠️ a) 如果运行
brew update
或brew install envoy
出错,输入以下命令修复它:macOS 或 Linux
打开你的终端并执行
$ xcode-select --install $ cd /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core/ $ git pull $ brew update-reset $ brew install envoy
⚠️ b) 使用go启动服务时报错 dial tcp xx.xx.xx.xx:443: i/o timeout
手动配置源
$ export GO111MODULE=on $ export GOPROXY=https://goproxy.cn
以上配置步骤只会在当前终端生效,如何长期生效,这样就不用每次都配置环境变量了。
$ echo "export GO111MODULE=on" >> ~/.profile $ echo "export GOPROXY=https://goproxy.cn" >> ~/.profile $ source ~/.profile
⚠️ c)
bazelisk
不支持旧版本。升级您的操作系统。
步骤 6.2。 配置 Envoy 代理
创建一个新文件 envoy.yaml
:
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 127.0.0.1, port_value: 12345 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: hello_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: hello_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
# win/mac hosts: Use address: host.docker.internal instead of address: localhost in the line below
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 9090
⚠️ 如果您在 Mac/Windows 上运行 Docker,请将最后一个地址:
localhost
更改为
... socket_address: address: host.docker.internal
或者如果您在 Mac 上的 Docker 版本比 v18.03.0 更早,请将其更改为:
... socket_address: address: docker.for.mac.localhost
步骤 6.3。 运行特使代理。
envoy.yaml 文件将 Envoy 配置为在端口 12345
监听浏览器请求,并将它们转发到端口 9090
。
$ npm run proxy
or
$ envoy -c ./envoy.yaml
步骤 6.4。 当这些都准备好后,您可以打开浏览器选项卡并导航到 http://localhost:10005
- NodeJS gRPC 服务(端口
9090
) - webpack 服务器(端口
10005
)
运行以下命令进行测试:
$ npm run start
或者
$ node ./server.js & node ./src/server/index.js
步骤 6.5。 测试连接
使用下面的命令检测:
$ curl -I http://localhost:12345/hello.HelloService/GetHelloReq?firstName=Amy&lastName=Grant
最后,希望这篇文章对你有用,您可以下载我的开源文件包 github.com/xizon/grpc-…
本文首发于没位道 - Chuckie Chang个人网站,如果您喜欢我的其它文字也可以去博客逛一逛。:)